fix(crypto): per-ciphertext PBKDF2 salt + v2 versioned format with v1 fallback (M-8)

This commit is contained in:
shankar0123
2026-04-17 05:36:29 +00:00
parent b1df6dab27
commit 5abeeb882b
16 changed files with 580 additions and 158 deletions
+2 -2
View File
@@ -194,7 +194,7 @@ func TestConcurrentTargetCRUD(t *testing.T) {
Targets: make(map[string]*domain.DeploymentTarget),
}
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
targetSvc := NewTargetService(mockTargetRepo, nil, nil, "", slog.New(slog.NewTextHandler(os.Stderr, nil)))
var mu sync.Mutex
createdTargets := make([]string, 0)
@@ -403,7 +403,7 @@ func TestConcurrentMixedOperations(t *testing.T) {
// Setup services
auditSvc := &AuditService{auditRepo: mockAuditRepo}
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, "", slog.New(slog.NewTextHandler(os.Stderr, nil)))
var wg sync.WaitGroup
errChan := make(chan error, 30)
+1 -1
View File
@@ -142,7 +142,7 @@ func TestTargetService_ListWithCancelledContext(t *testing.T) {
mockTargetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
targetSvc := NewTargetService(mockTargetRepo, nil, nil, "", slog.New(slog.NewTextHandler(os.Stderr, nil)))
_, _, err := targetSvc.List(ctx, 1, 50)
+10 -3
View File
@@ -17,20 +17,27 @@ import (
)
// IssuerService provides business logic for certificate issuer management.
//
// The encryptionKey field holds the raw passphrase (not a pre-derived 32-byte
// key). Per-ciphertext salt derivation is performed inside
// [crypto.EncryptIfKeySet] / [crypto.DecryptIfKeySet] on each call. See M-8
// in certctl-audit-report.md.
type IssuerService struct {
issuerRepo repository.IssuerRepository
auditService *AuditService
registry *IssuerRegistry
encryptionKey []byte
encryptionKey string
logger *slog.Logger
}
// NewIssuerService creates a new issuer service.
// NewIssuerService creates a new issuer service. The encryptionKey is the raw
// passphrase; it MUST NOT be pre-derived via crypto.DeriveKey (that was the
// v1 behavior, replaced in M-8 with per-ciphertext random salt).
func NewIssuerService(
issuerRepo repository.IssuerRepository,
auditService *AuditService,
registry *IssuerRegistry,
encryptionKey []byte,
encryptionKey string,
logger *slog.Logger,
) *IssuerService {
return &IssuerService{
+8 -8
View File
@@ -26,7 +26,7 @@ func TestBuildEnvVarSeeds_ACMEConfig(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
// Call buildEnvVarSeeds (unexported method, but testable from same package)
seeds := service.buildEnvVarSeeds(cfg)
@@ -82,7 +82,7 @@ func TestBuildEnvVarSeeds_VaultConfig(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
seeds := service.buildEnvVarSeeds(cfg)
@@ -136,7 +136,7 @@ func TestBuildEnvVarSeeds_NoConfig(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
seeds := service.buildEnvVarSeeds(cfg)
@@ -186,7 +186,7 @@ func TestBuildEnvVarSeeds_MultipleConfigs(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
seeds := service.buildEnvVarSeeds(cfg)
@@ -232,7 +232,7 @@ func TestSeedFromEnvVars_Empty(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
// Call SeedFromEnvVars on empty repo
service.SeedFromEnvVars(ctx, cfg)
@@ -280,7 +280,7 @@ func TestSeedFromEnvVars_AlreadyExists(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
// Get count before seeding
beforeSeeding, _ := repo.List(ctx)
@@ -328,7 +328,7 @@ func TestBuildRegistry_Success(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
// Call BuildRegistry
err := service.BuildRegistry(ctx)
@@ -351,7 +351,7 @@ func TestBuildRegistry_EmptyDatabase(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
// Call BuildRegistry on empty database
err := service.BuildRegistry(ctx)
+6 -1
View File
@@ -72,7 +72,12 @@ func (r *IssuerRegistry) Len() int {
// For each enabled issuer, it decrypts the config (if encryption key is set),
// instantiates a connector via the factory, wraps it in an adapter, and
// atomically swaps the entire map.
func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey []byte) error {
//
// The encryption passphrase is passed as a string; per-ciphertext salt derivation
// for v2 blobs is performed inside [crypto.DecryptIfKeySet]. Empty passphrase
// fails closed via [crypto.ErrEncryptionKeyRequired] when encrypted configs
// are encountered. See M-8 in certctl-audit-report.md.
func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey string) error {
newIssuers := make(map[string]IssuerConnector)
var errors []string
+13 -11
View File
@@ -101,7 +101,7 @@ func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) {
},
}
err := reg.Rebuild(configs, nil)
err := reg.Rebuild(configs, "")
if err != nil {
t.Fatalf("Rebuild failed: %v", err)
}
@@ -124,11 +124,12 @@ func TestIssuerRegistry_Rebuild_Enabled(t *testing.T) {
func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) {
reg := NewIssuerRegistry(registryTestLogger())
key := crypto.DeriveKey("test-key")
configJSON := []byte(`{"ca_common_name":"Encrypted CA"}`)
encrypted, err := crypto.Encrypt(configJSON, key)
// M-8: EncryptIfKeySet now emits v2 (magic 0x02 || per-ciphertext salt || sealed).
// IssuerRegistry.Rebuild accepts the raw passphrase and delegates PBKDF2 to crypto.DecryptIfKeySet.
encrypted, _, err := crypto.EncryptIfKeySet(configJSON, "test-key")
if err != nil {
t.Fatalf("encrypt failed: %v", err)
t.Fatalf("EncryptIfKeySet failed: %v", err)
}
configs := []*domain.Issuer{
@@ -141,7 +142,7 @@ func TestIssuerRegistry_Rebuild_WithEncryption(t *testing.T) {
},
}
err = reg.Rebuild(configs, key)
err = reg.Rebuild(configs, "test-key")
if err != nil {
t.Fatalf("Rebuild with encryption failed: %v", err)
}
@@ -165,10 +166,11 @@ func TestIssuerRegistry_Rebuild_NilKeyFallback(t *testing.T) {
},
}
// nil key should work — falls back to config column
err := reg.Rebuild(configs, nil)
// Empty passphrase is safe when no EncryptedConfig is present — falls back to config column.
// The C-2 fail-closed sentinel only fires when EncryptedConfig is non-empty.
err := reg.Rebuild(configs, "")
if err != nil {
t.Fatalf("Rebuild with nil key failed: %v", err)
t.Fatalf("Rebuild with empty key failed: %v", err)
}
_, ok := reg.Get("iss-plain")
@@ -198,7 +200,7 @@ func TestIssuerRegistry_Rebuild_InvalidConfig(t *testing.T) {
}
// Should return an error indicating partial failure, but still load valid issuers
err := reg.Rebuild(configs, nil)
err := reg.Rebuild(configs, "")
if err == nil {
t.Fatal("Rebuild should return error when some issuers fail to load")
}
@@ -230,7 +232,7 @@ func TestIssuerRegistry_Rebuild_ReplacesExisting(t *testing.T) {
},
}
err := reg.Rebuild(configs, nil)
err := reg.Rebuild(configs, "")
if err != nil {
t.Fatalf("Rebuild failed: %v", err)
}
@@ -275,7 +277,7 @@ func TestIssuerRegistry_Rebuild_Empty(t *testing.T) {
reg.Set("iss-existing", &mockIssuerConnector{})
err := reg.Rebuild([]*domain.Issuer{}, nil)
err := reg.Rebuild([]*domain.Issuer{}, "")
if err != nil {
t.Fatalf("Rebuild with empty configs failed: %v", err)
}
+15 -15
View File
@@ -50,7 +50,7 @@ func TestIssuerService_List(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
issuers, total, err := service.List(ctx, 1, 2)
@@ -87,7 +87,7 @@ func TestIssuerService_List_DefaultPagination(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
// Call with invalid page and perPage
issuers, total, err := service.List(ctx, 0, 0)
@@ -115,7 +115,7 @@ func TestIssuerService_List_RepositoryError(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
_, _, err := service.List(ctx, 1, 50)
@@ -137,7 +137,7 @@ func TestIssuerService_List_EmptyResult(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
issuers, total, err := service.List(ctx, 1, 50)
@@ -173,7 +173,7 @@ func TestIssuerService_Get(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
retrieved, err := service.Get(ctx, "iss-acme-prod")
@@ -199,7 +199,7 @@ func TestIssuerService_Get_NotFound(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
_, err := service.Get(ctx, "nonexistent-issuer")
@@ -280,7 +280,7 @@ func TestIssuerService_Create_EmptyName(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
issuer := &domain.Issuer{
Name: "",
@@ -314,7 +314,7 @@ func TestIssuerService_Create_RepositoryError(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
issuer := &domain.Issuer{
Name: "Test Issuer",
@@ -387,7 +387,7 @@ func TestIssuerService_Update_EmptyName(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
issuer := &domain.Issuer{
Name: "",
@@ -415,7 +415,7 @@ func TestIssuerService_Delete(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
err := service.Delete(ctx, "iss-to-delete", "user-frank")
@@ -447,7 +447,7 @@ func TestIssuerService_Delete_RepositoryError(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
err := service.Delete(ctx, "iss-bad-id", "user-grace")
@@ -482,7 +482,7 @@ func TestIssuerService_TestConnection_Success(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
svc := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
err := svc.TestConnectionWithContext(ctx, "iss-test-conn")
@@ -500,7 +500,7 @@ func TestIssuerService_TestConnection_NotFound(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
err := service.TestConnectionWithContext(ctx, "nonexistent-issuer")
@@ -540,7 +540,7 @@ func TestIssuerService_ListIssuers_HandlerInterface(t *testing.T) {
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
issuers, total, err := service.ListIssuers(1, 50)
@@ -606,7 +606,7 @@ func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) {
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
err := service.DeleteIssuer("iss-handler-delete")
+10 -3
View File
@@ -36,20 +36,27 @@ func isValidTargetType(t domain.TargetType) bool {
}
// TargetService provides business logic for deployment target management.
//
// The encryptionKey field holds the raw passphrase (not a pre-derived 32-byte
// key). Per-ciphertext salt derivation is performed inside
// [crypto.EncryptIfKeySet] / [crypto.DecryptIfKeySet] on each call. See M-8
// in certctl-audit-report.md.
type TargetService struct {
targetRepo repository.TargetRepository
agentRepo repository.AgentRepository
auditService *AuditService
encryptionKey []byte
encryptionKey string
logger *slog.Logger
}
// NewTargetService creates a new target service.
// NewTargetService creates a new target service. The encryptionKey is the raw
// passphrase; it MUST NOT be pre-derived via crypto.DeriveKey (that was the
// v1 behavior, replaced in M-8 with per-ciphertext random salt).
func NewTargetService(
targetRepo repository.TargetRepository,
auditService *AuditService,
agentRepo repository.AgentRepository,
encryptionKey []byte,
encryptionKey string,
logger *slog.Logger,
) *TargetService {
return &TargetService{
+7 -4
View File
@@ -12,12 +12,15 @@ import (
var errNotFound = errors.New("not found")
// testEncryptionKey is a deterministic 32-byte AES-256 key for unit tests that
// testEncryptionKey is a deterministic passphrase for unit tests that
// exercise IssuerService/TargetService write paths. After the C-2 remediation
// these services fail closed when no key is configured, so happy-path tests
// must supply a real key. Using a constant keeps wire-format assertions stable
// across runs and avoids flaky PBKDF2 timing.
var testEncryptionKey = []byte("0123456789abcdef0123456789abcdef") // 32 bytes
// must supply a real passphrase. M-8 reshaped the type from []byte to string
// because services now hold the raw passphrase and delegate PBKDF2 to
// crypto.EncryptIfKeySet / crypto.DecryptIfKeySet (which apply a fresh random
// salt per ciphertext). Using a constant keeps wire-format assertions stable
// across runs.
var testEncryptionKey = "0123456789abcdef0123456789abcdef"
// mockCertRepo is a test implementation of CertificateRepository
type mockCertRepo struct {