mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:21:35 +00:00
30f9f1e712
Closes M-001 + M-002 + M-013 + M-018 + M-025 from
comprehensive-audit-2026-04-25.
M-001 (CWE-916) — PBKDF2 100k -> 600k via v3 blob format
internal/crypto/encryption.go:
- New v3Magic (0x03), pbkdf2IterationsV3 (600,000 — OWASP 2024
Password Storage Cheat Sheet floor), v3SaltSize (16 bytes),
deriveKeyWithSaltV3 helper.
- EncryptIfKeySet now unconditionally writes v3:
magic(0x03) || salt(16) || nonce(12) || ciphertext+tag
- DecryptIfKeySet falls through v3 -> v2 -> v1 with AEAD verification
at each step. Wrong-passphrase v3 reads cannot be silently
misattributed to v2/v1.
- IsLegacyFormat updated to recognize 0x03 as non-legacy.
internal/crypto/encryption_v3_test.go (NEW, 7 tests):
V3 round-trip / V2 read-fallback against deterministic v2 fixture /
V3 wrong-passphrase fails / V3-vs-V2 dispatch order / V2 vs V3 keys
differ for same (passphrase, salt) / iteration-count pin at OWASP
2024 floor / IsLegacyFormat-recognises-V3.
Coverage internal/crypto: 86.7% -> 88.2%.
M-002 (CWE-862) — Auth-exempt allowlist constants + AST regression test
Recon found auth-exempt surface spans TWO layers (audit's claim was
incomplete):
Layer 1 (router.go direct r.mux.Handle):
GET /health, GET /ready, GET /api/v1/auth/info, GET /api/v1/version
Layer 2 (cmd/server/main.go::buildFinalHandler URL-prefix dispatch):
/.well-known/pki/*, /.well-known/est/*, /scep[/...]*
internal/api/router/router.go:
- New AuthExemptRouterRoutes constant with per-entry justifications.
- New AuthExemptDispatchPrefixes constant.
internal/api/router/auth_exempt_test.go (NEW, 2 tests):
AST-walks router.go for every direct mux.Handle call and asserts
set equals AuthExemptRouterRoutes; reads source bytes of Register /
RegisterFunc and asserts they still wrap with middleware.Chain.
cmd/server/auth_exempt_test.go (NEW, 2 tests):
14-case table test on buildFinalHandler asserting documented
prefixes route to noAuthHandler and authenticated routes route to
apiHandler; inverse-overlap pin proves no documented bypass shadows
an authenticated prefix.
M-013 (CWE-942) — CORS deny-by-default verified-already-clean + pin
Audit claim 'default allows all origins if env-var unset' was WRONG.
internal/api/middleware/middleware.go::NewCORS already denies cross-
origin requests when len(cfg.AllowedOrigins) == 0 (no
Access-Control-Allow-Origin header is emitted, same-origin policy
applies).
internal/api/middleware/cors_test.go: +TestNewCORS_NilOriginsDeniesAll
+ TestNewCORS_M013_ContractDocumentedInOrder (5-case table test
pinning the 3-arm dispatch contract).
M-018 (CWE-319 / PCI-DSS Req 4) — Postgres TLS opt-in toggle
deploy/helm/certctl/values.yaml: new postgresql.tls.{mode,caSecretRef}
operator-facing knobs. Default 'disable' preserves in-cluster pod-
network behavior; PCI-scoped operators set verify-full.
deploy/helm/certctl/templates/_helpers.tpl: certctl.databaseURL helper
pipes postgresql.tls.mode into ?sslmode=.
deploy/helm/certctl/templates/server-secret.yaml: uses the helper
instead of hardcoded sslmode=disable.
deploy/docker-compose.yml: CERTCTL_DATABASE_URL is now
${CERTCTL_DATABASE_URL:-...} so operators override without editing.
docs/database-tls.md (NEW): operator runbook covering 4 deployment
shapes, RDS verify-full example with PGSSLROOTCERT mount, and
pg_stat_ssl verification query.
helm template + helm lint clean.
M-025 (OWASP ASVS L2 §11.2.1) — Per-key rate limiting
internal/api/middleware/middleware.go::NewRateLimiter rewritten from
a single global tokenBucket to a keyedRateLimiter map keyed on
'user:'+GetUser(ctx) for authenticated callers
'ip:'+RemoteAddr-host for unauthenticated
- Empty UserKey strings treated as unauthenticated.
- X-Forwarded-For intentionally NOT consulted (header-spoofing risk).
- Create-on-demand bucket allocation under sync.RWMutex with double-
check pattern.
RateLimitConfig.PerUserRPS / PerUserBurstSize fields with env vars
CERTCTL_RATE_LIMIT_PER_USER_RPS / CERTCTL_RATE_LIMIT_PER_USER_BURST
allow per-user budgets distinct from per-IP.
internal/api/middleware/ratelimit_keyed_test.go (NEW, 5 tests):
TwoIPsHaveIndependentBuckets / SameUserDifferentIPsShareBucket /
TwoUsersHaveIndependentBuckets / PerUserBudgetOverride /
EmptyUserKeyTreatedAsAnonymous.
Coverage internal/api/middleware: 82.1% -> 83.7%.
Audit deliverables:
cowork/comprehensive-audit-2026-04-25/audit-report.md: score
25/55 -> 30/55 closed (High 7/9, Medium 7/27 -> 12/27, Low 8/19).
cowork/comprehensive-audit-2026-04-25/findings.yaml: 5 status flips
open -> closed with closure notes citing the Bundle B mechanism.
certctl/CHANGELOG.md: Bundle B section under [unreleased].
Verification:
go test -count=1 -short ./... all green
staticcheck on changed packages no new SA*/ST* hits
(the 4 pre-existing SA1019 sites in cmd/server/main_test.go are
Bundle 9 / M-028 partial closure leftovers tracked in Bundle C)
helm template + helm lint clean
internal/repository/postgres setup-fail sandbox disk pressure,
same on master HEAD before this branch — environmental, not Bundle B
493 lines
17 KiB
Go
493 lines
17 KiB
Go
package crypto
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"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) {
|
|
plaintext := []byte("config data")
|
|
|
|
result, wasEncrypted, err := EncryptIfKeySet(plaintext, "test-passphrase")
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
|
}
|
|
if !wasEncrypted {
|
|
t.Fatal("expected wasEncrypted=true when passphrase provided")
|
|
}
|
|
if bytes.Equal(result, plaintext) {
|
|
t.Fatal("result should be encrypted")
|
|
}
|
|
|
|
decrypted, err := DecryptIfKeySet(result, "test-passphrase")
|
|
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 passphrase 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")
|
|
|
|
result, wasEncrypted, err := EncryptIfKeySet(plaintext, "")
|
|
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 passphrase is configured.
|
|
func TestDecryptIfKeySet_EmptyKeyFailsClosed(t *testing.T) {
|
|
data := []byte("plaintext config data")
|
|
|
|
result, err := DecryptIfKeySet(data, "")
|
|
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) {
|
|
plaintext := []byte(`{"api_key":"s3cr3t","token":"abc"}`)
|
|
|
|
encrypted, wasEncrypted, err := EncryptIfKeySet(plaintext, "round-trip-key")
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
|
}
|
|
if !wasEncrypted {
|
|
t.Fatal("wasEncrypted must be true when passphrase is present")
|
|
}
|
|
if bytes.Equal(encrypted, plaintext) {
|
|
t.Fatal("EncryptIfKeySet returned plaintext — would regress C-2")
|
|
}
|
|
|
|
decrypted, err := DecryptIfKeySet(encrypted, "round-trip-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. The v2
|
|
// wire format is magic(1) || salt(16) || nonce(12) || ciphertext+tag, so
|
|
// flipping a byte anywhere past offset 29 lands squarely inside the AEAD body.
|
|
func TestDecryptIfKeySet_RejectsTamperedCiphertext(t *testing.T) {
|
|
plaintext := []byte("authenticated data")
|
|
|
|
encrypted, _, err := EncryptIfKeySet(plaintext, "tamper-test-key")
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
|
}
|
|
// Flip a byte past the v2 header (1 + 16 + 12 = 29) to invalidate the tag.
|
|
const minV2HeaderLen = 1 + v2SaltSize + 12
|
|
if len(encrypted) <= minV2HeaderLen {
|
|
t.Fatalf("ciphertext too short to tamper: %d bytes", len(encrypted))
|
|
}
|
|
encrypted[minV2HeaderLen] ^= 0xFF
|
|
|
|
if _, err := DecryptIfKeySet(encrypted, "tamper-test-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)")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M-8 additions: per-ciphertext salt + v2 wire format + v1 backward compat.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestDeriveKey_DifferentSaltsProduceDifferentKeys asserts that
|
|
// deriveKeyWithSalt fans out distinct 32-byte keys for the same passphrase
|
|
// across different salts. This is the core M-8 defense-in-depth property: even
|
|
// if an attacker obtains two v2 ciphertexts encrypted with the same master
|
|
// passphrase, the derived AES keys differ, and a brute-force attempt on one
|
|
// blob cannot be amortized across the other.
|
|
func TestDeriveKey_DifferentSaltsProduceDifferentKeys(t *testing.T) {
|
|
passphrase := "master-passphrase"
|
|
saltA := bytes.Repeat([]byte{0xAA}, v2SaltSize)
|
|
saltB := bytes.Repeat([]byte{0xBB}, v2SaltSize)
|
|
|
|
keyA := deriveKeyWithSalt(passphrase, saltA)
|
|
keyB := deriveKeyWithSalt(passphrase, saltB)
|
|
|
|
if len(keyA) != aes256KeySize || len(keyB) != aes256KeySize {
|
|
t.Fatalf("derived key length wrong: %d / %d", len(keyA), len(keyB))
|
|
}
|
|
if bytes.Equal(keyA, keyB) {
|
|
t.Fatal("deriveKeyWithSalt must produce different keys for different salts")
|
|
}
|
|
|
|
// Sanity-check that deterministic behaviour is preserved under a fixed salt.
|
|
keyA2 := deriveKeyWithSalt(passphrase, saltA)
|
|
if !bytes.Equal(keyA, keyA2) {
|
|
t.Fatal("deriveKeyWithSalt must be deterministic for a fixed (passphrase, salt)")
|
|
}
|
|
}
|
|
|
|
// TestEncryptIfKeySet_ProducesV2Format asserts the exact v2 wire-format bytes:
|
|
// magic(0x02) || salt(16) || nonce(12) || ciphertext+tag.
|
|
// TestEncryptIfKeySet_ProducesV3Format pins the Bundle B / M-001 write
|
|
// path: every fresh blob carries magic byte 0x03 and the v3 layout.
|
|
func TestEncryptIfKeySet_ProducesV3Format(t *testing.T) {
|
|
blob, _, err := EncryptIfKeySet([]byte("hello"), "any-passphrase")
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
|
}
|
|
|
|
const minLen = 1 + v3SaltSize + 12 + 16 // magic + salt + nonce + GCM tag (16)
|
|
if len(blob) < minLen {
|
|
t.Fatalf("v3 blob too short: got %d, want >= %d", len(blob), minLen)
|
|
}
|
|
if blob[0] != v3Magic {
|
|
t.Fatalf("v3 blob must start with magic byte 0x%02x, got 0x%02x", v3Magic, blob[0])
|
|
}
|
|
if IsLegacyFormat(blob) {
|
|
t.Fatal("IsLegacyFormat must return false for a freshly produced v3 blob")
|
|
}
|
|
}
|
|
|
|
// TestEncryptIfKeySet_SaltIsRandom asserts that two calls with the same
|
|
// passphrase and plaintext produce distinct embedded salts.
|
|
func TestEncryptIfKeySet_SaltIsRandom(t *testing.T) {
|
|
plaintext := []byte("same plaintext")
|
|
passphrase := "same-passphrase"
|
|
|
|
blob1, _, err := EncryptIfKeySet(plaintext, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet #1 failed: %v", err)
|
|
}
|
|
blob2, _, err := EncryptIfKeySet(plaintext, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet #2 failed: %v", err)
|
|
}
|
|
|
|
salt1 := blob1[1 : 1+v3SaltSize]
|
|
salt2 := blob2[1 : 1+v3SaltSize]
|
|
if bytes.Equal(salt1, salt2) {
|
|
t.Fatal("two EncryptIfKeySet invocations must produce distinct per-ciphertext salts")
|
|
}
|
|
if bytes.Equal(blob1, blob2) {
|
|
t.Fatal("two v3 blobs with same (passphrase, plaintext) must differ end-to-end")
|
|
}
|
|
}
|
|
|
|
// TestDecryptIfKeySet_V1BackwardCompat builds a deterministic v1-format
|
|
// ciphertext using the pre-M-8 recipe (DeriveKey with the fixed salt, then
|
|
// Encrypt with an all-zero nonce for reproducibility) and asserts that
|
|
// DecryptIfKeySet still decrypts it correctly. This is the migration guarantee:
|
|
// v1 blobs persisted before M-8 must remain decryptable.
|
|
func TestDecryptIfKeySet_V1BackwardCompat(t *testing.T) {
|
|
passphrase := "legacy-passphrase"
|
|
plaintext := []byte(`{"api_key":"legacy","org_id":"789"}`)
|
|
|
|
// Build a deterministic v1 blob directly: nonce(12 zero bytes) || ct+tag.
|
|
// This matches the exact wire shape that Encrypt produces, minus the random
|
|
// nonce, so the test is stable rather than 1/256 flaky.
|
|
key := DeriveKey(passphrase) // fixed-salt derivation (pre-M-8 behavior)
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
t.Fatalf("aes.NewCipher: %v", err)
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
t.Fatalf("cipher.NewGCM: %v", err)
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize()) // all zeros → first byte != v2Magic
|
|
v1Blob := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
if v1Blob[0] == v2Magic {
|
|
t.Fatalf("fixture nonce collided with v2 magic byte — test design error")
|
|
}
|
|
|
|
decrypted, err := DecryptIfKeySet(v1Blob, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("DecryptIfKeySet(v1) failed: %v", err)
|
|
}
|
|
if !bytes.Equal(decrypted, plaintext) {
|
|
t.Fatalf("v1 decrypt mismatch: got %q, want %q", decrypted, plaintext)
|
|
}
|
|
|
|
// Cross-check: IsLegacyFormat should flag this as legacy.
|
|
if !IsLegacyFormat(v1Blob) {
|
|
t.Fatal("IsLegacyFormat must return true for a v1 blob whose first byte != v2Magic")
|
|
}
|
|
}
|
|
|
|
// TestDecryptIfKeySet_V1MagicByteCollisionFallsThrough covers the 1/256 edge
|
|
// case where a v1 ciphertext's random 12-byte nonce happens to begin with
|
|
// 0x02. The dispatch must attempt v2, see AEAD failure, and fall through to
|
|
// v1 — never return a decrypt error when the passphrase is correct.
|
|
func TestDecryptIfKeySet_V1MagicByteCollisionFallsThrough(t *testing.T) {
|
|
passphrase := "collision-passphrase"
|
|
plaintext := []byte("colliding v1 blob")
|
|
|
|
// Craft a v1 blob whose first byte equals v2Magic by choosing a nonce
|
|
// starting with 0x02 and sealing manually.
|
|
key := DeriveKey(passphrase)
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
t.Fatalf("aes.NewCipher: %v", err)
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
t.Fatalf("cipher.NewGCM: %v", err)
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
nonce[0] = v2Magic // force collision
|
|
v1Blob := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
if v1Blob[0] != v2Magic {
|
|
t.Fatal("fixture construction bug: first byte must equal v2Magic")
|
|
}
|
|
|
|
decrypted, err := DecryptIfKeySet(v1Blob, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("DecryptIfKeySet must fall through to v1 on AEAD failure, got err: %v", err)
|
|
}
|
|
if !bytes.Equal(decrypted, plaintext) {
|
|
t.Fatalf("v1-via-fallback decrypt mismatch: got %q, want %q", decrypted, plaintext)
|
|
}
|
|
}
|
|
|
|
// TestDecryptIfKeySet_V2WithWrongPassphraseFails asserts that a v2 blob
|
|
// sealed under passphrase A cannot be decrypted under passphrase B. Both the
|
|
// v2 AEAD verify (with salt from the blob + passphrase B) and the v1 fallback
|
|
// (with fixed salt + passphrase B) must fail, and an error must be returned
|
|
// rather than silently-corrupt plaintext.
|
|
func TestDecryptIfKeySet_V2WithWrongPassphraseFails(t *testing.T) {
|
|
blob, _, err := EncryptIfKeySet([]byte("secret"), "passphrase-A")
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
|
}
|
|
|
|
got, err := DecryptIfKeySet(blob, "passphrase-B")
|
|
if err == nil {
|
|
t.Fatalf("DecryptIfKeySet must return error for wrong passphrase, got plaintext %q", got)
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("result must be nil on decrypt error, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestDecryptIfKeySet_TruncatedV2Blob asserts that a blob starting with the v2
|
|
// magic byte but too short to contain a full v2 header does not trip an
|
|
// out-of-bounds slice and does not succeed. It either returns an error (v1
|
|
// fallback on the short bytes fails with "ciphertext too short") or at minimum
|
|
// never returns plaintext.
|
|
func TestDecryptIfKeySet_TruncatedV2Blob(t *testing.T) {
|
|
truncated := []byte{v2Magic, 0x00, 0x01, 0x02, 0x03} // 5 bytes — well below the 29-byte v2 minimum
|
|
got, err := DecryptIfKeySet(truncated, "any-passphrase")
|
|
if err == nil {
|
|
t.Fatalf("DecryptIfKeySet must reject a truncated v2 blob, got plaintext %q", got)
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("result must be nil on decrypt error, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestIsLegacyFormat covers the three branches of the public magic-byte
|
|
// heuristic: v2 blob → false, v1 blob → true, empty blob → false.
|
|
func TestIsLegacyFormat(t *testing.T) {
|
|
v2Blob, _, err := EncryptIfKeySet([]byte("data"), "p")
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet failed: %v", err)
|
|
}
|
|
if IsLegacyFormat(v2Blob) {
|
|
t.Fatal("v2 blob must not be flagged as legacy")
|
|
}
|
|
|
|
// Any blob whose first byte isn't v2Magic should be reported as legacy.
|
|
v1Shape := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF}
|
|
if !IsLegacyFormat(v1Shape) {
|
|
t.Fatal("non-v2-magic blob must be flagged as legacy")
|
|
}
|
|
|
|
if IsLegacyFormat(nil) {
|
|
t.Fatal("nil blob must not be flagged as legacy (undefined)")
|
|
}
|
|
if IsLegacyFormat([]byte{}) {
|
|
t.Fatal("empty blob must not be flagged as legacy (undefined)")
|
|
}
|
|
}
|