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

+296
View File
@@ -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)
}
}
+106
View File
@@ -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
}
+110
View File
@@ -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)
}
}
}
+44
View File
@@ -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)
}
+29
View File
@@ -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)
}
}
+109
View File
@@ -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
}
+87
View File
@@ -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)
}
}
+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
}
}
+385
View File
@@ -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)
}
}
}