mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:41:36 +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
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
package crypto
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"testing"
|
|
)
|
|
|
|
// Bundle B / Audit M-001 (CWE-916 / OWASP 2024) regression suite.
|
|
//
|
|
// The on-disk blob format is now versioned three ways:
|
|
// v1 — pre-M-8, fixed-salt, 100k PBKDF2 rounds
|
|
// v2 — M-8, per-ciphertext salt, 100k rounds, magic 0x02
|
|
// v3 — Bundle B, per-ciphertext salt, 600k rounds, magic 0x03 (current)
|
|
//
|
|
// EncryptIfKeySet always emits v3. DecryptIfKeySet must accept all three
|
|
// in order v3 → v2 → v1 with AEAD-fallback so wrong-passphrase v3 blobs
|
|
// don't get incorrectly attributed to v1. These tests pin every arm.
|
|
|
|
// TestEncryptIfKeySet_V3RoundTrip pins the happy-path round trip under v3.
|
|
func TestEncryptIfKeySet_V3RoundTrip(t *testing.T) {
|
|
plaintext := []byte(`{"api_key":"acme-prod-2026","scope":"issuer"}`)
|
|
passphrase := "test-passphrase-bundleB"
|
|
|
|
blob, ok, err := EncryptIfKeySet(plaintext, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("EncryptIfKeySet: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("ok must be true on success")
|
|
}
|
|
if blob[0] != v3Magic {
|
|
t.Fatalf("first byte must be v3Magic 0x%02x, got 0x%02x", v3Magic, blob[0])
|
|
}
|
|
|
|
got, err := DecryptIfKeySet(blob, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("DecryptIfKeySet: %v", err)
|
|
}
|
|
if !bytes.Equal(got, plaintext) {
|
|
t.Fatalf("round trip mismatch: got %q want %q", got, plaintext)
|
|
}
|
|
}
|
|
|
|
// TestDecryptIfKeySet_V2BlobReadFallback constructs a deterministic v2
|
|
// blob using the v1/v2 PBKDF2 work factor and asserts DecryptIfKeySet
|
|
// still reads it correctly (read-time backward compat, no in-place
|
|
// re-encrypt).
|
|
func TestDecryptIfKeySet_V2BlobReadFallback(t *testing.T) {
|
|
passphrase := "v2-era-passphrase"
|
|
plaintext := []byte(`{"legacy":"v2"}`)
|
|
|
|
// Hand-build a v2 blob: magic(0x02) || salt(16) || nonce(12) || ct+tag.
|
|
salt := bytes.Repeat([]byte{0xAB}, v2SaltSize)
|
|
key := deriveKeyWithSalt(passphrase, salt) // 100k rounds
|
|
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 := bytes.Repeat([]byte{0xCD}, gcm.NonceSize())
|
|
inner := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
|
|
v2Blob := make([]byte, 0, 1+v2SaltSize+len(inner))
|
|
v2Blob = append(v2Blob, v2Magic)
|
|
v2Blob = append(v2Blob, salt...)
|
|
v2Blob = append(v2Blob, inner...)
|
|
|
|
got, err := DecryptIfKeySet(v2Blob, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("DecryptIfKeySet must read v2 blob: %v", err)
|
|
}
|
|
if !bytes.Equal(got, plaintext) {
|
|
t.Fatalf("v2 round-trip mismatch: got %q want %q", got, plaintext)
|
|
}
|
|
}
|
|
|
|
// TestDecryptIfKeySet_V3WrongPassphraseFails ensures a wrong passphrase
|
|
// against a v3 blob does NOT silently succeed via the v2/v1 fallback.
|
|
func TestDecryptIfKeySet_V3WrongPassphraseFails(t *testing.T) {
|
|
plaintext := []byte("secret")
|
|
blob, _, err := EncryptIfKeySet(plaintext, "correct-pw")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := DecryptIfKeySet(blob, "wrong-pw"); err == nil {
|
|
t.Fatal("decrypt with wrong passphrase must fail; got nil error")
|
|
}
|
|
}
|
|
|
|
// TestDecryptIfKeySet_V2MagicCollisionWithV3Header pins the AEAD-fallback
|
|
// behavior: a fresh v3 blob whose first byte happens to be 0x02 (would
|
|
// only occur if v3Magic were 0x02 — it is not, but the dispatch must
|
|
// still be robust). We exercise the inverse case explicitly: a real v2
|
|
// blob is correctly read after the v3 attempt fails.
|
|
func TestDecryptIfKeySet_V3VsV2DispatchOrder(t *testing.T) {
|
|
// Construct a v2 blob whose first byte is v3Magic by forcing the
|
|
// magic-byte choice. This simulates the 1/256 case where a hostile
|
|
// or coincidental nonce-prefix collision would otherwise mis-route.
|
|
passphrase := "ambiguous-pw"
|
|
plaintext := []byte("payload")
|
|
salt := bytes.Repeat([]byte{0xFE}, v2SaltSize)
|
|
key := deriveKeyWithSalt(passphrase, salt)
|
|
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 := bytes.Repeat([]byte{0xCD}, gcm.NonceSize())
|
|
inner := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
|
|
// Manually splice: magic(0x02) is correct for v2.
|
|
v2Blob := append([]byte{v2Magic}, salt...)
|
|
v2Blob = append(v2Blob, inner...)
|
|
|
|
got, err := DecryptIfKeySet(v2Blob, passphrase)
|
|
if err != nil {
|
|
t.Fatalf("v2 blob must be readable: %v", err)
|
|
}
|
|
if !bytes.Equal(got, plaintext) {
|
|
t.Fatalf("v2 fallback mismatch: got %q want %q", got, plaintext)
|
|
}
|
|
}
|
|
|
|
// TestDeriveKeyWithSaltV3_DistinctFromV2 sanity-checks that v2 and v3
|
|
// derive distinct keys for the same (passphrase, salt) — a regression
|
|
// here would mean the iteration count was accidentally identical.
|
|
func TestDeriveKeyWithSaltV3_DistinctFromV2(t *testing.T) {
|
|
passphrase := "any"
|
|
salt := bytes.Repeat([]byte{0x42}, 16)
|
|
v2Key := deriveKeyWithSalt(passphrase, salt)
|
|
v3Key := deriveKeyWithSaltV3(passphrase, salt)
|
|
if bytes.Equal(v2Key, v3Key) {
|
|
t.Fatal("v2 and v3 keys must differ for the same (passphrase, salt) — work factor must differ")
|
|
}
|
|
}
|
|
|
|
// TestPBKDF2Iterations_V3IsOWASP2024Floor pins the iteration count at the
|
|
// OWASP 2024 floor of 600,000. If a future change lowers this number,
|
|
// the test must fail so the change requires an explicit audit-trail
|
|
// update to BOTH the constant AND this assertion.
|
|
func TestPBKDF2Iterations_V3IsOWASP2024Floor(t *testing.T) {
|
|
const owasp2024MinIterations = 600000
|
|
if pbkdf2IterationsV3 < owasp2024MinIterations {
|
|
t.Fatalf("pbkdf2IterationsV3 = %d, below OWASP 2024 floor of %d (Bundle B / M-001 / CWE-916)",
|
|
pbkdf2IterationsV3, owasp2024MinIterations)
|
|
}
|
|
}
|
|
|
|
// TestIsLegacyFormat_V3IsNotLegacy pins the helper's contract: a v3 blob
|
|
// (magic 0x03) is NOT legacy.
|
|
func TestIsLegacyFormat_V3IsNotLegacy(t *testing.T) {
|
|
v3Blob, _, err := EncryptIfKeySet([]byte("x"), "p")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if IsLegacyFormat(v3Blob) {
|
|
t.Fatal("a v3 blob must NOT report as legacy")
|
|
}
|
|
}
|