mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 00:58:53 +00:00
fix(crypto): per-ciphertext PBKDF2 salt + v2 versioned format with v1 fallback (M-8)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user