386 lines
13 KiB
Go
386 lines
13 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|