Files
certctl/internal/crypto/encryption_test.go
T
shankar0123 f549a7aa79 security: fail closed when CERTCTL_CONFIG_ENCRYPTION_KEY is unset (fixes C-2)
EncryptIfKeySet/DecryptIfKeySet in internal/crypto/encryption.go previously
returned plaintext + wasEncrypted=false when the operator had not configured
CERTCTL_CONFIG_ENCRYPTION_KEY. That produced a data-at-rest confidentiality
bypass (CWE-311): sensitive fields on dynamically-configured issuer and
target rows (source='database') were persisted to PostgreSQL without any
encryption, and no caller could distinguish the encrypted from the plaintext
branch at runtime. The only visible signal was a single warning log line
emitted once at startup.

Fail closed instead:

- EncryptIfKeySet / DecryptIfKeySet now return crypto.ErrEncryptionKeyRequired
  (a new exported sentinel, errors.Is-unwrappable) when the key is empty or
  nil, rather than silently emitting plaintext. The (result, wasEncrypted,
  err) tuple signature is preserved for source compatibility; only the
  semantics of the no-key branch changed.

- cmd/server/main.go grows a startup pre-flight check: if no encryption key
  is configured the server lists issuers and targets, counts rows with
  source='database', and refuses to start (os.Exit(1)) if any exist. Operators
  must either configure CERTCTL_CONFIG_ENCRYPTION_KEY or remove the exposed
  rows before the control plane can boot. The warning-only path is retained
  for the clean-slate case (no database rows).

- internal/service/issuer.go's SeedFromEnvVars now guards the encryption call
  with len(s.encryptionKey) > 0 so env-seeded rows (source='env', which are
  reconstructable on every boot from process env) continue to persist as
  plaintext in the 'config' column when no key is configured. Registry load
  already falls through to cfg.Config when EncryptedConfig is nil. GUI/API
  write paths (source='database') remain fail-closed via propagation of
  ErrEncryptionKeyRequired.

- Integration tests that exercise CreateIssuer via the handler layer now
  supply a real 32-byte AES-256 test key so the encrypt path runs instead of
  returning ErrEncryptionKeyRequired. Same pattern in internal/service/
  testutil_test.go for consolidated service-layer tests.

- internal/crypto/encryption_test.go grows regression guards:
  TestEncryptIfKeySet_EmptyKeyFailsClosed (nil_key + empty_key subtests),
  TestDecryptIfKeySet_EmptyKeyFailsClosed (nil_key + empty_key subtests),
  TestEncryptDecryptIfKeySet_RoundTripProducesDifferentCiphertext,
  TestDecryptIfKeySet_RejectsTamperedCiphertext, and
  TestEncryptIfKeySet_PreservesErrEncryptionKeyRequiredSentinel (verifies
  the sentinel unwraps through fmt.Errorf(%w)-style wrapping).

Wire format is unchanged: AES-256-GCM Encrypt/Decrypt/DeriveKey, the
12-byte nonce prefix, the GCM auth tag, the PBKDF2 salt
('certctl-config-encryption-v1'), and the 100,000 iteration count are all
byte-identical. Ciphertexts produced before this change remain decryptable.

Verified:
- go build ./... : clean
- go vet ./...   : clean
- go test -race ./internal/crypto/... ./internal/service/... \
    ./internal/integration/... ./cmd/server/... : pass
- golangci-lint run ./... : 0 issues
- govulncheck ./... : 0 reachable vulnerabilities
- rg 'return plaintext, false, nil' internal/ : no matches
- Coverage: crypto 85.0% (unchanged), service 67.8% (was 67.9%, noise),
  cmd/server 0.0% (unchanged baseline). All above CI thresholds.

See certctl-audit-report.md for the full finding record and resolution log.
2026-04-16 21:10:40 +00:00

299 lines
8.4 KiB
Go

