Files
br c8df9d7651 erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
2026-10-01 22:22:13 +02:00

386 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"crypto/ed25519"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"sultan/pkg/store"
)
var ctx = context.Background()
// fakeDirectory ist ein In-Memory-Ersatz für den Store.
type fakeDirectory struct {
nodes map[string]store.Node // nach Public Key
tenants map[string]store.Tenant
err error // simuliert z. B. eine nicht erreichbare Datenbank
}
func (d *fakeDirectory) GetNodeByPublicKey(_ context.Context, key string) (store.Node, error) {
if d.err != nil {
return store.Node{}, d.err
}
n, ok := d.nodes[key]
if !ok {
return store.Node{}, store.ErrNotFound
}
return n, nil
}
func (d *fakeDirectory) GetTenant(_ context.Context, id string) (store.Tenant, error) {
t, ok := d.tenants[id]
if !ok {
return store.Tenant{}, store.ErrNotFound
}
return t, nil
}
type testEnv struct {
dir *fakeDirectory
signer *Signer
ver *Verifier
now time.Time
}
// newEnv: Mandant t1 im Community-Tier (Homelab) mit registriertem Knoten n1.
func newEnv(t *testing.T) *testEnv {
t.Helper()
priv, err := GenerateKey()
if err != nil {
t.Fatal(err)
}
pub := EncodePublicKey(priv.Public().(ed25519.PublicKey))
env := &testEnv{
dir: &fakeDirectory{
nodes: map[string]store.Node{pub: {ID: "n1", TenantID: "t1", PublicKey: pub, Status: store.NodeOnline}},
tenants: map[string]store.Tenant{"t1": {ID: "t1", Tier: store.TierCommunity, Status: store.TenantActive}},
},
now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC),
}
clock := func() time.Time { return env.now }
env.signer = &Signer{Key: priv, now: clock}
env.ver = &Verifier{Directory: env.dir, now: clock}
return env
}
func (e *testEnv) signed(t *testing.T, method, url, body string) *http.Request {
t.Helper()
var rd io.Reader
if body != "" {
rd = strings.NewReader(body)
}
r := httptest.NewRequest(method, url, rd)
// httptest.NewRequest erzeugt einen Server-Request; zum Signieren
// braucht Sign die Client-Sicht mit Schema.
r.URL.Scheme, r.URL.Host = "https", r.Host
if err := e.signer.Sign(r); err != nil {
t.Fatalf("Sign: %v", err)
}
return r
}
func TestVerify_Valid(t *testing.T) {
env := newEnv(t)
id, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/api/v1/stream?since=5", ""))
if err != nil {
t.Fatalf("Verify: %v", err)
}
if id.Node.ID != "n1" || id.Tenant.ID != "t1" {
t.Errorf("Identität = %+v", id)
}
}
// Auch im Community-/Homelab-Betrieb gibt es keinen Zugang ohne Signatur.
func TestVerify_CommunityTierRequiresSignature(t *testing.T) {
env := newEnv(t)
r := httptest.NewRequest(http.MethodGet, "https://ingress/api/v1/stream", nil)
if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
t.Errorf("unsignierter Request: erwartet ErrUnauthenticated, bekommen %v", err)
}
}
func TestVerify_BodyCoveredAndPreserved(t *testing.T) {
env := newEnv(t)
r := env.signed(t, http.MethodPost, "https://ingress/api/v1/state", `{"labels":{}}`)
if _, err := env.ver.Verify(r); err != nil {
t.Fatalf("Verify: %v", err)
}
body, _ := io.ReadAll(r.Body)
if string(body) != `{"labels":{}}` {
t.Errorf("Body nach Verify = %q, Handler muss ihn noch lesen können", body)
}
}
func TestVerify_MissingHeaders(t *testing.T) {
for _, h := range []string{HeaderKey, HeaderTimestamp, HeaderNonce, HeaderSignature} {
t.Run(h, func(t *testing.T) {
env := newEnv(t)
r := env.signed(t, http.MethodGet, "https://ingress/x", "")
r.Header.Del(h)
if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err)
}
})
}
}
func TestVerify_Tampering(t *testing.T) {
other, _ := GenerateKey()
tests := map[string]func(r *http.Request){
"Methode": func(r *http.Request) { r.Method = http.MethodDelete },
"Pfad": func(r *http.Request) { r.URL.Path = "/api/v1/admin" },
"Query": func(r *http.Request) { r.URL.RawQuery = "since=0" },
"Body": func(r *http.Request) { r.Body = io.NopCloser(strings.NewReader(`{"labels":{"böse":1}}`)) },
"Zeitstempel": func(r *http.Request) { r.Header.Set(HeaderTimestamp, "1790000001") },
"Nonce": func(r *http.Request) { r.Header.Set(HeaderNonce, "andere-nonce-1234") },
"Signatur Müll": func(r *http.Request) { r.Header.Set(HeaderSignature, "!!!") },
"Signatur kurz": func(r *http.Request) { r.Header.Set(HeaderSignature, "AAAA") },
"Schlüssel Müll": func(r *http.Request) { r.Header.Set(HeaderKey, "AAAA") },
"fremder Schlüssel": func(r *http.Request) {
r.Header.Set(HeaderKey, EncodePublicKey(other.Public().(ed25519.PublicKey)))
},
}
for name, tamper := range tests {
t.Run(name, func(t *testing.T) {
env := newEnv(t)
r := env.signed(t, http.MethodPost, "https://ingress/api/v1/state?since=5", `{"labels":{}}`)
tamper(r)
if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err)
}
})
}
}
func TestVerify_UnknownKey(t *testing.T) {
env := newEnv(t)
stranger, _ := GenerateKey()
env.signer.Key = stranger // korrekt signiert, aber nicht registriert
if _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")); !errors.Is(err, ErrUnauthenticated) {
t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err)
}
}
func TestVerify_ClockSkew(t *testing.T) {
tests := []struct {
name string
offset time.Duration // Zeit des Signers relativ zum Server
ok bool
}{
{"4 Minuten alt", -4 * time.Minute, true},
{"4 Minuten voraus", 4 * time.Minute, true},
{"6 Minuten alt", -6 * time.Minute, false},
{"6 Minuten voraus", 6 * time.Minute, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := newEnv(t)
signTime := env.now.Add(tt.offset)
env.signer.now = func() time.Time { return signTime }
_, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", ""))
if tt.ok && err != nil {
t.Errorf("erwartet OK, bekommen %v", err)
}
if !tt.ok && !errors.Is(err, ErrClockSkew) {
t.Errorf("erwartet ErrClockSkew, bekommen %v", err)
}
})
}
env := newEnv(t)
r := env.signed(t, http.MethodGet, "https://ingress/x", "")
r.Header.Set(HeaderTimestamp, "gestern")
if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
t.Errorf("unlesbarer Zeitstempel: erwartet ErrUnauthenticated, bekommen %v", err)
}
}
func TestVerify_Replay(t *testing.T) {
env := newEnv(t)
r := env.signed(t, http.MethodGet, "https://ingress/x", "")
replay := r.Clone(ctx)
if _, err := env.ver.Verify(r); err != nil {
t.Fatal(err)
}
if _, err := env.ver.Verify(replay); !errors.Is(err, ErrReplay) {
t.Errorf("erwartet ErrReplay, bekommen %v", err)
}
}
// Ein Angreifer ohne gültige Signatur darf keine Nonces "verbrennen".
func TestVerify_InvalidSignatureDoesNotConsumeNonce(t *testing.T) {
env := newEnv(t)
r := env.signed(t, http.MethodGet, "https://ingress/x", "")
forged := r.Clone(ctx)
forged.Header.Set(HeaderSignature, r.Header.Get(HeaderKey)+"xx")
if _, err := env.ver.Verify(forged); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("Fälschung: %v", err)
}
if _, err := env.ver.Verify(r); err != nil {
t.Errorf("echter Request nach Fälschung abgelehnt: %v", err)
}
}
func TestVerify_RejectsPlainHTTP(t *testing.T) {
env := newEnv(t)
r := env.signed(t, http.MethodGet, "https://ingress/x", "")
r.TLS = nil // korrekt signiert, aber über Klartext angekommen
if _, err := env.ver.Verify(r); !errors.Is(err, ErrInsecureTransport) {
t.Errorf("erwartet ErrInsecureTransport, bekommen %v", err)
}
}
func TestVerify_BodyTooLarge(t *testing.T) {
env := newEnv(t)
env.ver.MaxBodySize = 10
r := env.signed(t, http.MethodPost, "https://ingress/x", strings.Repeat("x", 11))
if _, err := env.ver.Verify(r); !errors.Is(err, ErrBodyTooLarge) {
t.Errorf("erwartet ErrBodyTooLarge, bekommen %v", err)
}
}
// Verify beantwortet "wer ist das?". Gesperrt wird hier nur bei
// Quarantäne; Zahlungsverzug, Ablauf und Drain regelt die Policy-Engine
// über das Routing – der Agent braucht den Kanal weiterhin (z. B. für
// lease_renew oder Reaktivierung).
func TestVerify_StatusPolicy(t *testing.T) {
tests := []struct {
name string
tenant store.TenantStatus
node store.NodeStatus
want error
}{
{"aktiv", store.TenantActive, store.NodeOnline, nil},
{"Zahlungsverzug", store.TenantSuspendedPayment, store.NodeOnline, nil},
{"abgelaufen", store.TenantExpired, store.NodeOnline, nil},
{"Knoten gedraint", store.TenantActive, store.NodeDrained, nil},
{"Quarantäne", store.TenantQuarantined, store.NodeOnline, ErrForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := newEnv(t)
for k, n := range env.dir.nodes {
n.Status = tt.node
env.dir.nodes[k] = n
}
tn := env.dir.tenants["t1"]
tn.Status = tt.tenant
env.dir.tenants["t1"] = tn
id, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", ""))
if !errors.Is(err, tt.want) {
t.Fatalf("erwartet %v, bekommen %v", tt.want, err)
}
if err == nil && (id.Tenant.Status != tt.tenant || id.Node.Status != tt.node) {
t.Errorf("Identität ohne Status: %+v", id)
}
})
}
}
func TestVerify_TenantMissing(t *testing.T) {
env := newEnv(t)
delete(env.dir.tenants, "t1")
if _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")); !errors.Is(err, ErrUnauthenticated) {
t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err)
}
}
// Infrastrukturfehler sind keine Authentifizierungsfehler (500 statt 401).
func TestVerify_DirectoryError(t *testing.T) {
env := newEnv(t)
dbDown := errors.New("Datenbank weg")
env.dir.err = dbDown
_, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", ""))
if !errors.Is(err, dbDown) || errors.Is(err, ErrUnauthenticated) {
t.Errorf("erwartet den DB-Fehler, bekommen %v", err)
}
if StatusCode(err) != http.StatusInternalServerError {
t.Errorf("StatusCode = %d", StatusCode(err))
}
}
func TestStatusCode(t *testing.T) {
tests := map[error]int{
nil: http.StatusOK,
ErrUnauthenticated: http.StatusUnauthorized,
ErrClockSkew: http.StatusUnauthorized,
ErrReplay: http.StatusUnauthorized,
ErrForbidden: http.StatusForbidden,
ErrInsecureTransport: http.StatusUpgradeRequired,
ErrBodyTooLarge: http.StatusRequestEntityTooLarge,
}
for err, want := range tests {
if got := StatusCode(err); got != want {
t.Errorf("StatusCode(%v) = %d, erwartet %d", err, got, want)
}
}
}
// Enrollment: Der Schlüssel ist noch nicht registriert. Die Signatur beweist
// nur den Besitz des privaten Schlüssels; den Rest prüft store.Enroll.
func TestVerifyUnregistered(t *testing.T) {
env := newEnv(t)
fresh, _ := GenerateKey()
env.signer.Key = fresh
r := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", `{"token":"sltn_x"}`)
replay := r.Clone(ctx)
replay.Body = io.NopCloser(strings.NewReader(`{"token":"sltn_x"}`))
pub, err := env.ver.VerifyUnregistered(r)
if err != nil {
t.Fatalf("VerifyUnregistered: %v", err)
}
if !pub.Equal(fresh.Public()) {
t.Error("falscher Public Key zurückgegeben")
}
if body, _ := io.ReadAll(r.Body); string(body) != `{"token":"sltn_x"}` {
t.Errorf("Body nicht erhalten: %q", body)
}
if _, err := env.ver.VerifyUnregistered(replay); !errors.Is(err, ErrReplay) {
t.Errorf("Replay: erwartet ErrReplay, bekommen %v", err)
}
tampered := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", `{"token":"sltn_x"}`)
tampered.Body = io.NopCloser(strings.NewReader(`{"token":"sltn_y"}`))
if _, err := env.ver.VerifyUnregistered(tampered); !errors.Is(err, ErrUnauthenticated) {
t.Errorf("manipuliert: erwartet ErrUnauthenticated, bekommen %v", err)
}
plain := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", "")
plain.TLS = nil
if _, err := env.ver.VerifyUnregistered(plain); !errors.Is(err, ErrInsecureTransport) {
t.Errorf("Klartext: erwartet ErrInsecureTransport, bekommen %v", err)
}
}
func TestVerifier_DefaultLimits(t *testing.T) {
var v Verifier
if v.maxSkew() != 5*time.Minute {
t.Errorf("MaxSkew-Default = %v", v.maxSkew())
}
if v.maxBodySize() != 1<<20 {
t.Errorf("MaxBodySize-Default = %d", v.maxBodySize())
}
}
func TestVerify_NonceFormat(t *testing.T) {
for _, nonce := range []string{"kurz", strings.Repeat("x", 200)} {
env := newEnv(t)
r := httptest.NewRequest(http.MethodGet, "https://ingress/x", nil)
r.Header.Set(HeaderKey, EncodePublicKey(env.signer.Key.Public().(ed25519.PublicKey)))
r.Header.Set(HeaderTimestamp, strconv.FormatInt(env.now.Unix(), 10))
r.Header.Set(HeaderNonce, nonce)
r.Header.Set(HeaderSignature, "AAAA")
if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
t.Errorf("Nonce der Länge %d: erwartet ErrUnauthenticated, bekommen %v", len(nonce), err)
}
}
}