erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
1 parent
9d5789c5d7
commit
c8df9d7651
41 files changed
+6455
-36
No files matched your search
@@ -0,0 +1,385 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user