mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
feat(M34): dynamic issuer configuration with encrypted config storage
Replace static env-var-based issuer wiring with GUI-driven dynamic configuration stored encrypted in PostgreSQL. Operators can now configure, test, enable/disable, and manage issuers from the dashboard without restarting the server. Key changes: - AES-256-GCM encryption for sensitive issuer config at rest (PBKDF2 key derivation with 100k iterations) - Dynamic IssuerRegistry with sync.RWMutex replacing static map - Connector factory pattern (issuerfactory.NewFromConfig) replacing 140 lines of static wiring in main.go - Migration 000009: encrypted_config, last_tested_at, test_status, source columns on issuers table - Env var seeding on first boot with ON CONFLICT DO NOTHING - Registry Rebuild() for atomic map swap after CRUD operations - Issuer type validation against domain constants on Create - Audit trail for test connection results - Conditional seeding for step-ca/OpenSSL (only when env vars set) - GUI: source badge, connection test status on issuer detail page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// Package crypto provides AES-256-GCM encryption for sensitive configuration data.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM with a random 12-byte nonce prepended to the output.
|
||||
// The key must be exactly 32 bytes (AES-256). Returns [12-byte nonce][ciphertext+tag].
|
||||
func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ciphertext that was encrypted with Encrypt.
|
||||
// Expects format: [12-byte nonce][ciphertext+tag]. Key must be exactly 32 bytes.
|
||||
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("encryption key must be exactly 32 bytes, got %d", len(key))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext))
|
||||
}
|
||||
|
||||
nonce, ciphertextBody := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBody, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// DeriveKey derives a 32-byte AES-256 key from a passphrase using PBKDF2-SHA256.
|
||||
// Uses a fixed application-specific salt and 100,000 iterations for resistance
|
||||
// to brute-force attacks on weak passphrases.
|
||||
func DeriveKey(passphrase string) []byte {
|
||||
// Fixed salt is acceptable here because:
|
||||
// 1. Each certctl instance has its own passphrase
|
||||
// 2. The salt prevents generic rainbow table attacks
|
||||
// 3. Per-user salts are unnecessary (single server key, not user passwords)
|
||||
salt := []byte("certctl-config-encryption-v1")
|
||||
return pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
|
||||
}
|
||||
|
||||
// EncryptIfKeySet encrypts plaintext if a key is provided, otherwise returns plaintext unchanged.
|
||||
// This supports the development/demo fallback where encryption isn't configured.
|
||||
func EncryptIfKeySet(plaintext []byte, key []byte) ([]byte, bool, error) {
|
||||
if len(key) == 0 {
|
||||
return plaintext, false, nil
|
||||
}
|
||||
encrypted, err := Encrypt(plaintext, key)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return encrypted, true, nil
|
||||
}
|
||||
|
||||
// DecryptIfKeySet decrypts ciphertext if a key is provided, otherwise returns ciphertext unchanged.
|
||||
func DecryptIfKeySet(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
if len(key) == 0 {
|
||||
return ciphertext, nil
|
||||
}
|
||||
return Decrypt(ciphertext, key)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
key := DeriveKey("test-passphrase")
|
||||
plaintext := []byte(`{"api_key":"secret123","org_id":"456"}`)
|
||||
|
||||
encrypted, err := Encrypt(plaintext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if bytes.Equal(encrypted, plaintext) {
|
||||
t.Fatal("encrypted data should differ from plaintext")
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("round-trip failed: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWrongKey(t *testing.T) {
|
||||
key1 := DeriveKey("key-one")
|
||||
key2 := DeriveKey("key-two")
|
||||
plaintext := []byte("sensitive config data")
|
||||
|
||||
encrypted, err := Encrypt(plaintext, key1)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = Decrypt(encrypted, key2)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when decrypting with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptTamperedCiphertext(t *testing.T) {
|
||||
key := DeriveKey("test-key")
|
||||
plaintext := []byte("important data")
|
||||
|
||||
encrypted, err := Encrypt(plaintext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// Tamper with the ciphertext (flip a byte after the nonce)
|
||||
if len(encrypted) > 13 {
|
||||
encrypted[13] ^= 0xFF
|
||||
}
|
||||
|
||||
_, err = Decrypt(encrypted, key)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when decrypting tampered ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptEmptyPlaintext(t *testing.T) {
|
||||
key := DeriveKey("test-key")
|
||||
plaintext := []byte{}
|
||||
|
||||
encrypted, err := Encrypt(plaintext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt empty plaintext failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt empty plaintext failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("empty plaintext round-trip failed: got %q", decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptInvalidKeyLength(t *testing.T) {
|
||||
_, err := Encrypt([]byte("data"), []byte("short-key"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidKeyLength(t *testing.T) {
|
||||
_, err := Decrypt([]byte("some-ciphertext-data"), []byte("short-key"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptTooShortCiphertext(t *testing.T) {
|
||||
key := DeriveKey("test-key")
|
||||
_, err := Decrypt([]byte("short"), key)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-short ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyDeterministic(t *testing.T) {
|
||||
key1 := DeriveKey("same-passphrase")
|
||||
key2 := DeriveKey("same-passphrase")
|
||||
if !bytes.Equal(key1, key2) {
|
||||
t.Fatal("DeriveKey should be deterministic")
|
||||
}
|
||||
if len(key1) != 32 {
|
||||
t.Fatalf("DeriveKey should return 32 bytes, got %d", len(key1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyDifferentPassphrases(t *testing.T) {
|
||||
key1 := DeriveKey("passphrase-one")
|
||||
key2 := DeriveKey("passphrase-two")
|
||||
if bytes.Equal(key1, key2) {
|
||||
t.Fatal("different passphrases should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptIfKeySet_WithKey(t *testing.T) {
|
||||
key := DeriveKey("test-key")
|
||||
plaintext := []byte("config data")
|
||||
|
||||
result, wasEncrypted, err := EncryptIfKeySet(plaintext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
||||
}
|
||||
if !wasEncrypted {
|
||||
t.Fatal("expected wasEncrypted=true when key provided")
|
||||
}
|
||||
if bytes.Equal(result, plaintext) {
|
||||
t.Fatal("result should be encrypted")
|
||||
}
|
||||
|
||||
decrypted, err := DecryptIfKeySet(result, key)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptIfKeySet failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("round-trip failed: got %q", decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptIfKeySet_NilKey(t *testing.T) {
|
||||
plaintext := []byte("config data")
|
||||
|
||||
result, wasEncrypted, err := EncryptIfKeySet(plaintext, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptIfKeySet with nil key failed: %v", err)
|
||||
}
|
||||
if wasEncrypted {
|
||||
t.Fatal("expected wasEncrypted=false when key is nil")
|
||||
}
|
||||
if !bytes.Equal(result, plaintext) {
|
||||
t.Fatal("result should be unchanged plaintext when key is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptIfKeySet_NilKey(t *testing.T) {
|
||||
data := []byte("plaintext config data")
|
||||
|
||||
result, err := DecryptIfKeySet(data, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptIfKeySet with nil key failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, data) {
|
||||
t.Fatal("result should be unchanged when key is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
key := DeriveKey("test-key")
|
||||
plaintext := []byte("same data")
|
||||
|
||||
enc1, _ := Encrypt(plaintext, key)
|
||||
enc2, _ := Encrypt(plaintext, key)
|
||||
|
||||
if bytes.Equal(enc1, enc2) {
|
||||
t.Fatal("encrypting same plaintext twice should produce different ciphertexts (random nonce)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user