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