297 lines
9.3 KiB
Go
297 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|