From 82600bdf82849d96e997c7850de1fd960da6f8ef Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 11 Jun 2026 08:39:30 +0200 Subject: [PATCH] feat: add cluster token encryption --- CHANGELOG.md | 2 + README.md | 4 + TODO.md | 12 ++ backend/cmd/api/main.go | 11 ++ backend/internal/cluster/repository.go | 205 ++++++++++++++++++++ backend/internal/cluster/repository_test.go | 126 ++++++++++++ backend/internal/encryption/cipher.go | 79 ++++++++ backend/internal/encryption/cipher_test.go | 96 +++++++++ platform/config/config.go | 2 + platform/config/config_test.go | 5 + 10 files changed, 542 insertions(+) create mode 100644 backend/internal/cluster/repository.go create mode 100644 backend/internal/cluster/repository_test.go create mode 100644 backend/internal/encryption/cipher.go create mode 100644 backend/internal/encryption/cipher_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0d938..33b9880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Cluster-Repository zum verschluesselten Speichern und entschluesselten Laden von Proxmox-Tokens angelegt. +- AES-GCM-Krypto-Layer fuer verschluesselte Cluster-Tokens mit Master-Key-Konfiguration angelegt. - Autorisierungs-Middleware fuer deklarative Rollen-/Permission-Pruefung pro Route angelegt. - Membership-Resolver fuer tenant-gescoped Backend-Routen mit 403 fuer Nicht-Mitglieder angelegt. - Frontend-Prototyp mit Supabase Auth, Backend-Profilcheck und Platzhalter-Views angelegt. diff --git a/README.md b/README.md index a150310..d7645a3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ Migrationen nutzen `MIGRATE_DATABASE_URL`, falls gesetzt. Andernfalls wird `DATA Das Backend validiert Supabase-JWTs ueber `SUPABASE_JWKS_URL` und `SUPABASE_ISSUER`. Fuer das lokale Self-Hosted-Setup mit leerem JWKS wird zusaetzlich `SUPABASE_JWT_SECRET` als HS256-Fallback genutzt. Beim ersten authentifizierten Request synchronisiert das Backend den Supabase-User lazy nach `public.profiles`. +Cluster-Tokens werden mit AES-256-GCM verschluesselt. `MASTER_KEY_BASE64` muss ein base64-kodierter 32-Byte-Key sein, zum Beispiel erzeugt mit `openssl rand -base64 32`. Ohne gueltigen Master-Key bricht der Backend-Start ab. + +Das Cluster-Repository speichert Proxmox-Token-Secrets nur als Ciphertext in `public.clusters.encrypted_token`. Entschluesselte Tokens bleiben interne Runtime-Daten und werden nicht in API-DTOs dokumentiert. + Lokale Dienste: - Supabase API Gateway: `http://localhost:8000` diff --git a/TODO.md b/TODO.md index 7d8a10b..99b0036 100644 --- a/TODO.md +++ b/TODO.md @@ -101,6 +101,16 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] Supabase Auth Login/Registrierung angebunden - [x] Backend-`/me` Profil-Sync-Pruefung angebunden - [x] App-Shell mit Platzhalter-Views fuer Projekte, VMs, SSH-Keys, Audit und Konsole angelegt +- [x] E3-T01: Krypto-Layer fuer Envelope-Encryption + - [x] AES-256-GCM mit 32-Byte-Master-Key angelegt + - [x] Master-Key aus `MASTER_KEY_BASE64` konfiguriert + - [x] Ciphertext-Format mit Version-Byte und vorangestellter Nonce angelegt + - [x] Roundtrip, falscher Key, falsche Version und fehlender Key per Unit-Test abgedeckt +- [x] E3-T02: Cluster-Repository + - [x] `GetCluster` laedt Cluster und entschluesselt das Token im Speicher + - [x] `UpsertCluster` verschluesselt Token-Secrets vor dem Speichern + - [x] SQL-Storage fuer `public.clusters` angelegt + - [x] Unit-Test stellt sicher, dass gespeicherte Tokens keinen Klartext enthalten ## MVP-Backlog @@ -154,3 +164,5 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - 2026-06-10: Frontend-Prototyp mit Supabase Auth und Backend-`/me` Check erfolgreich gebaut. - 2026-06-11: Membership-Resolver mit Middleware-Tests fuer erlaubte, fehlende und verbotene Tenant-Zugriffe angelegt. - 2026-06-11: Autorisierungs-Middleware mit Tests fuer erlaubte, fehlende und verweigerte Permissions angelegt. +- 2026-06-11: AES-GCM-Krypto-Layer fuer Cluster-Tokens mit Master-Key-Validierung und Unit-Tests angelegt. +- 2026-06-11: Cluster-Repository mit verschluesseltem Token-Speicher und Entschluesselung beim Laden angelegt. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 9145821..1531386 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -18,6 +18,8 @@ import ( "proxui/backend/internal/auth" "proxui/backend/internal/authorization" + "proxui/backend/internal/cluster" + "proxui/backend/internal/encryption" "proxui/backend/internal/membership" "proxui/backend/internal/profile" "proxui/backend/internal/rbac" @@ -42,6 +44,13 @@ func main() { os.Exit(1) } authMiddleware := auth.NewMiddleware(jwtValidator) + tokenCipher, err := encryption.NewFromBase64(cfg.MasterKeyBase64) + if err != nil { + logger.Error("failed to initialize cluster token encryption", "error", err) + os.Exit(1) + } + _ = tokenCipher + db, err := openDatabase(cfg.DatabaseURL) if err != nil { logger.Error("failed to connect database", "error", err) @@ -52,6 +61,8 @@ func main() { profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger) membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger) authorizationMiddleware := authorization.NewMiddleware() + clusterRepository := cluster.NewRepository(db, tokenCipher) + _ = clusterRepository mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/cluster/repository.go b/backend/internal/cluster/repository.go new file mode 100644 index 0000000..22fa333 --- /dev/null +++ b/backend/internal/cluster/repository.go @@ -0,0 +1,205 @@ +package cluster + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "proxui/backend/internal/encryption" +) + +type Cluster struct { + ID string + Name string + APIEndpoint string + TLSFingerprint string + TokenID string + TokenSecret string + Status string + CreatedAt time.Time +} + +type StoredCluster struct { + ID string + Name string + APIEndpoint string + TLSFingerprint string + EncryptedToken []byte + TokenID string + Status string + CreatedAt time.Time +} + +type Storage interface { + Get(ctx context.Context, id string) (StoredCluster, bool, error) + Upsert(ctx context.Context, cluster StoredCluster) (StoredCluster, error) +} + +type Repository struct { + storage Storage + cipher encryption.Cipher +} + +func NewRepository(db *sql.DB, cipher encryption.Cipher) Repository { + return Repository{ + storage: SQLStorage{db: db}, + cipher: cipher, + } +} + +func NewRepositoryWithStorage(storage Storage, cipher encryption.Cipher) Repository { + return Repository{ + storage: storage, + cipher: cipher, + } +} + +func (r Repository) GetCluster(ctx context.Context, id string) (Cluster, bool, error) { + stored, found, err := r.storage.Get(ctx, id) + if err != nil || !found { + return Cluster{}, found, err + } + + tokenSecret, err := r.cipher.Decrypt(stored.EncryptedToken) + if err != nil { + return Cluster{}, false, err + } + + return Cluster{ + ID: stored.ID, + Name: stored.Name, + APIEndpoint: stored.APIEndpoint, + TLSFingerprint: stored.TLSFingerprint, + TokenID: stored.TokenID, + TokenSecret: string(tokenSecret), + Status: stored.Status, + CreatedAt: stored.CreatedAt, + }, true, nil +} + +func (r Repository) UpsertCluster(ctx context.Context, cluster Cluster) (Cluster, error) { + if strings.TrimSpace(cluster.TokenSecret) == "" { + return Cluster{}, fmt.Errorf("token secret is required") + } + + encryptedToken, err := r.cipher.Encrypt([]byte(cluster.TokenSecret)) + if err != nil { + return Cluster{}, err + } + + status := strings.TrimSpace(cluster.Status) + if status == "" { + status = "active" + } + + stored, err := r.storage.Upsert(ctx, StoredCluster{ + ID: strings.TrimSpace(cluster.ID), + Name: cluster.Name, + APIEndpoint: cluster.APIEndpoint, + TLSFingerprint: cluster.TLSFingerprint, + EncryptedToken: encryptedToken, + TokenID: cluster.TokenID, + Status: status, + }) + if err != nil { + return Cluster{}, err + } + + return Cluster{ + ID: stored.ID, + Name: stored.Name, + APIEndpoint: stored.APIEndpoint, + TLSFingerprint: stored.TLSFingerprint, + TokenID: stored.TokenID, + TokenSecret: cluster.TokenSecret, + Status: stored.Status, + CreatedAt: stored.CreatedAt, + }, nil +} + +type SQLStorage struct { + db *sql.DB +} + +func (s SQLStorage) Get(ctx context.Context, id string) (StoredCluster, bool, error) { + var cluster StoredCluster + err := s.db.QueryRowContext(ctx, ` + select id::text, name, api_endpoint, tls_fingerprint, encrypted_token, token_id, status, created_at + from public.clusters + where id = $1 + `, id).Scan( + &cluster.ID, + &cluster.Name, + &cluster.APIEndpoint, + &cluster.TLSFingerprint, + &cluster.EncryptedToken, + &cluster.TokenID, + &cluster.Status, + &cluster.CreatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return StoredCluster{}, false, nil + } + if err != nil { + return StoredCluster{}, false, err + } + + return cluster, true, nil +} + +func (s SQLStorage) Upsert(ctx context.Context, cluster StoredCluster) (StoredCluster, error) { + var stored StoredCluster + err := s.db.QueryRowContext(ctx, ` + insert into public.clusters ( + id, + name, + api_endpoint, + tls_fingerprint, + encrypted_token, + token_id, + status + ) + values ( + coalesce(nullif($1, '')::uuid, gen_random_uuid()), + $2, + $3, + $4, + $5, + $6, + $7 + ) + on conflict (id) do update + set name = excluded.name, + api_endpoint = excluded.api_endpoint, + tls_fingerprint = excluded.tls_fingerprint, + encrypted_token = excluded.encrypted_token, + token_id = excluded.token_id, + status = excluded.status + returning id::text, name, api_endpoint, tls_fingerprint, encrypted_token, token_id, status, created_at + `, + cluster.ID, + cluster.Name, + cluster.APIEndpoint, + cluster.TLSFingerprint, + cluster.EncryptedToken, + cluster.TokenID, + cluster.Status, + ).Scan( + &stored.ID, + &stored.Name, + &stored.APIEndpoint, + &stored.TLSFingerprint, + &stored.EncryptedToken, + &stored.TokenID, + &stored.Status, + &stored.CreatedAt, + ) + if err != nil { + return StoredCluster{}, err + } + + return stored, nil +} diff --git a/backend/internal/cluster/repository_test.go b/backend/internal/cluster/repository_test.go new file mode 100644 index 0000000..8cd128e --- /dev/null +++ b/backend/internal/cluster/repository_test.go @@ -0,0 +1,126 @@ +package cluster + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "proxui/backend/internal/encryption" +) + +func TestRepositoryEncryptsStoredTokenAndDecryptsOnLoad(t *testing.T) { + cipher := testCipher(t, 1) + storage := newMemoryStorage() + repository := NewRepositoryWithStorage(storage, cipher) + + saved, err := repository.UpsertCluster(context.Background(), Cluster{ + ID: "cluster-1", + Name: "Lab", + APIEndpoint: "https://pve.example.test:8006", + TLSFingerprint: "AA:BB", + TokenID: "root@pam!proxui", + TokenSecret: "secret-token", + }) + if err != nil { + t.Fatalf("UpsertCluster() error = %v", err) + } + + stored := storage.records[saved.ID] + if bytes.Contains(stored.EncryptedToken, []byte("secret-token")) { + t.Fatal("stored encrypted token contains plaintext") + } + + got, found, err := repository.GetCluster(context.Background(), saved.ID) + if err != nil { + t.Fatalf("GetCluster() error = %v", err) + } + if !found { + t.Fatal("GetCluster() found = false, want true") + } + if got.TokenSecret != "secret-token" { + t.Fatalf("TokenSecret = %q, want secret-token", got.TokenSecret) + } + if got.TokenID != "root@pam!proxui" { + t.Fatalf("TokenID = %q, want root@pam!proxui", got.TokenID) + } +} + +func TestRepositoryRejectsMissingTokenSecret(t *testing.T) { + repository := NewRepositoryWithStorage(newMemoryStorage(), testCipher(t, 1)) + + _, err := repository.UpsertCluster(context.Background(), Cluster{ + ID: "cluster-1", + Name: "Lab", + APIEndpoint: "https://pve.example.test:8006", + TokenID: "root@pam!proxui", + }) + if err == nil { + t.Fatal("UpsertCluster() error = nil, want error") + } +} + +func TestRepositoryReturnsErrorWhenStoredTokenUsesWrongKey(t *testing.T) { + storage := newMemoryStorage() + writer := NewRepositoryWithStorage(storage, testCipher(t, 1)) + reader := NewRepositoryWithStorage(storage, testCipher(t, 2)) + + saved, err := writer.UpsertCluster(context.Background(), Cluster{ + ID: "cluster-1", + Name: "Lab", + APIEndpoint: "https://pve.example.test:8006", + TLSFingerprint: "AA:BB", + TokenID: "root@pam!proxui", + TokenSecret: "secret-token", + }) + if err != nil { + t.Fatalf("UpsertCluster() error = %v", err) + } + + if _, _, err := reader.GetCluster(context.Background(), saved.ID); err == nil { + t.Fatal("GetCluster() error = nil, want error") + } +} + +func testCipher(t *testing.T, value byte) encryption.Cipher { + t.Helper() + + cipher, err := encryption.New(bytes.Repeat([]byte{value}, 32)) + if err != nil { + t.Fatalf("encryption.New() error = %v", err) + } + return cipher +} + +type memoryStorage struct { + records map[string]StoredCluster + nextID int +} + +func newMemoryStorage() *memoryStorage { + return &memoryStorage{ + records: make(map[string]StoredCluster), + } +} + +func (s *memoryStorage) Get(_ context.Context, id string) (StoredCluster, bool, error) { + cluster, ok := s.records[id] + return cluster, ok, nil +} + +func (s *memoryStorage) Upsert(_ context.Context, cluster StoredCluster) (StoredCluster, error) { + if cluster.ID == "" { + s.nextID++ + cluster.ID = fmt.Sprintf("cluster-%d", s.nextID) + } + if cluster.Status == "" { + cluster.Status = "active" + } + if cluster.CreatedAt.IsZero() { + cluster.CreatedAt = time.Now() + } + + s.records[cluster.ID] = cluster + return cluster, nil +} diff --git a/backend/internal/encryption/cipher.go b/backend/internal/encryption/cipher.go new file mode 100644 index 0000000..3ca9eef --- /dev/null +++ b/backend/internal/encryption/cipher.go @@ -0,0 +1,79 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "strings" +) + +const ( + currentVersion byte = 1 + keySize = 32 +) + +type Cipher struct { + gcm cipher.AEAD +} + +func New(masterKey []byte) (Cipher, error) { + if len(masterKey) != keySize { + return Cipher{}, fmt.Errorf("master key must be %d bytes", keySize) + } + + block, err := aes.NewCipher(masterKey) + if err != nil { + return Cipher{}, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return Cipher{}, err + } + + return Cipher{gcm: gcm}, nil +} + +func NewFromBase64(encodedKey string) (Cipher, error) { + encodedKey = strings.TrimSpace(encodedKey) + if encodedKey == "" { + return Cipher{}, fmt.Errorf("MASTER_KEY_BASE64 is required") + } + + key, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil { + return Cipher{}, fmt.Errorf("MASTER_KEY_BASE64 must be base64 encoded: %w", err) + } + + return New(key) +} + +func (c Cipher) Encrypt(plaintext []byte) ([]byte, error) { + nonce := make([]byte, c.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + ciphertext := make([]byte, 0, 1+len(nonce)+len(plaintext)+c.gcm.Overhead()) + ciphertext = append(ciphertext, currentVersion) + ciphertext = append(ciphertext, nonce...) + ciphertext = c.gcm.Seal(ciphertext, nonce, plaintext, nil) + return ciphertext, nil +} + +func (c Cipher) Decrypt(ciphertext []byte) ([]byte, error) { + nonceSize := c.gcm.NonceSize() + if len(ciphertext) < 1+nonceSize+c.gcm.Overhead() { + return nil, fmt.Errorf("ciphertext is too short") + } + if ciphertext[0] != currentVersion { + return nil, fmt.Errorf("unsupported ciphertext version") + } + + nonce := ciphertext[1 : 1+nonceSize] + encrypted := ciphertext[1+nonceSize:] + return c.gcm.Open(nil, nonce, encrypted, nil) +} diff --git a/backend/internal/encryption/cipher_test.go b/backend/internal/encryption/cipher_test.go new file mode 100644 index 0000000..a30e32b --- /dev/null +++ b/backend/internal/encryption/cipher_test.go @@ -0,0 +1,96 @@ +package encryption + +import ( + "bytes" + "encoding/base64" + "strings" + "testing" +) + +func TestCipherRoundtrip(t *testing.T) { + cipher, err := New(bytes.Repeat([]byte{1}, keySize)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + plaintext := []byte("secret-proxmox-token") + ciphertext, err := cipher.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + if bytes.Contains(ciphertext, plaintext) { + t.Fatal("ciphertext contains plaintext") + } + if ciphertext[0] != currentVersion { + t.Fatalf("version = %d, want %d", ciphertext[0], currentVersion) + } + + got, err := cipher.Decrypt(ciphertext) + if err != nil { + t.Fatalf("Decrypt() error = %v", err) + } + if string(got) != string(plaintext) { + t.Fatalf("plaintext = %q, want %q", got, plaintext) + } +} + +func TestDecryptRejectsWrongKey(t *testing.T) { + cipherA, err := New(bytes.Repeat([]byte{1}, keySize)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + cipherB, err := New(bytes.Repeat([]byte{2}, keySize)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ciphertext, err := cipherA.Encrypt([]byte("secret-proxmox-token")) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + if _, err := cipherB.Decrypt(ciphertext); err == nil { + t.Fatal("Decrypt() error = nil, want error") + } +} + +func TestDecryptRejectsUnknownVersion(t *testing.T) { + cipher, err := New(bytes.Repeat([]byte{1}, keySize)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ciphertext, err := cipher.Encrypt([]byte("secret-proxmox-token")) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + ciphertext[0] = 99 + + if _, err := cipher.Decrypt(ciphertext); err == nil { + t.Fatal("Decrypt() error = nil, want error") + } +} + +func TestNewRejectsInvalidKeySize(t *testing.T) { + if _, err := New(bytes.Repeat([]byte{1}, keySize-1)); err == nil { + t.Fatal("New() error = nil, want error") + } +} + +func TestNewFromBase64(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, keySize)) + + if _, err := NewFromBase64(encoded); err != nil { + t.Fatalf("NewFromBase64() error = %v", err) + } +} + +func TestNewFromBase64RejectsMissingKey(t *testing.T) { + _, err := NewFromBase64("") + if err == nil { + t.Fatal("NewFromBase64() error = nil, want error") + } + if !strings.Contains(err.Error(), "MASTER_KEY_BASE64") { + t.Fatalf("error = %q, want MASTER_KEY_BASE64 hint", err) + } +} diff --git a/platform/config/config.go b/platform/config/config.go index 601330b..0db8cef 100644 --- a/platform/config/config.go +++ b/platform/config/config.go @@ -22,6 +22,7 @@ type Config struct { SupabaseJWTSecret string RedisAddr string AppSiteURL string + MasterKeyBase64 string } func Load() (Config, error) { @@ -52,6 +53,7 @@ func Load() (Config, error) { SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), RedisAddr: getenv("REDIS_ADDR", "localhost:6379"), AppSiteURL: getenv("APP_SITE_URL", "http://localhost:5173"), + MasterKeyBase64: os.Getenv("MASTER_KEY_BASE64"), }, nil } diff --git a/platform/config/config_test.go b/platform/config/config_test.go index 89d0b9d..13830cf 100644 --- a/platform/config/config_test.go +++ b/platform/config/config_test.go @@ -42,6 +42,7 @@ func TestLoadOverrides(t *testing.T) { t.Setenv("CONSOLE_PROXY_ADDR", ":9001") t.Setenv("WORKER_CONCURRENCY", "12") t.Setenv("REDIS_ADDR", "redis:6379") + t.Setenv("MASTER_KEY_BASE64", "test-key") cfg, err := Load() if err != nil { @@ -66,6 +67,9 @@ func TestLoadOverrides(t *testing.T) { if cfg.RedisAddr != "redis:6379" { t.Fatalf("RedisAddr = %q, want redis:6379", cfg.RedisAddr) } + if cfg.MasterKeyBase64 != "test-key" { + t.Fatalf("MasterKeyBase64 = %q, want test-key", cfg.MasterKeyBase64) + } } func TestLoadRejectsInvalidLogLevel(t *testing.T) { @@ -105,6 +109,7 @@ func clearConfigEnv(t *testing.T) { "SUPABASE_JWT_SECRET", "REDIS_ADDR", "APP_SITE_URL", + "MASTER_KEY_BASE64", } { t.Setenv(key, "") }