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,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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user