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
This commit is contained in:
shankar0123
2026-04-26 23:09:10 +00:00
parent f609270cea
commit 30f9f1e712
17 changed files with 1287 additions and 114 deletions
+70
View File
@@ -6,6 +6,76 @@ import (
"testing"
)
// Bundle B / Audit M-013 (CWE-942) regression pins.
//
// The audit-finding text reads: "CORS configuration default allows all
// origins if env-var unset". Phase 0 recon proves that claim is WRONG —
// internal/api/middleware/middleware.go::NewCORS already denies when
// len(cfg.AllowedOrigins) == 0 (no Access-Control-Allow-Origin header is
// emitted, so same-origin policy applies). Bundle B's M-013 closure is
// "verified-already-clean": these tests pin the deny-by-default contract
// in BOTH shapes (nil slice and empty slice) so a future refactor that
// inverts the default fails CI.
// TestNewCORS_NilOriginsDeniesAll pins the deny-by-default contract for
// the nil-slice shape (which is what propagates from a missing
// CERTCTL_CORS_ORIGINS env var via internal/config/config.go::getEnvList).
func TestNewCORS_NilOriginsDeniesAll(t *testing.T) {
mw := NewCORS(CORSConfig{AllowedOrigins: nil})
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Origin", "https://attacker.example.com")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("nil AllowedOrigins must NOT emit Access-Control-Allow-Origin, got %q", got)
}
if got := rr.Header().Get("Vary"); got != "" {
t.Errorf("nil AllowedOrigins must NOT emit Vary, got %q", got)
}
}
// TestNewCORS_M013_ContractDocumentedInOrder pins the documented dispatch
// order so a refactor cannot silently invert the cases:
//
// 1. len(AllowedOrigins) == 0 → deny (no CORS headers)
// 2. AllowedOrigins == ["*"] → allow all (Access-Control-Allow-Origin: *)
// 3. else → exact-match allowlist with Vary: Origin
//
// If a refactor accidentally falls through to the allow-all branch when
// AllowedOrigins is empty, this test fails on case 1.
func TestNewCORS_M013_ContractDocumentedInOrder(t *testing.T) {
cases := []struct {
name string
origins []string
incomingOrigin string
wantHeader string // "" means no header expected
}{
{"deny_empty_slice", []string{}, "https://app.example.com", ""},
{"deny_nil", nil, "https://app.example.com", ""},
{"allow_all_with_star", []string{"*"}, "https://app.example.com", "*"},
{"exact_allow_match", []string{"https://app.example.com"}, "https://app.example.com", "https://app.example.com"},
{"exact_deny_mismatch", []string{"https://app.example.com"}, "https://attacker.example.com", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mw := NewCORS(CORSConfig{AllowedOrigins: tc.origins})
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", tc.incomingOrigin)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != tc.wantHeader {
t.Errorf("got Access-Control-Allow-Origin=%q, want %q (incoming origin=%q)", got, tc.wantHeader, tc.incomingOrigin)
}
})
}
}
// TestNewCORS_EmptyOriginList denies CORS by default (secure default).
func TestNewCORS_EmptyOriginList(t *testing.T) {
mw := NewCORS(CORSConfig{AllowedOrigins: []string{}})
+117 -10
View File
@@ -240,24 +240,67 @@ func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
}
// RateLimitConfig holds configuration for the rate limiter.
//
// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1) extends this with per-user
// and per-IP keying. The historic RPS / BurstSize fields are preserved for
// source compatibility — they now describe the per-key budget rather than
// the global budget. PerUserRPS / PerUserBurstSize, when non-zero, override
// RPS / BurstSize for authenticated callers; the IP-keyed fallback
// continues to use RPS / BurstSize so unauthenticated callers don't get
// a more generous bucket than authenticated ones by default.
type RateLimitConfig struct {
RPS float64 // Requests per second
BurstSize int // Maximum burst size
RPS float64 // Tokens per second per key (default applies to IP-keyed buckets)
BurstSize int // Max tokens per key (default applies to IP-keyed buckets)
// PerUserRPS overrides RPS for authenticated callers (keyed by UserKey
// in context). Zero means "use RPS as the authenticated budget too".
PerUserRPS float64
// PerUserBurstSize overrides BurstSize for authenticated callers.
// Zero means "use BurstSize".
PerUserBurstSize int
}
// NewRateLimiter creates a token bucket rate limiting middleware.
// Uses a simple token bucket: tokens refill at RPS rate, burst allows short spikes.
// NewRateLimiter creates a per-key token bucket rate limiting middleware.
//
// Bundle B / Audit M-025: pre-bundle this returned a single global bucket
// shared across every request, so a single noisy caller could exhaust the
// budget for everyone else (effectively a self-DoS). Post-bundle each
// authenticated user and each unauthenticated IP gets its own bucket. Keys
// are computed per request:
//
// - Authenticated: "user:" + middleware.GetUser(ctx)
// - Unauthenticated: "ip:" + r.RemoteAddr's host portion
//
// The bucket map is sync.RWMutex-guarded; create-on-demand for new keys.
// There is no eviction — for a long-running server with millions of unique
// IPs this can leak memory. A future enhancement is per-key TTL via a
// lazy sweeper. For now the leak is bounded by realistic operator IP
// fan-out and is acceptable per OWASP ASVS L2 (the threat model is abuse
// by a known set of clients, not infinite-cardinality scanners).
func NewRateLimiter(cfg RateLimitConfig) func(http.Handler) http.Handler {
limiter := &tokenBucket{
rate: cfg.RPS,
burstSize: float64(cfg.BurstSize),
tokens: float64(cfg.BurstSize),
lastRefill: time.Now(),
// Default per-user budgets to the IP-keyed budget when not overridden.
perUserRPS := cfg.PerUserRPS
if perUserRPS == 0 {
perUserRPS = cfg.RPS
}
perUserBurst := float64(cfg.PerUserBurstSize)
if perUserBurst == 0 {
perUserBurst = float64(cfg.BurstSize)
}
limiter := &keyedRateLimiter{
ipRate: cfg.RPS,
ipBurst: float64(cfg.BurstSize),
userRate: perUserRPS,
userBurst: perUserBurst,
buckets: make(map[string]*tokenBucket),
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.allow() {
key, isUser := rateLimitKey(r)
if !limiter.allow(key, isUser) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Retry-After", "1")
http.Error(w, `{"error":"Rate limit exceeded"}`, http.StatusTooManyRequests)
@@ -268,6 +311,70 @@ func NewRateLimiter(cfg RateLimitConfig) func(http.Handler) http.Handler {
}
}
// rateLimitKey computes the per-request bucket key. Authenticated callers
// get a "user:<name>" key derived from the UserKey context value populated
// by NewAuthWithNamedKeys; everyone else falls back to "ip:<host>" parsed
// from r.RemoteAddr (X-Forwarded-For is intentionally NOT consulted here
// — operators behind a trusted proxy must configure that proxy to set
// RemoteAddr correctly, or the rate limiter would be trivially bypassable
// by spoofing the header).
//
// Returns (key, isAuthenticated). Empty UserKey strings are treated as
// unauthenticated so a misconfigured auth middleware doesn't grant the
// same bucket to every anonymous request.
func rateLimitKey(r *http.Request) (string, bool) {
if user := GetUser(r.Context()); user != "" {
return "user:" + user, true
}
host := r.RemoteAddr
if idx := strings.LastIndex(host, ":"); idx >= 0 {
host = host[:idx]
}
if host == "" {
host = "unknown"
}
return "ip:" + host, false
}
// keyedRateLimiter holds a token bucket per (user-or-ip) key with separate
// rate / burst defaults for the user-keyed and ip-keyed dimensions.
type keyedRateLimiter struct {
mu sync.RWMutex
buckets map[string]*tokenBucket
ipRate float64
ipBurst float64
userRate float64
userBurst float64
}
func (k *keyedRateLimiter) allow(key string, isUser bool) bool {
// Fast path: bucket already exists.
k.mu.RLock()
tb, ok := k.buckets[key]
k.mu.RUnlock()
if !ok {
// Slow path: create-on-demand under write lock with double-check.
k.mu.Lock()
tb, ok = k.buckets[key]
if !ok {
rate, burst := k.ipRate, k.ipBurst
if isUser {
rate, burst = k.userRate, k.userBurst
}
tb = &tokenBucket{
rate: rate,
burstSize: burst,
tokens: burst,
lastRefill: time.Now(),
}
k.buckets[key] = tb
}
k.mu.Unlock()
}
return tb.allow()
}
// tokenBucket implements a simple thread-safe token bucket rate limiter.
// This avoids importing golang.org/x/time/rate to keep dependencies minimal.
type tokenBucket struct {
@@ -0,0 +1,188 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1): per-key rate-limiter
// regression suite. Pre-bundle the limiter was global — a single noisy
// caller could exhaust everyone's budget. Post-bundle each authenticated
// user and each distinct IP gets an independent token bucket.
func newKeyedTestHandler(t *testing.T, cfg RateLimitConfig) http.Handler {
t.Helper()
return NewRateLimiter(cfg)(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
}
// TestRateLimiter_M025_TwoIPsHaveIndependentBuckets ensures one IP
// exhausting its bucket does not affect another IP.
func TestRateLimiter_M025_TwoIPsHaveIndependentBuckets(t *testing.T) {
h := newKeyedTestHandler(t, RateLimitConfig{RPS: 0.0001, BurstSize: 1})
// IP A burns its single token.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("IP A first request should pass; got %d", rr.Code)
}
// IP A's second request must 429.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Errorf("IP A second request should 429; got %d", rr.Code)
}
// IP B's first request must still pass — independent bucket.
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.RemoteAddr = "10.0.0.2:54321"
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("IP B first request must pass (independent bucket); got %d", rr2.Code)
}
}
// TestRateLimiter_M025_SameUserDifferentIPsShareBucket pins the keying
// rule that authenticated callers are bucketed by user identity, not by
// IP — so a user rotating between devices still shares one budget.
func TestRateLimiter_M025_SameUserDifferentIPsShareBucket(t *testing.T) {
h := newKeyedTestHandler(t, RateLimitConfig{RPS: 0.0001, BurstSize: 1})
mkReq := func(remote string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remote
ctx := context.WithValue(req.Context(), UserKey{}, "alice")
return req.WithContext(ctx)
}
// Alice from IP X exhausts her bucket.
rr := httptest.NewRecorder()
h.ServeHTTP(rr, mkReq("10.0.0.1:54321"))
if rr.Code != http.StatusOK {
t.Fatalf("alice first request should pass; got %d", rr.Code)
}
// Alice from IP Y must 429 — same user-scoped bucket.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, mkReq("10.0.0.2:54321"))
if rr.Code != http.StatusTooManyRequests {
t.Errorf("alice second request from different IP should still 429; got %d", rr.Code)
}
}
// TestRateLimiter_M025_TwoUsersHaveIndependentBuckets pins the keying rule
// that two authenticated users share neither buckets nor side effects.
func TestRateLimiter_M025_TwoUsersHaveIndependentBuckets(t *testing.T) {
h := newKeyedTestHandler(t, RateLimitConfig{RPS: 0.0001, BurstSize: 1})
mkReq := func(user string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
ctx := context.WithValue(req.Context(), UserKey{}, user)
return req.WithContext(ctx)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, mkReq("alice"))
if rr.Code != http.StatusOK {
t.Fatalf("alice first request should pass; got %d", rr.Code)
}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, mkReq("alice"))
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("alice second request should 429; got %d", rr.Code)
}
// Bob shares the same RemoteAddr but his bucket is independent.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, mkReq("bob"))
if rr.Code != http.StatusOK {
t.Errorf("bob's first request must pass despite alice exhausting hers; got %d", rr.Code)
}
}
// TestRateLimiter_M025_PerUserBudgetOverride exercises the optional
// PerUserRPS / PerUserBurstSize knobs. Authenticated callers get the
// generous budget; unauthenticated callers stay on the strict default.
func TestRateLimiter_M025_PerUserBudgetOverride(t *testing.T) {
cfg := RateLimitConfig{
RPS: 0.0001,
BurstSize: 1, // strict for unauthenticated
PerUserRPS: 0.0001,
PerUserBurstSize: 5, // generous for authenticated
}
h := newKeyedTestHandler(t, cfg)
// IP-keyed: 1 token, second request 429.
ipReq := func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.99:54321"
return req
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, ipReq())
if rr.Code != http.StatusOK {
t.Fatalf("ip request 1 should pass; got %d", rr.Code)
}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, ipReq())
if rr.Code != http.StatusTooManyRequests {
t.Errorf("ip request 2 should 429; got %d", rr.Code)
}
// User-keyed: 5 tokens, sixth request 429.
userReq := func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.42:54321"
ctx := context.WithValue(req.Context(), UserKey{}, "carol")
return req.WithContext(ctx)
}
for i := 1; i <= 5; i++ {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, userReq())
if rr.Code != http.StatusOK {
t.Errorf("user request %d should pass; got %d", i, rr.Code)
}
}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, userReq())
if rr.Code != http.StatusTooManyRequests {
t.Errorf("user request 6 should 429 (over PerUserBurstSize); got %d", rr.Code)
}
}
// TestRateLimiter_M025_EmptyUserKeyTreatedAsAnonymous ensures a
// misconfigured auth middleware that puts an empty string under UserKey
// does NOT collapse every anonymous request onto a single bucket.
func TestRateLimiter_M025_EmptyUserKeyTreatedAsAnonymous(t *testing.T) {
h := newKeyedTestHandler(t, RateLimitConfig{RPS: 0.0001, BurstSize: 1})
mkReq := func(remote string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remote
ctx := context.WithValue(req.Context(), UserKey{}, "")
return req.WithContext(ctx)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, mkReq("10.0.1.1:54321"))
if rr.Code != http.StatusOK {
t.Fatalf("first anonymous request should pass; got %d", rr.Code)
}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, mkReq("10.0.1.2:54321"))
if rr.Code != http.StatusOK {
t.Errorf("second anonymous request from different IP should still pass (independent IP buckets); got %d", rr.Code)
}
}
+182
View File
@@ -0,0 +1,182 @@
package router
import (
"go/ast"
"go/parser"
"go/token"
"os"
"sort"
"strings"
"testing"
)
// osReadFile is a thin wrapper that the test functions use; aliased so the
// file's helper section reads cleanly without importing "os" repeatedly in
// the body.
var osReadFile = os.ReadFile
// Bundle B / Audit M-002 (CWE-862 Authorization Bypass).
//
// The certctl router has TWO layers where a route can be made auth-exempt:
//
// 1. internal/api/router/router.go::RegisterHandlers calls r.mux.Handle
// directly (instead of r.Register), bypassing the router-level
// middleware.Chain wrap. The 4 routes that do this today are pinned
// in AuthExemptRouterRoutes.
//
// 2. cmd/server/main.go::buildFinalHandler dispatches by URL prefix,
// routing some prefixes through the noAuthHandler chain. Those are
// pinned in AuthExemptDispatchPrefixes.
//
// This file pins layer 1: it parses router.go's AST, finds every
// r.mux.Handle string-literal arg, and asserts that set equals
// AuthExemptRouterRoutes exactly. Adding a new mux.Handle without
// updating the allowlist constant fails CI; updating the constant
// requires a code reviewer to read the new entry's justification
// comment. Layer 2's pin lives in cmd/server/main_test.go for symmetry
// with the dispatch logic itself.
func TestRouter_AuthExemptAllowlist_PinsActualRegistrations(t *testing.T) {
actual, err := extractRouterDirectMuxHandles("router.go")
if err != nil {
t.Fatalf("scan router.go: %v", err)
}
expected := append([]string(nil), AuthExemptRouterRoutes...)
sort.Strings(actual)
sort.Strings(expected)
if !slicesEqual(actual, expected) {
t.Errorf("AuthExemptRouterRoutes drift detected.\n"+
" Direct r.mux.Handle calls in router.go: %v\n"+
" AuthExemptRouterRoutes constant: %v\n"+
"\n"+
"If you added a new mux.Handle, you MUST also add the route to\n"+
"AuthExemptRouterRoutes WITH a justification comment explaining\n"+
"why it is safe-without-auth. Adding a new auth-bypass without\n"+
"updating the allowlist is the M-002 regression this test guards.\n",
actual, expected)
}
}
func TestRouter_AllRegisterCallsGoThroughMiddlewareChain(t *testing.T) {
// Every r.Register / r.RegisterFunc call in router.go pipes through
// middleware.Chain(handler, r.middleware...). Any future change to
// the Register / RegisterFunc body that drops the middleware wrap
// silently exempts every "authenticated" route from auth — fail fast.
//
// We read router.go as raw bytes and check for the load-bearing
// strings inside each function body. AST stringification is overkill
// for a substring check.
raw, err := readFileBytes("router.go")
if err != nil {
t.Fatalf("read router.go: %v", err)
}
registerBody := extractFuncSourceByName(raw, "Register")
registerFuncBody := extractFuncSourceByName(raw, "RegisterFunc")
if !strings.Contains(registerBody, "middleware.Chain") {
t.Errorf("Router.Register no longer pipes through middleware.Chain — auth bypass risk. Body:\n%s", registerBody)
}
// RegisterFunc is allowed to either chain directly or delegate to Register.
if !strings.Contains(registerFuncBody, "r.Register") && !strings.Contains(registerFuncBody, "middleware.Chain") {
t.Errorf("Router.RegisterFunc no longer delegates to Register / middleware.Chain — auth bypass risk. Body:\n%s", registerFuncBody)
}
}
// --- helpers --------------------------------------------------------------
func parseRouterFile(name string) (*ast.File, error) {
fset := token.NewFileSet()
return parser.ParseFile(fset, name, nil, parser.ParseComments)
}
// extractRouterDirectMuxHandles returns every "<METHOD> <PATH>" string
// literal passed as the first argument to r.mux.Handle in the file.
func extractRouterDirectMuxHandles(name string) ([]string, error) {
src, err := parseRouterFile(name)
if err != nil {
return nil, err
}
var out []string
ast.Inspect(src, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Looking for r.mux.Handle(...) — selector chain Sel="Handle",
// X is itself a SelectorExpr Sel="mux".
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Handle" {
return true
}
inner, ok := sel.X.(*ast.SelectorExpr)
if !ok || inner.Sel.Name != "mux" {
return true
}
if len(call.Args) == 0 {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
// Skip the generic Register helper itself (line 38: r.mux.Handle(pattern, ...))
// — pattern there is a func parameter, not a string literal.
// Trim quotes on the literal value.
v := strings.Trim(lit.Value, "\"`")
if v == "" {
return true
}
out = append(out, v)
return true
})
return out, nil
}
func readFileBytes(name string) ([]byte, error) {
return osReadFile(name)
}
// extractFuncSourceByName returns the raw source body (between the opening
// and matching closing brace) of the named func defined in src.
func extractFuncSourceByName(src []byte, name string) string {
needle := []byte("func (r *Router) " + name + "(")
idx := indexOfBytes(src, needle)
if idx < 0 {
return ""
}
// Find first '{' after the signature, then walk to the matching '}'.
openIdx := idx + indexOfBytes(src[idx:], []byte("{"))
if openIdx < 0 {
return ""
}
depth := 0
for i := openIdx; i < len(src); i++ {
switch src[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return string(src[openIdx : i+1])
}
}
}
return ""
}
func indexOfBytes(haystack, needle []byte) int {
return strings.Index(string(haystack), string(needle))
}
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+43
View File
@@ -43,6 +43,49 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter,
r.Register(pattern, http.HandlerFunc(handler))
}
// AuthExemptRouterRoutes is the documented allowlist of routes that the
// router itself registers via direct r.mux.Handle calls (NOT via r.Register),
// thereby bypassing the router-level middleware chain — including auth.
//
// Bundle B / Audit M-002 (CWE-862 Authorization Bypass): this is one of the
// two layers where auth-exempt status is decided. The complete picture:
//
// 1. Router layer (this constant) — direct mux.Handle registrations in
// RegisterHandlers below. Used for endpoints that must never carry a
// Bearer token (health probes, auth-info before login, version probe).
//
// 2. Dispatch layer (cmd/server/main.go::buildFinalHandler) — URL-prefix
// dispatch that routes /.well-known/pki/*, /.well-known/est/*, and
// /scep[/...]* through the no-auth handler chain. Those protocols
// authenticate via CSR-embedded credentials (EST/SCEP challenge
// password) or are inherently unauthenticated by RFC (CRL/OCSP relying
// parties).
//
// Every entry in this slice has a justification. Adding a new entry MUST
// include a code comment explaining why the route is safe-without-auth.
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
var AuthExemptRouterRoutes = []string{
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
}
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
// that cmd/server/main.go::buildFinalHandler routes through the no-auth
// handler chain. These are RFC-mandated unauthenticated surfaces (CRL/OCSP)
// or protocols that authenticate via embedded credentials (EST/SCEP).
//
// Bundle B / Audit M-002: complement to AuthExemptRouterRoutes. The
// TestDispatch_AuthExemptPrefixes regression test in cmd/server/main_test.go
// pins this slice to buildFinalHandler's actual dispatch logic.
var AuthExemptDispatchPrefixes = []string{
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
}
// HandlerRegistry groups all API handler dependencies for router registration.
type HandlerRegistry struct {
Certificates handler.CertificateHandler
+26 -4
View File
@@ -924,13 +924,21 @@ type AuthConfig struct {
}
// RateLimitConfig contains rate limiting configuration.
//
// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1): pre-bundle the rate
// limiter was global (a single token bucket shared across every request);
// post-bundle it is per-key with separate budgets for IP-keyed and
// user-keyed buckets. RPS / BurstSize are PER-KEY budgets.
type RateLimitConfig struct {
// Enabled controls whether rate limiting is enforced on API endpoints.
// Default: true. Set to false to disable rate limits (not recommended for production).
// Setting: CERTCTL_RATE_LIMIT_ENABLED environment variable.
Enabled bool
// RPS is the target requests per second allowed per client (token bucket rate).
// RPS is the target requests per second allowed PER KEY (token bucket
// rate). For unauthenticated callers the key is the source IP; for
// authenticated callers the key is the API-key name (UserKey context
// value populated by NewAuthWithNamedKeys).
// Default: 50. Higher values allow burst throughput; lower values restrict load.
// Setting: CERTCTL_RATE_LIMIT_RPS environment variable.
RPS float64
@@ -940,6 +948,18 @@ type RateLimitConfig struct {
// Must be at least as large as RPS. Higher = more lenient burst handling.
// Setting: CERTCTL_RATE_LIMIT_BURST environment variable.
BurstSize int
// PerUserRPS overrides RPS for authenticated callers. When zero, RPS is
// used for both keying dimensions. Set this higher than RPS to grant
// authenticated clients a more generous budget than anonymous probes.
// Default: 0 (use RPS).
// Setting: CERTCTL_RATE_LIMIT_PER_USER_RPS environment variable.
PerUserRPS float64
// PerUserBurstSize overrides BurstSize for authenticated callers. When
// zero, BurstSize is used. Default: 0 (use BurstSize).
// Setting: CERTCTL_RATE_LIMIT_PER_USER_BURST environment variable.
PerUserBurstSize int
}
// CORSConfig contains CORS configuration.
@@ -1011,9 +1031,11 @@ func Load() (*Config, error) {
AgentBootstrapToken: getEnv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", ""),
},
RateLimit: RateLimitConfig{
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
RPS: getEnvFloat("CERTCTL_RATE_LIMIT_RPS", 50),
BurstSize: getEnvInt("CERTCTL_RATE_LIMIT_BURST", 100),
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
RPS: getEnvFloat("CERTCTL_RATE_LIMIT_RPS", 50),
BurstSize: getEnvInt("CERTCTL_RATE_LIMIT_BURST", 100),
PerUserRPS: getEnvFloat("CERTCTL_RATE_LIMIT_PER_USER_RPS", 0),
PerUserBurstSize: getEnvInt("CERTCTL_RATE_LIMIT_PER_USER_BURST", 0),
},
CORS: CORSConfig{
AllowedOrigins: getEnvList("CERTCTL_CORS_ORIGINS", nil),
+155 -86
View File
@@ -1,31 +1,48 @@
// Package crypto provides AES-256-GCM encryption for sensitive configuration data.
//
// The on-disk format for blobs produced by [EncryptIfKeySet] is versioned. Two
// versions coexist and both can be read by [DecryptIfKeySet]:
// 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:
//
// v2 (current, M-8)
// 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 from the operator
// passphrase and the per-ciphertext random salt.
// — 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 from the operator
// passphrase and the package-level fixed salt
// — 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 blobs are accepted by the read path for backward compatibility with rows
// persisted before the M-8 remediation. They are never produced by the write
// path. Any row that is updated after M-8 is re-sealed as v2 in-place via the
// normal UPDATE flow.
// 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 per-ciphertext salt (see 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 replaces the fixed salt with 16 fresh random bytes per write
// and stores the salt alongside the ciphertext. Derived keys now differ per
// row and per re-encryption.
// 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 (
@@ -58,26 +75,48 @@ import (
// a configured passphrase.
var ErrEncryptionKeyRequired = errors.New("crypto: CERTCTL_CONFIG_ENCRYPTION_KEY is required to encrypt or decrypt sensitive config")
// v2Magic is the first byte of every v2-format ciphertext blob. It distinguishes
// v2 blobs (per-ciphertext random salt, embedded in the blob) from v1 legacy
// blobs (no magic byte, fixed package-level salt).
// 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 is deliberate: v1 blobs begin with a random 12-byte AES-GCM
// nonce. A v1 nonce can coincidentally start with 0x02 with probability 1/256,
// which makes a pure magic-byte dispatch ambiguous. [DecryptIfKeySet] resolves
// the ambiguity by falling back to the v1 path when v2 AEAD verification fails.
const v2Magic byte = 0x02
// 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 is the length in bytes of the per-ciphertext salt embedded in a
// v2 blob. 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.
const v2SaltSize = 16
// 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
)
// pbkdf2Iterations is the PBKDF2-SHA256 work factor applied uniformly to both
// v1 and v2 key derivations. The value is preserved from the pre-M-8 design so
// that v1 fallback reads stay bit-identical.
const pbkdf2Iterations = 100000
// 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]
@@ -173,7 +212,8 @@ func DeriveKey(passphrase string) []byte {
}
// deriveKeyWithSalt derives a 32-byte AES-256 key from a passphrase and an
// explicit salt using PBKDF2-SHA256 with [pbkdf2Iterations] rounds.
// 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
@@ -182,87 +222,100 @@ func deriveKeyWithSalt(passphrase string, salt []byte) []byte {
return pbkdf2.Key([]byte(passphrase), salt, pbkdf2Iterations, aes256KeySize, sha256.New)
}
// IsLegacyFormat reports whether blob is in the v1 legacy wire format (no magic
// byte, fixed-salt derivation) as opposed to the v2 wire format
// (magic(0x02) || salt(16) || nonce(12) || ciphertext+tag).
// 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 ciphertext: the shortest possible v2 blob is
// 1 + v2SaltSize + 12 = 29 bytes, and even a 29+ byte blob that starts with
// 0x02 may turn out to be a v1 ciphertext whose random nonce happens to begin
// with 0x02 (probability 1/256). [DecryptIfKeySet] resolves this ambiguity at
// decrypt time by falling back to v1 when v2 AEAD verification fails; callers
// of IsLegacyFormat should use it only as a heuristic (e.g. migration
// 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
}
return blob[0] != v2Magic
first := blob[0]
return first != v2Magic && first != v3Magic
}
// EncryptIfKeySet encrypts plaintext with the supplied passphrase and emits a
// v2 wire-format blob: magic(0x02) || salt(16) || nonce(12) || ciphertext+tag.
// 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).
// 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 a v1 blob. v1 blobs are read-only legacy
// 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, v2SaltSize)
salt := make([]byte, v3SaltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, false, fmt.Errorf("failed to generate v2 salt: %w", err)
return nil, false, fmt.Errorf("failed to generate v3 salt: %w", err)
}
key := deriveKeyWithSalt(passphrase, salt)
key := deriveKeyWithSaltV3(passphrase, salt)
inner, err := Encrypt(plaintext, key)
if err != nil {
return nil, false, err
}
// v2 blob layout: magic(1) || salt(v2SaltSize) || inner
blob := make([]byte, 0, 1+v2SaltSize+len(inner))
blob = append(blob, v2Magic)
// 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 both
// v2 (M-8 and later) and v1 (legacy) on-disk formats.
// 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
// [v2Magic] and is long enough to contain a v2 header plus an AEAD-authenticated
// inner ciphertext, a v2 decrypt is attempted using a key derived from the
// embedded salt. If that succeeds, its plaintext is returned. If v2 AEAD
// verification fails — which covers both the "wrong passphrase" case and the
// 1/256 case where a v1 blob's first byte happens to be 0x02 — the function
// falls through to the v1 path and attempts decryption using a key derived
// from the package-level fixed salt [legacyV1Salt].
// [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.
//
// 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).
// 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.
//
// The function never re-encrypts in place. A v1 blob that is successfully
// decrypted is returned to the caller as plaintext; re-sealing as v2 happens
// naturally on the next UPDATE via [EncryptIfKeySet].
// 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
@@ -271,8 +324,22 @@ func DecryptIfKeySet(blob []byte, passphrase string) ([]byte, error) {
return nil, fmt.Errorf("ciphertext is empty")
}
// v2 path: magic || salt(16) || nonce(12) || ciphertext+tag (min 29 bytes
// ignoring the GCM tag; the AEAD verify inside Decrypt enforces the tag).
// 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:]
@@ -280,14 +347,16 @@ func DecryptIfKeySet(blob []byte, passphrase string) ([]byte, error) {
if plaintext, err := Decrypt(sealed, key); err == nil {
return plaintext, nil
}
// v2 AEAD verification failed. Fall through to v1 so that a v1 blob
// whose first byte happens to be 0x02 (1/256 probability) is still
// decryptable. If this is truly a v2 blob with the wrong passphrase,
// the v1 attempt below will also fail and the v1 error is returned.
// 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).
// 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)
}
+11 -9
View File
@@ -309,21 +309,23 @@ func TestDeriveKey_DifferentSaltsProduceDifferentKeys(t *testing.T) {
// TestEncryptIfKeySet_ProducesV2Format asserts the exact v2 wire-format bytes:
// magic(0x02) || salt(16) || nonce(12) || ciphertext+tag.
func TestEncryptIfKeySet_ProducesV2Format(t *testing.T) {
// 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 + v2SaltSize + 12 + 16 // magic + salt + nonce + GCM tag (16)
const minLen = 1 + v3SaltSize + 12 + 16 // magic + salt + nonce + GCM tag (16)
if len(blob) < minLen {
t.Fatalf("v2 blob too short: got %d, want >= %d", len(blob), minLen)
t.Fatalf("v3 blob too short: got %d, want >= %d", len(blob), minLen)
}
if blob[0] != v2Magic {
t.Fatalf("v2 blob must start with magic byte 0x%02x, got 0x%02x", v2Magic, blob[0])
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 v2 blob")
t.Fatal("IsLegacyFormat must return false for a freshly produced v3 blob")
}
}
@@ -342,13 +344,13 @@ func TestEncryptIfKeySet_SaltIsRandom(t *testing.T) {
t.Fatalf("EncryptIfKeySet #2 failed: %v", err)
}
salt1 := blob1[1 : 1+v2SaltSize]
salt2 := blob2[1 : 1+v2SaltSize]
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 v2 blobs with same (passphrase, plaintext) must differ end-to-end")
t.Fatal("two v3 blobs with same (passphrase, plaintext) must differ end-to-end")
}
}
+167
View File
@@ -0,0 +1,167 @@
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")
}
}