package auth import ( "context" "crypto/ed25519" "encoding/base64" "errors" "fmt" "net/http" "strconv" "time" "sultan/pkg/store" ) var ( // ErrUnauthenticated: Signatur fehlt, ist falsch oder der Schlüssel ist // nicht registriert. Bewusst ohne Unterscheidung nach außen. ErrUnauthenticated = errors.New("auth: nicht authentifiziert") // ErrClockSkew: Der Zeitstempel liegt außerhalb des erlaubten Fensters. ErrClockSkew = errors.New("auth: Zeitstempel außerhalb des Toleranzfensters") // ErrReplay: Die Nonce wurde bereits verwendet. ErrReplay = errors.New("auth: Request wiederholt (Replay)") // ErrForbidden: Authentifiziert, aber gesperrt (Mandant in Quarantäne). ErrForbidden = errors.New("auth: Zugriff gesperrt") // ErrBodyTooLarge: Der Body überschreitet MaxBodySize. ErrBodyTooLarge = errors.New("auth: Body zu groß") ) // Directory schlägt Knoten und Mandanten nach; *store.Store erfüllt es. type Directory interface { GetNodeByPublicKey(ctx context.Context, publicKey string) (store.Node, error) GetTenant(ctx context.Context, id string) (store.Tenant, error) } // Identity ist der geprüfte Absender eines Requests. type Identity struct { Node store.Node Tenant store.Tenant } // Verifier prüft signierte Requests auf Server-Seite. Der Nullwert (plus // Directory) ist einsatzbereit; er darf parallel verwendet werden. type Verifier struct { Directory Directory // MaxSkew ist die erlaubte Zeitabweichung zwischen Agent und Server (Default 5min). MaxSkew time.Duration // MaxBodySize begrenzt den signierten Body (Default 1 MiB). MaxBodySize int64 now func() time.Time nonces nonceCache } func (v *Verifier) maxSkew() time.Duration { if v.MaxSkew > 0 { return v.MaxSkew } return 5 * time.Minute } func (v *Verifier) maxBodySize() int64 { if v.MaxBodySize > 0 { return v.MaxBodySize } return 1 << 20 } func (v *Verifier) clock() time.Time { if v.now != nil { return v.now() } return time.Now() } // Verify prüft r und liefert die Identität des registrierten Knotens. // Prüfreihenfolge: TLS, Signatur (ohne Datenbank), Zeitfenster, Nonce, // erst dann der Store – ungültige Requests kosten also keinen DB-Zugriff // und können keine Nonces verbrauchen. // // Gesperrt wird nur bei Quarantäne (ErrForbidden). Zahlungsverzug, Ablauf // und gedrainte Knoten regelt die Policy-Engine über das Routing; der Agent // braucht den Kanal weiterhin, z. B. für lease_renew. func (v *Verifier) Verify(r *http.Request) (Identity, error) { key, err := v.check(r) if err != nil { return Identity{}, err } node, err := v.Directory.GetNodeByPublicKey(r.Context(), key) if errors.Is(err, store.ErrNotFound) { return Identity{}, fmt.Errorf("%w: Schlüssel nicht registriert", ErrUnauthenticated) } if err != nil { return Identity{}, fmt.Errorf("auth: Knoten nachschlagen: %w", err) } tenant, err := v.Directory.GetTenant(r.Context(), node.TenantID) if errors.Is(err, store.ErrNotFound) { return Identity{}, fmt.Errorf("%w: Mandant fehlt", ErrUnauthenticated) } if err != nil { return Identity{}, fmt.Errorf("auth: Mandant nachschlagen: %w", err) } if tenant.Status == store.TenantQuarantined { return Identity{}, fmt.Errorf("%w: Mandant %s in Quarantäne", ErrForbidden, tenant.ID) } return Identity{Node: node, Tenant: tenant}, nil } // VerifyUnregistered prüft einen signierten Request eines noch nicht // registrierten Schlüssels (Enrollment). Bewiesen ist damit nur der Besitz // des privaten Schlüssels; Token und Mandant prüft store.Enroll. func (v *Verifier) VerifyUnregistered(r *http.Request) (ed25519.PublicKey, error) { key, err := v.check(r) if err != nil { return nil, err } return DecodePublicKey(key) } // check prüft alles außer der Registrierung und liefert den kanonisch // kodierten Public Key. func (v *Verifier) check(r *http.Request) (string, error) { if r.TLS == nil { return "", ErrInsecureTransport } pub, err := DecodePublicKey(r.Header.Get(HeaderKey)) if err != nil { return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderKey) } tsRaw := r.Header.Get(HeaderTimestamp) ts, err := strconv.ParseInt(tsRaw, 10, 64) if err != nil { return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderTimestamp) } nonce := r.Header.Get(HeaderNonce) if len(nonce) < 16 || len(nonce) > 64 { return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderNonce) } sig, err := base64.StdEncoding.DecodeString(r.Header.Get(HeaderSignature)) if err != nil || len(sig) != ed25519.SignatureSize { return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderSignature) } body, err := readBody(r, v.maxBodySize()) if err != nil { return "", err } if !ed25519.Verify(pub, canonical(r.Method, r.URL.RequestURI(), tsRaw, nonce, body), sig) { return "", fmt.Errorf("%w: Signatur ungültig", ErrUnauthenticated) } now := v.clock() signedAt := time.Unix(ts, 0) if d := now.Sub(signedAt); d > v.maxSkew() || d < -v.maxSkew() { return "", fmt.Errorf("%w: Abweichung %v", ErrClockSkew, d.Round(time.Second)) } key := EncodePublicKey(pub) if !v.nonces.add(key, nonce, signedAt.Add(v.maxSkew()), now) { return "", ErrReplay } return key, nil } // StatusCode bildet Fehler von Verify auf HTTP-Statuscodes ab. func StatusCode(err error) int { switch { case err == nil: return http.StatusOK case errors.Is(err, ErrInsecureTransport): return http.StatusUpgradeRequired case errors.Is(err, ErrBodyTooLarge): return http.StatusRequestEntityTooLarge case errors.Is(err, ErrForbidden): return http.StatusForbidden case errors.Is(err, ErrUnauthenticated), errors.Is(err, ErrClockSkew), errors.Is(err, ErrReplay): return http.StatusUnauthorized default: return http.StatusInternalServerError } }