Files
certctl/internal/crypto/encryption.go
T
shankar0123 30f9f1e712 Bundle B: Auth & transport surface tightening — 5 findings closed
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
2026-04-26 23:09:10 +00:00

363 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package crypto provides AES-256-GCM encryption for sensitive configuration data.
//
// The on-disk format for blobs produced by [EncryptIfKeySet] is versioned.
// Three versions coexist; the write path always emits v3, the read path
// (DecryptIfKeySet) accepts all three:
//
// v3 (current, Bundle B / M-001)
// magic(0x03) || salt(16) || nonce(12) || ciphertext+tag
// — 32-byte AES-256 key derived via PBKDF2-SHA256 (600,000 rounds)
// from the operator passphrase and the per-ciphertext random salt.
// OWASP 2024 recommends 600,000 rounds for SHA-256 PBKDF2; this is
// a 6× increase over v2.
//
// v2 (legacy, M-8)
// magic(0x02) || salt(16) || nonce(12) || ciphertext+tag
// — 32-byte AES-256 key derived via PBKDF2-SHA256 (100,000 rounds)
// from the operator passphrase and the per-ciphertext random salt.
//
// v1 (legacy, pre-M-8)
// nonce(12) || ciphertext+tag
// — 32-byte AES-256 key derived via PBKDF2-SHA256 (100,000 rounds)
// from the operator passphrase and the package-level fixed salt
// "certctl-config-encryption-v1".
//
// v1 and v2 blobs are accepted by the read path for backward compatibility
// with rows persisted before each remediation. They are never produced by the
// write path. Any row that is updated after Bundle B is re-sealed as v3
// in-place via the normal UPDATE flow.
//
// Rationale for the iteration bump (see Bundle B / Audit M-001 / CWE-916):
// PBKDF2 work factor is the only knob that bounds an attacker's ability to
// brute-force a leaked passphrase + ciphertext pair. OWASP's December-2023
// Password Storage Cheat Sheet raises the SHA-256 PBKDF2 floor to 600,000;
// 100k was the 2018-era floor. v3 brings certctl onto the current floor at
// the cost of ~6× more boot-time CPU on the encryption code path (a
// configuration-load operation, so amortized across the entire process
// lifetime).
//
// Rationale for the per-ciphertext salt (M-8 / CWE-916 / CWE-329): the
// pre-M-8 design reused a single 28-byte fixed salt for every ciphertext,
// which (a) removes one defense-in-depth layer against passphrase-space
// brute force and (b) makes every encrypted column across every row share
// the exact same derived key. v2/v3 replace the fixed salt with 16 fresh
// random bytes per write and store the salt alongside the ciphertext.
// Derived keys differ per row and per re-encryption.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"golang.org/x/crypto/pbkdf2"
)
// ErrEncryptionKeyRequired is returned by EncryptIfKeySet and DecryptIfKeySet when
// the caller provides an empty passphrase but the data on the wire requires
// protection.
//
// Historically these helpers silently returned plaintext when no key was configured,
// which produced a data-at-rest confidentiality bypass (CWE-311): sensitive fields
// in dynamically-configured issuer and target records (source='database') were
// persisted to PostgreSQL without any encryption whenever the operator forgot to
// set CERTCTL_CONFIG_ENCRYPTION_KEY. Callers could not distinguish the encrypted
// and plaintext branches at runtime, so the only visible signal was a warning
// line emitted once at startup.
//
// The fix (C-2, commit fb4ce1a) is to fail closed: EncryptIfKeySet/DecryptIfKeySet
// now require a passphrase whenever they are invoked on sensitive material, and
// the server refuses to start if any source='database' rows already exist without
// a configured passphrase.
var ErrEncryptionKeyRequired = errors.New("crypto: CERTCTL_CONFIG_ENCRYPTION_KEY is required to encrypt or decrypt sensitive config")
// v2Magic / v3Magic are the first byte of every v2/v3-format ciphertext blob.
// Magic bytes distinguish each version from v1 legacy blobs (no magic byte,
// fixed package-level salt) and from each other (different PBKDF2 work
// factors).
//
// The choice of 0x02 / 0x03 is deliberate: v1 blobs begin with a random
// 12-byte AES-GCM nonce. A v1 nonce can coincidentally start with 0x02 or
// 0x03 with probability 1/256 each, which makes a pure magic-byte dispatch
// ambiguous. [DecryptIfKeySet] resolves the ambiguity by falling back
// through the version chain on AEAD verification failure
// (v3 → v2 → v1).
const (
v2Magic byte = 0x02
v3Magic byte = 0x03
)
// v2SaltSize / v3SaltSize is the length in bytes of the per-ciphertext salt
// embedded in v2/v3 blobs. 16 bytes (128 bits) matches the lower bound
// recommended in NIST SP 800-132 §5.1 for PBKDF2 salts and is sufficient
// given the one-shot-per-row nature of the derivation. The two versions use
// the same salt size — only the iteration count changes.
const (
v2SaltSize = 16
v3SaltSize = 16
)
// pbkdf2IterationsV1V2 is the PBKDF2-SHA256 work factor for v1 and v2 blobs
// (100,000 rounds, the 2018-era OWASP recommendation). Preserved byte-for-byte
// so legacy fallback reads stay deterministic.
//
// pbkdf2IterationsV3 is the work factor for newly-written v3 blobs (600,000
// rounds, the OWASP 2024 recommendation per the Password Storage Cheat Sheet).
// Bundle B / Audit M-001 / CWE-916.
const (
pbkdf2IterationsV1V2 = 100000
pbkdf2IterationsV3 = 600000
)
// pbkdf2Iterations is preserved as an alias for v1V2 so existing internal
// references and downstream tests that compute v1 bytes manually keep working.
// New code should reference pbkdf2IterationsV3 explicitly.
const pbkdf2Iterations = pbkdf2IterationsV1V2
// aes256KeySize is the output length in bytes of both [DeriveKey] and
// [deriveKeyWithSalt]. It is also the only AES key length accepted by [Encrypt]
// and [Decrypt].
const aes256KeySize = 32
// legacyV1Salt is the fixed salt used by pre-M-8 config encryption. It is
// retained exclusively to preserve the v1 read path — any v1 blob that pre-dates
// M-8 remediation must be decryptable with a key derived from (passphrase,
// legacyV1Salt). The write path never uses this salt.
//
// Exposed as a package-level var rather than a local so that tests can reason
// about v1 fixture bytes symbolically.
var legacyV1Salt = []byte("certctl-config-encryption-v1")
// Encrypt encrypts plaintext using AES-256-GCM with a random 12-byte nonce prepended to the output.
// The key must be exactly 32 bytes (AES-256). Returns [12-byte nonce][ciphertext+tag].
//
// Encrypt is a low-level primitive. It is intentionally kept byte-identical to
// the pre-M-8 implementation so that existing v1 blobs on disk remain
// decryptable via [Decrypt] when paired with a [DeriveKey]-derived key. New
// callers should prefer [EncryptIfKeySet], which handles key derivation and
// emits the v2 wire format.
func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
if len(key) != aes256KeySize {
return nil, fmt.Errorf("encryption key must be exactly %d bytes, got %d", aes256KeySize, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
// Decrypt decrypts ciphertext that was encrypted with Encrypt.
// Expects format: [12-byte nonce][ciphertext+tag]. Key must be exactly 32 bytes.
//
// Decrypt is a low-level primitive. It is intentionally kept byte-identical to
// the pre-M-8 implementation so that [DecryptIfKeySet] can delegate to it for
// both the v2 inner blob (after stripping the magic byte + embedded salt) and
// the v1 legacy blob (unmodified).
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
if len(key) != aes256KeySize {
return nil, fmt.Errorf("encryption key must be exactly %d bytes, got %d", aes256KeySize, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short: %d bytes", len(ciphertext))
}
nonce, ciphertextBody := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBody, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt: %w", err)
}
return plaintext, nil
}
// DeriveKey derives a 32-byte AES-256 key from a passphrase using PBKDF2-SHA256
// with the legacy v1 fixed salt.
//
// This helper is preserved byte-identical to the pre-M-8 implementation so that
// v1 ciphertexts persisted before the M-8 remediation remain decryptable
// unchanged. New code paths should prefer [EncryptIfKeySet] and
// [DecryptIfKeySet], which use a per-ciphertext random salt.
func DeriveKey(passphrase string) []byte {
return deriveKeyWithSalt(passphrase, legacyV1Salt)
}
// deriveKeyWithSalt derives a 32-byte AES-256 key from a passphrase and an
// explicit salt using PBKDF2-SHA256 with [pbkdf2Iterations] rounds (= the
// v1/v2 work factor). v3 blobs use [deriveKeyWithSaltV3] instead.
//
// The per-ciphertext random salt path (v2) calls this directly with a fresh
// 16-byte random salt embedded in the ciphertext blob. The legacy path
// ([DeriveKey]) calls it with the package-level fixed salt [legacyV1Salt].
func deriveKeyWithSalt(passphrase string, salt []byte) []byte {
return pbkdf2.Key([]byte(passphrase), salt, pbkdf2Iterations, aes256KeySize, sha256.New)
}
// deriveKeyWithSaltV3 derives a 32-byte AES-256 key from a passphrase and
// an explicit salt using PBKDF2-SHA256 with [pbkdf2IterationsV3] rounds
// (the OWASP 2024 floor of 600,000). Bundle B / Audit M-001 / CWE-916.
func deriveKeyWithSaltV3(passphrase string, salt []byte) []byte {
return pbkdf2.Key([]byte(passphrase), salt, pbkdf2IterationsV3, aes256KeySize, sha256.New)
}
// IsLegacyFormat reports whether blob is in the v1 legacy wire format (no
// magic byte, fixed-salt derivation) as opposed to a v2 or v3 wire format
// (magic byte || salt(16) || nonce(12) || ciphertext+tag).
//
// A return value of false is a necessary but not sufficient condition for
// a blob to be a valid v2/v3 ciphertext: the shortest possible v2/v3 blob
// is 1 + saltSize + 12 = 29 bytes, and even a 29+ byte blob that starts
// with 0x02/0x03 may turn out to be a v1 ciphertext whose random nonce
// happens to begin with that byte (probability 1/256 each).
// [DecryptIfKeySet] resolves this ambiguity at decrypt time by falling
// back through the version chain when AEAD verification fails; callers of
// IsLegacyFormat should use it only as a heuristic (e.g. migration
// tooling, log annotation).
func IsLegacyFormat(blob []byte) bool {
if len(blob) == 0 {
return false
}
first := blob[0]
return first != v2Magic && first != v3Magic
}
// EncryptIfKeySet encrypts plaintext with the supplied passphrase and emits
// a v3 wire-format blob: magic(0x03) || salt(16) || nonce(12) || ciphertext+tag.
//
// Key derivation is performed internally per invocation with a fresh 16-byte
// random salt, producing a distinct AES-256 key for every ciphertext. The
// operator-supplied passphrase is the only cross-ciphertext shared secret.
// The work factor is [pbkdf2IterationsV3] (600,000) — Bundle B / Audit M-001
// / CWE-916 / OWASP 2024.
//
// The second return value is always true when err == nil — the "wasEncrypted"
// flag is retained for source-compatibility with callers that previously
// used it to log provenance. Callers MUST handle err: passing an empty
// passphrase returns [ErrEncryptionKeyRequired] rather than silently
// emitting plaintext. See the package-level [ErrEncryptionKeyRequired]
// documentation for the history behind this behavior change (C-2).
//
// The write path never produces v1 or v2 blobs. They are read-only legacy
// state — see [DecryptIfKeySet] for the compatibility fallback.
func EncryptIfKeySet(plaintext []byte, passphrase string) ([]byte, bool, error) {
if passphrase == "" {
return nil, false, ErrEncryptionKeyRequired
}
salt := make([]byte, v3SaltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, false, fmt.Errorf("failed to generate v3 salt: %w", err)
}
key := deriveKeyWithSaltV3(passphrase, salt)
inner, err := Encrypt(plaintext, key)
if err != nil {
return nil, false, err
}
// v3 blob layout: magic(1) || salt(v3SaltSize) || inner
blob := make([]byte, 0, 1+v3SaltSize+len(inner))
blob = append(blob, v3Magic)
blob = append(blob, salt...)
blob = append(blob, inner...)
return blob, true, nil
}
// DecryptIfKeySet decrypts blob with the supplied passphrase, supporting v3
// (Bundle B and later), v2 (M-8 era), and v1 (pre-M-8 legacy) on-disk
// formats.
//
// Dispatch is first-byte magic + AEAD fallback. If blob starts with
// [v3Magic] / [v2Magic] and is long enough to contain a header plus an
// AEAD-authenticated inner ciphertext, the matching version is attempted
// using a key derived from the embedded salt at the version's PBKDF2 work
// factor. If AEAD verification fails — which covers both the "wrong
// passphrase" case and the 1/256 case where a different-version blob
// happens to start with that magic byte — the function falls through to
// the next version. The order is v3 → v2 → v1.
//
// A v1 blob that is successfully decrypted is returned as plaintext;
// re-sealing as v3 happens naturally on the next UPDATE via
// [EncryptIfKeySet]. The function never re-encrypts in place.
//
// Passing an empty passphrase returns [ErrEncryptionKeyRequired]. Callers
// that legitimately store plaintext (e.g. env-seeded source='env' rows
// that keep the raw JSON in the unencrypted `config` column) must branch
// on the presence of the ciphertext themselves rather than relying on
// this helper to silently pass bytes through. See the package-level
// [ErrEncryptionKeyRequired] documentation for the history behind this
// behavior change (C-2).
func DecryptIfKeySet(blob []byte, passphrase string) ([]byte, error) {
if passphrase == "" {
return nil, ErrEncryptionKeyRequired
}
if len(blob) == 0 {
return nil, fmt.Errorf("ciphertext is empty")
}
// v3 path: Bundle B / M-001 — magic(0x03) || salt(16) || nonce(12) || ct+tag.
// 600,000 PBKDF2 rounds.
if blob[0] == v3Magic && len(blob) >= 1+v3SaltSize+12 {
salt := blob[1 : 1+v3SaltSize]
sealed := blob[1+v3SaltSize:]
key := deriveKeyWithSaltV3(passphrase, salt)
if plaintext, err := Decrypt(sealed, key); err == nil {
return plaintext, nil
}
// v3 AEAD failed. Fall through — could be a v2 blob whose first
// byte happens to be 0x03 (1/256), or a v1 nonce-prefix collision,
// or a wrong-passphrase v3.
}
// v2 path: M-8 — magic(0x02) || salt(16) || nonce(12) || ct+tag.
// 100,000 PBKDF2 rounds.
if blob[0] == v2Magic && len(blob) >= 1+v2SaltSize+12 {
salt := blob[1 : 1+v2SaltSize]
sealed := blob[1+v2SaltSize:]
key := deriveKeyWithSalt(passphrase, salt)
if plaintext, err := Decrypt(sealed, key); err == nil {
return plaintext, nil
}
// v2 AEAD failed. Fall through to v1.
}
// v1 legacy path: blob is the full ciphertext with no header and was
// sealed with a key derived from (passphrase, legacyV1Salt) at 100k
// rounds. If both v2/v3 attempts above failed and this also fails, the
// returned error is the v1 attempt's error — which is the most likely
// "wrong passphrase" surface for an operator on a recent install (no
// pre-M-8 v1 rows, so the first two paths are the actual write format
// and only v1 has a chance to surface a meaningful error).
key := DeriveKey(passphrase)
return Decrypt(blob, key)
}