feat: add cluster token encryption

This commit is contained in:
Philipp
2026-06-11 08:39:30 +02:00
parent 452d4c5f71
commit 82600bdf82
10 changed files with 542 additions and 0 deletions
+2
View File
@@ -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.
+4
View File
@@ -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`
+12
View File
@@ -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.
+11
View File
@@ -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) {
+205
View File
@@ -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
}
+126
View File
@@ -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
}
+79
View File
@@ -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)
}
@@ -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)
}
}
+2
View File
@@ -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
}
+5
View File
@@ -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, "")
}