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,296 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/store"
|
||||
"sultan/pkg/stream"
|
||||
)
|
||||
|
||||
// Ende-zu-Ende über TLS: Enrollment gegen den echten SQLite-Store, danach
|
||||
// verbindet sich der Agent per signiertem SSE-Stream; die Knoten-ID für den
|
||||
// Hub stammt ausschließlich aus der geprüften Identität.
|
||||
func TestIntegration_EnrollAndStream(t *testing.T) {
|
||||
db, err := store.Open(ctx, filepath.Join(t.TempDir(), "sultan.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateTenant(ctx, store.Tenant{
|
||||
ID: "homelab", Name: "Homelab", Email: "ich@example.com",
|
||||
Tier: store.TierCommunity, ValidUntil: time.Now().AddDate(1, 0, 0),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AddEnrollmentToken(ctx, "sltn_einladung", "homelab", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ver := &Verifier{Directory: db}
|
||||
hub := &stream.Hub{}
|
||||
defer hub.Close()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/enroll", func(w http.ResponseWriter, r *http.Request) {
|
||||
pub, err := ver.VerifyUnregistered(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), StatusCode(err))
|
||||
return
|
||||
}
|
||||
_, err = db.Enroll(r.Context(), r.Header.Get("X-Test-Token"), store.Node{
|
||||
ID: "homelab-pi", Hostname: "pi", PublicKey: EncodePublicKey(pub), IPAddress: "192.168.1.20",
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := ver.Verify(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), StatusCode(err))
|
||||
return
|
||||
}
|
||||
hub.Serve(w, r, id.Node.ID)
|
||||
})
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
priv, _ := GenerateKey()
|
||||
signer := &Signer{Key: priv}
|
||||
|
||||
// Vor dem Enrollment: Stream wird abgelehnt.
|
||||
client := &stream.Client{URL: srv.URL + "/api/v1/stream", HTTPClient: srv.Client(), Prepare: signer.Sign}
|
||||
if err := client.Connect(ctx, nil); err == nil {
|
||||
t.Fatal("Stream ohne Enrollment akzeptiert")
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/enroll", nil)
|
||||
req.Header.Set("X-Test-Token", "sltn_einladung")
|
||||
if err := signer.Sign(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("Enrollment: Status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got := make(chan stream.Event, 1)
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go client.Run(runCtx, func(ev stream.Event) error { got <- ev; return nil })
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !hub.Connected("homelab-pi") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("Agent hat sich nicht verbunden")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if err := hub.Send("homelab-pi", stream.Event{Type: stream.EventLeaseRenew}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case ev := <-got:
|
||||
if ev.NodeID != "homelab-pi" {
|
||||
t.Errorf("NodeID = %q", ev.NodeID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("kein Event empfangen")
|
||||
}
|
||||
}
|
||||
|
||||
// Gegenprobe: Ein Klartext-Server bekommt vom Signer gar nichts.
|
||||
func TestIntegration_PlainHTTPNeverSigned(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Request hat einen Klartext-Server erreicht")
|
||||
}))
|
||||
defer srv.Close()
|
||||
priv, _ := GenerateKey()
|
||||
client := &stream.Client{URL: srv.URL, Prepare: (&Signer{Key: priv}).Sign}
|
||||
if err := client.Connect(ctx, nil); err == nil {
|
||||
t.Error("erwartet Fehler")
|
||||
}
|
||||
}
|
||||
|
||||
// pinnedServer startet den Sultan-Server-Teil (Enrollment + Stream) mit
|
||||
// eigenem Zertifikat aus crypto.LoadOrCreateServerCert.
|
||||
func pinnedServer(t *testing.T, db *store.Store, hub *stream.Hub) (*httptest.Server, crypto.ServerCert, *atomic.Int32) {
|
||||
t.Helper()
|
||||
sc, err := crypto.LoadOrCreateServerCert(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hits atomic.Int32
|
||||
ver := &Verifier{Directory: db}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/enroll", func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
pub, err := ver.VerifyUnregistered(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), StatusCode(err))
|
||||
return
|
||||
}
|
||||
var req struct{ Token string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := db.Enroll(r.Context(), req.Token, store.Node{
|
||||
ID: "homelab-pi", Hostname: "pi", PublicKey: EncodePublicKey(pub), IPAddress: "192.168.1.20",
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
id, err := ver.Verify(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), StatusCode(err))
|
||||
return
|
||||
}
|
||||
hub.Serve(w, r, id.Node.ID)
|
||||
})
|
||||
srv := httptest.NewUnstartedServer(mux)
|
||||
srv.TLS = crypto.ServerTLSConfig(sc)
|
||||
srv.Config.ErrorLog = log.New(io.Discard, "", 0) // abgewiesene Handshakes sind hier gewollt
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, sc, &hits
|
||||
}
|
||||
|
||||
func homelabStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
db, err := store.Open(ctx, filepath.Join(t.TempDir(), "sultan.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if _, err := db.CreateTenant(ctx, store.Tenant{
|
||||
ID: "homelab", Name: "Homelab", Email: "ich@example.com",
|
||||
Tier: store.TierCommunity, ValidUntil: time.Now().AddDate(1, 0, 0),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func enroll(signer *Signer, hc *http.Client, url, secret string) (*http.Response, error) {
|
||||
body, _ := json.Marshal(map[string]string{"token": secret})
|
||||
req, _ := http.NewRequest(http.MethodPost, url+"/api/v1/enroll", bytes.NewReader(body))
|
||||
if err := signer.Sign(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hc.Do(req)
|
||||
}
|
||||
|
||||
// Kompletter Ablauf wie im Betrieb: Server erzeugt Token mit eingebettetem
|
||||
// Fingerprint, Agent prüft beim Erstkontakt dagegen, pinnt, enrollt und
|
||||
// verbindet danach den Stream allein über den Pin.
|
||||
func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
|
||||
db := homelabStore(t)
|
||||
hub := &stream.Hub{}
|
||||
defer hub.Close()
|
||||
srv, sc, _ := pinnedServer(t, db, hub)
|
||||
|
||||
// Server-Seite (sultan token): Token erzeugen, nur das Secret speichern.
|
||||
tok, err := crypto.NewEnrollmentToken(sc.Fingerprint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AddEnrollmentToken(ctx, tok.Secret, "homelab", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Agent-Seite: bekommt nur den Token-String (z. B. per --token).
|
||||
parsed, err := crypto.ParseEnrollmentToken(tok.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := srv.Listener.Addr().String()
|
||||
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
|
||||
firstContact := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, parsed.Fingerprint)}}
|
||||
priv, _ := GenerateKey()
|
||||
signer := &Signer{Key: priv}
|
||||
|
||||
resp, err := enroll(signer, firstContact, srv.URL, parsed.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Enrollment: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("Enrollment: Status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Ab jetzt nur noch über den Pin, ohne Token-Fingerprint.
|
||||
pinned := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, "")}}
|
||||
client := &stream.Client{URL: srv.URL + "/api/v1/stream", HTTPClient: pinned, Prepare: signer.Sign}
|
||||
got := make(chan stream.Event, 1)
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go client.Run(runCtx, func(ev stream.Event) error { got <- ev; return nil })
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !hub.Connected("homelab-pi") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("Agent hat sich nicht verbunden")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if err := hub.Send("homelab-pi", stream.Event{Type: stream.EventNodeCommand}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-got:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("kein Event empfangen")
|
||||
}
|
||||
}
|
||||
|
||||
// Angreifer sitzt beim Erstkontakt dazwischen: Der Agent bricht im
|
||||
// TLS-Handshake ab; Token und Signatur erreichen den Angreifer nie, der
|
||||
// Token bleibt beim echten Server einlösbar.
|
||||
func TestIntegration_MITMNeverSeesToken(t *testing.T) {
|
||||
db := homelabStore(t)
|
||||
hub := &stream.Hub{}
|
||||
defer hub.Close()
|
||||
_, realCert, _ := pinnedServer(t, db, hub)
|
||||
mitm, _, mitmHits := pinnedServer(t, homelabStore(t), &stream.Hub{})
|
||||
|
||||
tok, _ := crypto.NewEnrollmentToken(realCert.Fingerprint)
|
||||
db.AddEnrollmentToken(ctx, tok.Secret, "homelab", time.Now().Add(time.Hour))
|
||||
|
||||
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
|
||||
addr := mitm.Listener.Addr().String()
|
||||
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, tok.Fingerprint)}}
|
||||
priv, _ := GenerateKey()
|
||||
|
||||
_, err := enroll(&Signer{Key: priv}, hc, mitm.URL, tok.Secret)
|
||||
if !errors.Is(err, crypto.ErrFingerprintMismatch) {
|
||||
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
|
||||
}
|
||||
if mitmHits.Load() != 0 {
|
||||
t.Error("Request hat den Angreifer erreicht")
|
||||
}
|
||||
if _, err := db.Enroll(ctx, tok.Secret, store.Node{ID: "n", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil {
|
||||
t.Errorf("Token beim echten Server nicht mehr einlösbar: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package auth authentifiziert Agent-Requests per Ed25519-Signatur (Spec
|
||||
// Abschnitt 3.2) – in jedem Tier, auch im Community-/Homelab-Betrieb.
|
||||
//
|
||||
// Der Agent signiert jeden Request mit seinem lokal erzeugten node.key
|
||||
// (Signer). Der Server prüft Signatur, Zeitfenster und Nonce, schlägt den
|
||||
// Knoten über den Public Key nach und liefert dessen Identität (Verifier).
|
||||
// Die Knoten-ID stammt damit immer aus dem geprüften Schlüssel, nie aus einer
|
||||
// Angabe des Clients.
|
||||
//
|
||||
// Beide Seiten verweigern Klartext: Der Signer signiert nur https-Requests,
|
||||
// der Verifier akzeptiert nur Requests, die über TLS angekommen sind.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidKey: Schlüssel fehlt, ist kaputt oder kein Ed25519-Schlüssel.
|
||||
ErrInvalidKey = errors.New("auth: ungültiger Schlüssel")
|
||||
// ErrInsecureKeyFile: Die Schlüsseldatei ist für Gruppe oder andere lesbar.
|
||||
ErrInsecureKeyFile = errors.New("auth: Schlüsseldatei hat zu offene Rechte (erwartet 0600)")
|
||||
)
|
||||
|
||||
const pemTypePrivateKey = "PRIVATE KEY"
|
||||
|
||||
// GenerateKey erzeugt ein neues Ed25519-Schlüsselpaar.
|
||||
func GenerateKey() (ed25519.PrivateKey, error) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
return priv, err
|
||||
}
|
||||
|
||||
// WritePrivateKey speichert key als PKCS#8-PEM mit Rechten 0600. Eine
|
||||
// bestehende Datei wird nie überschrieben (os.ErrExist) – ein versehentlich
|
||||
// ersetzter Schlüssel würde den Knoten aussperren.
|
||||
func WritePrivateKey(path string, key ed25519.PrivateKey) error {
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidKey, err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pem.Encode(f, &pem.Block{Type: pemTypePrivateKey, Bytes: der}); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadPrivateKey liest einen mit WritePrivateKey gespeicherten Schlüssel.
|
||||
// Dateien, die Gruppe oder andere lesen dürfen, werden abgelehnt.
|
||||
func ReadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
return nil, fmt.Errorf("%w: %s hat %o", ErrInsecureKeyFile, path, info.Mode().Perm())
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil || block.Type != pemTypePrivateKey {
|
||||
return nil, fmt.Errorf("%w: kein %q-PEM-Block in %s", ErrInvalidKey, pemTypePrivateKey, path)
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidKey, err)
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %T statt Ed25519", ErrInvalidKey, parsed)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// EncodePublicKey kodiert einen Public Key so, wie er in Headern und im
|
||||
// Store (nodes.public_key) steht: Base64 (Standard-Alphabet).
|
||||
func EncodePublicKey(pub ed25519.PublicKey) string {
|
||||
return base64.StdEncoding.EncodeToString(pub)
|
||||
}
|
||||
|
||||
// DecodePublicKey ist die Umkehrung von EncodePublicKey.
|
||||
func DecodePublicKey(s string) (ed25519.PublicKey, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil || len(b) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("%w: Public Key nicht lesbar", ErrInvalidKey)
|
||||
}
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKeyFile_RoundTrip(t *testing.T) {
|
||||
priv, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "node.key")
|
||||
if err := WritePrivateKey(path, priv); err != nil {
|
||||
t.Fatalf("WritePrivateKey: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("Dateirechte %o, erwartet 600", perm)
|
||||
}
|
||||
got, err := ReadPrivateKey(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPrivateKey: %v", err)
|
||||
}
|
||||
if !got.Equal(priv) {
|
||||
t.Error("gelesener Schlüssel weicht ab")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePrivateKey_NeverOverwrites(t *testing.T) {
|
||||
priv, _ := GenerateKey()
|
||||
path := filepath.Join(t.TempDir(), "node.key")
|
||||
if err := WritePrivateKey(path, priv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := GenerateKey()
|
||||
if err := WritePrivateKey(path, other); !errors.Is(err, os.ErrExist) {
|
||||
t.Errorf("erwartet os.ErrExist, bekommen %v", err)
|
||||
}
|
||||
if got, _ := ReadPrivateKey(path); !got.Equal(priv) {
|
||||
t.Error("bestehender Schlüssel wurde überschrieben")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPrivateKey_RejectsInsecurePermissions(t *testing.T) {
|
||||
priv, _ := GenerateKey()
|
||||
path := filepath.Join(t.TempDir(), "node.key")
|
||||
if err := WritePrivateKey(path, priv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadPrivateKey(path); !errors.Is(err, ErrInsecureKeyFile) {
|
||||
t.Errorf("erwartet ErrInsecureKeyFile, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPrivateKey_InvalidContent(t *testing.T) {
|
||||
ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
ecDER, _ := x509.MarshalPKCS8PrivateKey(ecKey)
|
||||
tests := map[string][]byte{
|
||||
"kein PEM": []byte("hallo"),
|
||||
"falscher Typ": pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte{1, 2, 3}}),
|
||||
"kaputtes PKCS8": pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte{1, 2, 3}}),
|
||||
"kein Ed25519": pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: ecDER}),
|
||||
}
|
||||
for name, content := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "node.key")
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadPrivateKey(path); !errors.Is(err, ErrInvalidKey) {
|
||||
t.Errorf("erwartet ErrInvalidKey, bekommen %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := ReadPrivateKey(filepath.Join(t.TempDir(), "fehlt.key")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("fehlende Datei: erwartet os.ErrNotExist, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyEncoding(t *testing.T) {
|
||||
priv, _ := GenerateKey()
|
||||
pub := priv.Public().(ed25519PublicKey)
|
||||
enc := EncodePublicKey(pub)
|
||||
got, err := DecodePublicKey(enc)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePublicKey: %v", err)
|
||||
}
|
||||
if !got.Equal(pub) {
|
||||
t.Error("Round-Trip weicht ab")
|
||||
}
|
||||
for _, bad := range []string{"", "!!!", "AAAA"} {
|
||||
if _, err := DecodePublicKey(bad); !errors.Is(err, ErrInvalidKey) {
|
||||
t.Errorf("DecodePublicKey(%q): erwartet ErrInvalidKey, bekommen %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nonceCache merkt sich verwendete Nonces, solange ihr Zeitstempel noch
|
||||
// akzeptiert würde. Danach verhindert schon die Zeitprüfung jede
|
||||
// Wiederverwendung, der Eintrag kann weg.
|
||||
type nonceCache struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]time.Time // Schlüssel+Nonce -> Ablauf
|
||||
lastPurge time.Time
|
||||
}
|
||||
|
||||
// add vermerkt nonce für key und meldet false, wenn sie schon bekannt ist.
|
||||
func (c *nonceCache) add(key, nonce string, expires, now time.Time) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.seen == nil {
|
||||
c.seen = map[string]time.Time{}
|
||||
}
|
||||
if now.Sub(c.lastPurge) > time.Minute {
|
||||
for k, exp := range c.seen {
|
||||
if !now.Before(exp) {
|
||||
delete(c.seen, k)
|
||||
}
|
||||
}
|
||||
c.lastPurge = now
|
||||
}
|
||||
id := key + "\x00" + nonce
|
||||
if _, dup := c.seen[id]; dup {
|
||||
return false
|
||||
}
|
||||
c.seen[id] = expires
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *nonceCache) len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.seen)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNonceCache(t *testing.T) {
|
||||
now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
||||
var c nonceCache
|
||||
|
||||
if !c.add("keyA", "n1", now.Add(time.Minute), now) {
|
||||
t.Fatal("erste Verwendung abgelehnt")
|
||||
}
|
||||
if c.add("keyA", "n1", now.Add(time.Minute), now) {
|
||||
t.Error("Wiederholung akzeptiert")
|
||||
}
|
||||
if !c.add("keyB", "n1", now.Add(time.Minute), now) {
|
||||
t.Error("gleiche Nonce eines anderen Schlüssels abgelehnt")
|
||||
}
|
||||
|
||||
// Nach Ablauf wird der Eintrag aufgeräumt; der Zeitstempel-Check
|
||||
// verhindert dann ohnehin jede Wiederverwendung.
|
||||
later := now.Add(2 * time.Minute)
|
||||
c.add("keyA", "n2", later.Add(time.Minute), later)
|
||||
if n := c.len(); n != 1 {
|
||||
t.Errorf("%d Einträge nach Ablauf, erwartet 1", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Header, in denen die Signatur übertragen wird.
|
||||
const (
|
||||
HeaderKey = "X-Sultan-Key"
|
||||
HeaderTimestamp = "X-Sultan-Timestamp"
|
||||
HeaderNonce = "X-Sultan-Nonce"
|
||||
HeaderSignature = "X-Sultan-Signature"
|
||||
)
|
||||
|
||||
// ErrInsecureTransport: Request ohne TLS. Weder wird er signiert noch
|
||||
// akzeptiert.
|
||||
var ErrInsecureTransport = errors.New("auth: unverschlüsselte Verbindung abgelehnt")
|
||||
|
||||
// signatureVersion trennt die signierten Daten von anderen Ed25519-Nutzungen
|
||||
// desselben Schlüssels und erlaubt spätere Formatänderungen.
|
||||
const signatureVersion = "SULTAN-ED25519-V1"
|
||||
|
||||
// canonical baut die signierten Daten. Jede Zeile ist eindeutig abgegrenzt;
|
||||
// der Body geht als SHA-256 ein.
|
||||
func canonical(method, requestURI, timestamp, nonce string, body []byte) []byte {
|
||||
sum := sha256.Sum256(body)
|
||||
return []byte(strings.Join([]string{
|
||||
signatureVersion, method, requestURI, timestamp, nonce, hex.EncodeToString(sum[:]),
|
||||
}, "\n"))
|
||||
}
|
||||
|
||||
// Signer signiert Requests des Agenten. Sign passt als stream.Client.Prepare.
|
||||
type Signer struct {
|
||||
Key ed25519.PrivateKey
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Sign signiert r (Methode, Pfad+Query, Zeitstempel, frische Nonce, Body).
|
||||
// Der Body wird dafür gelesen und wiederhergestellt. Nur https-URLs werden
|
||||
// signiert.
|
||||
func (s *Signer) Sign(r *http.Request) error {
|
||||
if len(s.Key) != ed25519.PrivateKeySize {
|
||||
return ErrInvalidKey
|
||||
}
|
||||
if r.URL == nil || r.URL.Scheme != "https" {
|
||||
return ErrInsecureTransport
|
||||
}
|
||||
body, err := readBody(r, -1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Body != nil {
|
||||
r.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
|
||||
r.ContentLength = int64(len(body))
|
||||
}
|
||||
|
||||
now := time.Now
|
||||
if s.now != nil {
|
||||
now = s.now
|
||||
}
|
||||
ts := strconv.FormatInt(now().Unix(), 10)
|
||||
nonce := newNonce()
|
||||
sig := ed25519.Sign(s.Key, canonical(r.Method, r.URL.RequestURI(), ts, nonce, body))
|
||||
|
||||
r.Header.Set(HeaderKey, EncodePublicKey(s.Key.Public().(ed25519.PublicKey)))
|
||||
r.Header.Set(HeaderTimestamp, ts)
|
||||
r.Header.Set(HeaderNonce, nonce)
|
||||
r.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(sig))
|
||||
return nil
|
||||
}
|
||||
|
||||
func newNonce() string {
|
||||
var b [16]byte
|
||||
rand.Read(b[:])
|
||||
return base64.RawURLEncoding.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// readBody liest den Body vollständig (limit < 0: unbegrenzt) und ersetzt
|
||||
// ihn durch eine Kopie, damit nachfolgende Handler ihn erneut lesen können.
|
||||
func readBody(r *http.Request, limit int64) ([]byte, error) {
|
||||
if r.Body == nil || r.Body == http.NoBody {
|
||||
return nil, nil
|
||||
}
|
||||
rd := io.Reader(r.Body)
|
||||
if limit >= 0 {
|
||||
rd = io.LimitReader(r.Body, limit+1)
|
||||
}
|
||||
body, err := io.ReadAll(rd)
|
||||
r.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit >= 0 && int64(len(body)) > limit {
|
||||
return nil, ErrBodyTooLarge
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type ed25519PublicKey = ed25519.PublicKey
|
||||
|
||||
func TestSign_SetsHeaders(t *testing.T) {
|
||||
priv, _ := GenerateKey()
|
||||
s := &Signer{Key: priv}
|
||||
r, _ := http.NewRequest(http.MethodGet, "https://ingress.example:8443/api/v1/stream", nil)
|
||||
if err := s.Sign(r); err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if got := r.Header.Get(HeaderKey); got != EncodePublicKey(priv.Public().(ed25519.PublicKey)) {
|
||||
t.Errorf("%s = %q", HeaderKey, got)
|
||||
}
|
||||
for _, h := range []string{HeaderTimestamp, HeaderNonce, HeaderSignature} {
|
||||
if r.Header.Get(h) == "" {
|
||||
t.Errorf("Header %s fehlt", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_FreshNonceEachTime(t *testing.T) {
|
||||
priv, _ := GenerateKey()
|
||||
s := &Signer{Key: priv}
|
||||
seen := map[string]bool{}
|
||||
for range 100 {
|
||||
r, _ := http.NewRequest(http.MethodGet, "https://h/x", nil)
|
||||
if err := s.Sign(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := r.Header.Get(HeaderNonce)
|
||||
if seen[n] {
|
||||
t.Fatalf("Nonce %q doppelt", n)
|
||||
}
|
||||
seen[n] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_PreservesBody(t *testing.T) {
|
||||
priv, _ := GenerateKey()
|
||||
r, _ := http.NewRequest(http.MethodPost, "https://h/api/v1/state", strings.NewReader(`{"a":1}`))
|
||||
if err := (&Signer{Key: priv}).Sign(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if string(body) != `{"a":1}` {
|
||||
t.Errorf("Body nach Sign = %q", body)
|
||||
}
|
||||
if r.GetBody == nil {
|
||||
t.Fatal("GetBody fehlt – Redirects/Retries könnten den Body nicht erneut senden")
|
||||
}
|
||||
again, _ := r.GetBody()
|
||||
body, _ = io.ReadAll(again)
|
||||
if !bytes.Equal(body, []byte(`{"a":1}`)) {
|
||||
t.Errorf("GetBody liefert %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Kein Signieren für Klartext-Verbindungen: Signatur und Body dürfen nie
|
||||
// unverschlüsselt über die Leitung gehen.
|
||||
func TestSign_RefusesPlainHTTP(t *testing.T) {
|
||||
priv, _ := GenerateKey()
|
||||
r, _ := http.NewRequest(http.MethodGet, "http://ingress.example:8080/api/v1/stream", nil)
|
||||
if err := (&Signer{Key: priv}).Sign(r); !errors.Is(err, ErrInsecureTransport) {
|
||||
t.Errorf("erwartet ErrInsecureTransport, bekommen %v", err)
|
||||
}
|
||||
if r.Header.Get(HeaderSignature) != "" {
|
||||
t.Error("Signatur trotz Klartext gesetzt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_NoKey(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodGet, "https://h/x", nil)
|
||||
if err := (&Signer{}).Sign(r); !errors.Is(err, ErrInvalidKey) {
|
||||
t.Errorf("erwartet ErrInvalidKey, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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