feat: add cluster token encryption
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user