erste module drin.

zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
br committed 2026-10-01 22:22:13 +02:00
1 parent 9d5789c5d7
commit c8df9d7651
41 files changed
+6455 -36

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
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
}
}