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

180 lines
5.7 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"
"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
}
}