feat: add cluster token encryption
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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.
|
- Autorisierungs-Middleware fuer deklarative Rollen-/Permission-Pruefung pro Route angelegt.
|
||||||
- Membership-Resolver fuer tenant-gescoped Backend-Routen mit 403 fuer Nicht-Mitglieder 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.
|
- Frontend-Prototyp mit Supabase Auth, Backend-Profilcheck und Platzhalter-Views angelegt.
|
||||||
|
|||||||
@@ -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`.
|
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:
|
Lokale Dienste:
|
||||||
|
|
||||||
- Supabase API Gateway: `http://localhost:8000`
|
- Supabase API Gateway: `http://localhost:8000`
|
||||||
|
|||||||
@@ -101,6 +101,16 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
|||||||
- [x] Supabase Auth Login/Registrierung angebunden
|
- [x] Supabase Auth Login/Registrierung angebunden
|
||||||
- [x] Backend-`/me` Profil-Sync-Pruefung angebunden
|
- [x] Backend-`/me` Profil-Sync-Pruefung angebunden
|
||||||
- [x] App-Shell mit Platzhalter-Views fuer Projekte, VMs, SSH-Keys, Audit und Konsole angelegt
|
- [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
|
## 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-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: 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: 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.
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
|
|
||||||
"proxui/backend/internal/auth"
|
"proxui/backend/internal/auth"
|
||||||
"proxui/backend/internal/authorization"
|
"proxui/backend/internal/authorization"
|
||||||
|
"proxui/backend/internal/cluster"
|
||||||
|
"proxui/backend/internal/encryption"
|
||||||
"proxui/backend/internal/membership"
|
"proxui/backend/internal/membership"
|
||||||
"proxui/backend/internal/profile"
|
"proxui/backend/internal/profile"
|
||||||
"proxui/backend/internal/rbac"
|
"proxui/backend/internal/rbac"
|
||||||
@@ -42,6 +44,13 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
authMiddleware := auth.NewMiddleware(jwtValidator)
|
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)
|
db, err := openDatabase(cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to connect database", "error", err)
|
logger.Error("failed to connect database", "error", err)
|
||||||
@@ -52,6 +61,8 @@ func main() {
|
|||||||
profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger)
|
profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger)
|
||||||
membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger)
|
membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger)
|
||||||
authorizationMiddleware := authorization.NewMiddleware()
|
authorizationMiddleware := authorization.NewMiddleware()
|
||||||
|
clusterRepository := cluster.NewRepository(db, tokenCipher)
|
||||||
|
_ = clusterRepository
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ type Config struct {
|
|||||||
SupabaseJWTSecret string
|
SupabaseJWTSecret string
|
||||||
RedisAddr string
|
RedisAddr string
|
||||||
AppSiteURL string
|
AppSiteURL string
|
||||||
|
MasterKeyBase64 string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -52,6 +53,7 @@ func Load() (Config, error) {
|
|||||||
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
||||||
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
|
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
|
||||||
AppSiteURL: getenv("APP_SITE_URL", "http://localhost:5173"),
|
AppSiteURL: getenv("APP_SITE_URL", "http://localhost:5173"),
|
||||||
|
MasterKeyBase64: os.Getenv("MASTER_KEY_BASE64"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func TestLoadOverrides(t *testing.T) {
|
|||||||
t.Setenv("CONSOLE_PROXY_ADDR", ":9001")
|
t.Setenv("CONSOLE_PROXY_ADDR", ":9001")
|
||||||
t.Setenv("WORKER_CONCURRENCY", "12")
|
t.Setenv("WORKER_CONCURRENCY", "12")
|
||||||
t.Setenv("REDIS_ADDR", "redis:6379")
|
t.Setenv("REDIS_ADDR", "redis:6379")
|
||||||
|
t.Setenv("MASTER_KEY_BASE64", "test-key")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,6 +67,9 @@ func TestLoadOverrides(t *testing.T) {
|
|||||||
if cfg.RedisAddr != "redis:6379" {
|
if cfg.RedisAddr != "redis:6379" {
|
||||||
t.Fatalf("RedisAddr = %q, want redis:6379", cfg.RedisAddr)
|
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) {
|
func TestLoadRejectsInvalidLogLevel(t *testing.T) {
|
||||||
@@ -105,6 +109,7 @@ func clearConfigEnv(t *testing.T) {
|
|||||||
"SUPABASE_JWT_SECRET",
|
"SUPABASE_JWT_SECRET",
|
||||||
"REDIS_ADDR",
|
"REDIS_ADDR",
|
||||||
"APP_SITE_URL",
|
"APP_SITE_URL",
|
||||||
|
"MASTER_KEY_BASE64",
|
||||||
} {
|
} {
|
||||||
t.Setenv(key, "")
|
t.Setenv(key, "")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user