package crypto
import (
"bytes"
"errors"
"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)
}
}
// TestEncryptIfKeySet_EmptyKeyFailsClosed asserts the C-2 regression guard:
// EncryptIfKeySet must refuse to silently emit plaintext when no key is configured.
// The pre-fix behavior was to return plaintext with wasEncrypted=false, which
// produced a data-at-rest confidentiality bypass (CWE-311) for GUI-created
// issuer and target configs.
func TestEncryptIfKeySet_EmptyKeyFailsClosed(t *testing.T) {
plaintext := []byte("config data")
cases := []struct {
name string
key []byte
}{
{"nil_key", nil},
{"empty_key", []byte{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, wasEncrypted, err := EncryptIfKeySet(plaintext, tc.key)
if err == nil {
t.Fatal("expected ErrEncryptionKeyRequired, got nil")
}
if !errors.Is(err, ErrEncryptionKeyRequired) {
t.Fatalf("expected ErrEncryptionKeyRequired, got %v", err)
}
if wasEncrypted {
t.Fatal("wasEncrypted must be false on error")
}
if result != nil {
t.Fatalf("expected nil result on error, got %q", result)
}
})
}
}
// TestDecryptIfKeySet_EmptyKeyFailsClosed asserts the matching C-2 regression
// guard on the read path: DecryptIfKeySet must refuse to pass ciphertext
// through as plaintext when no key is configured.
func TestDecryptIfKeySet_EmptyKeyFailsClosed(t *testing.T) {
data := []byte("plaintext config data")
cases := []struct {
name string
key []byte
}{
{"nil_key", nil},
{"empty_key", []byte{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, err := DecryptIfKeySet(data, tc.key)
if err == nil {
t.Fatal("expected ErrEncryptionKeyRequired, got nil")
}
if !errors.Is(err, ErrEncryptionKeyRequired) {
t.Fatalf("expected ErrEncryptionKeyRequired, got %v", err)
}
if result != nil {
t.Fatalf("expected nil result on error, got %q", result)
}
})
}
}
// TestEncryptDecryptIfKeySet_RoundTripProducesDifferentCiphertext proves the
// "if set" helpers produce real AES-GCM output (not plaintext) and that a full
// round-trip through both helpers recovers the original bytes.
func TestEncryptDecryptIfKeySet_RoundTripProducesDifferentCiphertext(t *testing.T) {
key := DeriveKey("round-trip-key")
plaintext := []byte(`{"api_key":"s3cr3t","token":"abc"}`)
encrypted, wasEncrypted, err := EncryptIfKeySet(plaintext, key)
if err != nil {
t.Fatalf("EncryptIfKeySet failed: %v", err)
}
if !wasEncrypted {
t.Fatal("wasEncrypted must be true when key is present")
}
if bytes.Equal(encrypted, plaintext) {
t.Fatal("EncryptIfKeySet returned plaintext — would regress C-2")
}
decrypted, err := DecryptIfKeySet(encrypted, key)
if err != nil {
t.Fatalf("DecryptIfKeySet failed: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Fatalf("round-trip mismatch: got %q, want %q", decrypted, plaintext)
}
}
// TestDecryptIfKeySet_RejectsTamperedCiphertext confirms the AEAD auth tag
// still rejects modified ciphertext when routed through the helper.
func TestDecryptIfKeySet_RejectsTamperedCiphertext(t *testing.T) {
key := DeriveKey("tamper-test-key")
plaintext := []byte("authenticated data")
encrypted, _, err := EncryptIfKeySet(plaintext, key)
if err != nil {
t.Fatalf("EncryptIfKeySet failed: %v", err)
}
// Flip a byte inside the GCM body (past the 12-byte nonce) to invalidate the tag.
if len(encrypted) <= 13 {
t.Fatalf("ciphertext too short to tamper: %d bytes", len(encrypted))
}
encrypted[13] ^= 0xFF
if _, err := DecryptIfKeySet(encrypted, key); err == nil {
t.Fatal("DecryptIfKeySet accepted tampered ciphertext — AEAD tag check bypassed")
}
}
// TestEncryptIfKeySet_PreservesErrEncryptionKeyRequiredSentinel guards the
// stability of the public sentinel error so audit-log detectors and callers
// outside this package can rely on errors.Is(err, ErrEncryptionKeyRequired).
func TestEncryptIfKeySet_PreservesErrEncryptionKeyRequiredSentinel(t *testing.T) {
if ErrEncryptionKeyRequired == nil {
t.Fatal("ErrEncryptionKeyRequired sentinel must be non-nil")
}
if ErrEncryptionKeyRequired.Error() == "" {
t.Fatal("ErrEncryptionKeyRequired must carry a non-empty message")
}
// Wrap it and confirm errors.Is unwraps correctly — real callers wrap with %w.
wrapped := wrapSentinel(ErrEncryptionKeyRequired)
if !errors.Is(wrapped, ErrEncryptionKeyRequired) {
t.Fatal("errors.Is must unwrap ErrEncryptionKeyRequired through %w-wrapped callers")
}
}
// wrapSentinel is a tiny helper that mimics how production callers propagate
// the sentinel (e.g. fmt.Errorf("failed to encrypt config: %w", err)).
func wrapSentinel(err error) error {
return errors.Join(errors.New("failed to encrypt config"), err)
}
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)")
}
}