mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
f549a7aa79
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.
299 lines
8.4 KiB
Go
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)")
|
|
}
|
|
}
|