Skip to content

Commit 6046c0f

Browse files
authored
fix(config): replace TOKEN_SIGNING_SECRET entropy heuristic (#30)
* fix(config): replace TOKEN_SIGNING_SECRET entropy heuristic The previous distinct-byte check was structurally broken in two directions: - False positive: a real `openssl rand -hex 32` value is drawn from a 16-symbol alphabet; the expected number of distinct chars in 64 draws is ~15.75, so a genuinely random secret intermittently fell below the < 16 threshold and was rejected at startup under PROD_MODE=true. - False negative: literals like `0123456789abcdef0123456789abcdef` have exactly 16 distinct chars and passed the gate despite trivially repeating with period 16. A patterned secret with no actual entropy past the first 16 bytes would clear the check. Replaced with a three-part shape check on the raw secret bytes: 1. All-same byte (period 1) → "is N copies of a single byte". 2. Repeating period < len(b) → "repeats with period P". Catches both the period-16 false-pass above and shorter shapes like `abcabc…`. 3. Tiny alphabet (< 8 distinct values) → catches the `aaaa…b`-shaped near-miss and uneven-run-length shapes (`aaaaabbbbbcccccddddd…`) that defeat both the period and all-same checks. Threshold of 8 is the most defensive floor with zero practical false-positive rate on real random output: 32 hex chars over a 16-symbol alphabet expect ~14 distinct, base64 / raw expect more. The new check fires at the same two sites as the old one — the warn path during secret parsing, and the hard-fail path under PROD_MODE — so dev / single-replica deployments that intentionally use a weak secret keep working under PROD_MODE=false with a startup warning. Tests: - TestLoad_SecretWeaknessWarning_PeriodicRepeat is the headline regression for the famous false-pass: rejects `0123456789abcdef0123456789abcdef`. - TestLoad_SecretWeaknessWarning_TinyAlphabet is a 4-case boundary table: 2-distinct rejects, 7-distinct rejects (just below the floor), 8-distinct passes (at the floor, true 8-symbol non-periodic fixture), 15-distinct passes. - TestLoad_SecretWeaknessWarning_NonPeriodic covers the false-positive direction with frozen `openssl rand -hex 32`, base64-shaped, and alphanumeric-32 samples. - TestWeakSecretReason_FreshRandomNeverFlags is a 9000-sample experiment per CI run (1000 iterations × 3 encodings × 3 raw-byte lengths) demonstrating zero false-positives on fresh crypto/rand output across raw, hex, and base64 shapes at lengths 32 / 48 / 64. - TestLoad_ProdMode_BlocksWeakSecret is now table-driven (3 shapes × 2 modes = 6 sub-tests). - The setAllRequired test fixture's TOKEN_SIGNING_SECRET moved from the literal famous false-pass to a non-periodic real-random hex sample so every existing test runs against a secret the new gate accepts. - distinctByteCount removed entirely. Docs aligned: specs.md, README.md, and docs/configuration.md all describe the new semantics and point at manifests/scripts/generate-signing-secret.sh as the canonical generator path. * docs: name the third weak-secret class (tiny alphabet) in prose The validator rejects three weak-secret shapes — all-same byte, short repeating period, AND tiny alphabet (< 8 distinct values). The prose in README, docs/configuration.md, and specs.md described only the first two; the tiny-alphabet floor (added in this PR) was visible only via the inline Go code. A reader of the docs alone now sees the full contract and the rationale for each class. * docs(README): correct generate-signing-secret.sh output description The Demo stack section described the script as emitting "a 32-byte random hex string". The script actually emits a 64-character cryptographically-random base64 string (it pipes `openssl rand -base64 48` through tr-d and head -c 64). Same ballpark of bits of entropy, different encoding.
1 parent 9dc12d1 commit 6046c0f

5 files changed

Lines changed: 312 additions & 102 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ production posture.
133133
| **`OIDC_CLIENT_SECRET`** | IdP client secret |
134134
| **`PROXY_BASE_URL`** | Public URL of this proxy (audience-bound into every sealed token) |
135135
| **`UPSTREAM_MCP_URL`** | Upstream MCP URL with explicit path (`http://mcp:8000/mcp`); the path is the proxy's mount AND forwarded verbatim. Origin-only, fragment-bearing, or control-plane-colliding paths are rejected at startup |
136-
| **`TOKEN_SIGNING_SECRET`** | ≥ 32 bytes, AES-GCM key; byte-identical across replicas. Generate with `manifests/scripts/generate-signing-secret.sh`. Rotation procedure (with `TOKEN_SIGNING_SECRETS_PREVIOUS` for zero-downtime rollover) in [`docs/runbooks/key-rotation.md`](./docs/runbooks/key-rotation.md) |
136+
| **`TOKEN_SIGNING_SECRET`** | ≥ 32 bytes, AES-GCM key; byte-identical across replicas. **Generate with `manifests/scripts/generate-signing-secret.sh`** (64-char base64). The startup validator rejects three weak-secret shapes: all-same-byte, short-repeating-period, and tiny alphabet (< 8 distinct values). Under `PROD_MODE=true` weak secrets fail fast. Rotation procedure (with `TOKEN_SIGNING_SECRETS_PREVIOUS` for zero-downtime rollover) in [`docs/runbooks/key-rotation.md`](./docs/runbooks/key-rotation.md) |
137137

138138
Optional knobs (rate limits, replay store tuning, header trust,
139139
observability, dev/compat) are documented in
@@ -258,8 +258,9 @@ Compose with Keycloak (pre-seeded realm + admin user), Redis, a
258258
minimal MCP server, and the proxy itself wired end-to-end. The
259259
`manifests/k8s/` set is split between reference YAML templates and a
260260
production-oriented kustomize overlay at
261-
`manifests/overlays/production`. `scripts/generate-signing-secret.sh`
262-
emits a 32-byte random hex string suitable for `TOKEN_SIGNING_SECRET`.
261+
`manifests/overlays/production`. `manifests/scripts/generate-signing-secret.sh`
262+
emits a 64-character cryptographically-random base64 string suitable
263+
for `TOKEN_SIGNING_SECRET`.
263264

264265
---
265266

config/config.go

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ type Config struct {
181181
// env: UPSTREAM_AUTHORIZATION_HEADER. Treat as a secret in
182182
// deployment (mount from a Secret, not a ConfigMap).
183183
UpstreamAuthorization string
184-
// secretWeakWarning is non-empty when TOKEN_SIGNING_SECRET has low
185-
// byte-entropy (fewer than 16 distinct bytes). Exposed via
184+
// secretWeakWarning is non-empty when TOKEN_SIGNING_SECRET matches
185+
// an obvious-weakness pattern (all-same byte, or short repeating
186+
// period). Exposed via
186187
// SecretWeaknessWarning() so the caller can emit a structured log
187188
// event at startup without Load() taking a *zap.Logger dependency.
188189
secretWeakWarning string
@@ -248,20 +249,18 @@ func Load() (*Config, error) {
248249
return nil, fmt.Errorf("TOKEN_SIGNING_SECRET must be at least 32 bytes")
249250
default:
250251
c.TokenSigningSecret = []byte(secret)
251-
// L1: count distinct bytes in the secret. A 32-byte string with <16
252-
// unique byte values signals a human-picked or repeating pattern
253-
// (e.g. "aaaaaaaa..."): the AES-GCM key derived from SHA-256 is
254-
// still 256 bits wide, but the secret itself has far less effective
255-
// entropy than its length suggests. Warn here unconditionally; the
256-
// PROD_MODE violations block below promotes this to a hard error
257-
// when strict mode is on, so dev / single-replica deployments that
258-
// knowingly use a patterned secret keep working under
259-
// PROD_MODE=false.
260-
if distinct := distinctByteCount(c.TokenSigningSecret); distinct < 16 {
261-
c.secretWeakWarning = fmt.Sprintf(
262-
"TOKEN_SIGNING_SECRET has only %d distinct bytes (<16); effective entropy is much lower than its length suggests",
263-
distinct,
264-
)
252+
// Detect obvious-weakness patterns (all-same byte, or short
253+
// repeating period). The AES-GCM key derived via SHA-256 is
254+
// still 256 bits wide, but the secret itself has near-zero
255+
// effective entropy when it's `aaaa…` or `abcabc…`. Warn
256+
// unconditionally; the PROD_MODE violations block below
257+
// promotes it to a hard error when strict mode is on, so dev
258+
// / single-replica work that knowingly uses a patterned
259+
// secret keeps working under PROD_MODE=false. Use
260+
// `manifests/scripts/generate-signing-secret.sh` to produce
261+
// a known-good 64-char base64 value.
262+
if reason := weakSecretReason(c.TokenSigningSecret); reason != "" {
263+
c.secretWeakWarning = "TOKEN_SIGNING_SECRET " + reason
265264
}
266265
}
267266

@@ -530,19 +529,18 @@ func Load() (*Config, error) {
530529
if allowInsecureOIDCHTTP {
531530
violations = append(violations, "OIDC_ALLOW_INSECURE_HTTP=true (cleartext OIDC exposes the client secret)")
532531
}
533-
// Promote the L1 low-entropy warning to a hard fail under
534-
// PROD_MODE. A 32-byte secret with <16 distinct bytes signals
535-
// a human-typed or repeating pattern; the AES-GCM key derived
536-
// from SHA-256 stays 256 bits wide but the *secret* itself
537-
// has far less effective entropy than its length suggests.
538-
// Dev / single-replica work that intentionally uses a
539-
// patterned secret should set PROD_MODE=false.
540-
if d := distinctByteCount(c.TokenSigningSecret); d > 0 && d < 16 {
541-
violations = append(violations, fmt.Sprintf("TOKEN_SIGNING_SECRET has %d distinct bytes (<16); patterned secret indicates a human-typed value", d))
532+
// Promote the obvious-weakness warning to a hard fail under
533+
// PROD_MODE. A patterned secret (all-same byte, or short
534+
// repeating period) leaves the AES-GCM key derived from
535+
// SHA-256 trivially recoverable. Dev / single-replica work
536+
// that intentionally uses a patterned secret should set
537+
// PROD_MODE=false.
538+
if reason := weakSecretReason(c.TokenSigningSecret); reason != "" {
539+
violations = append(violations, "TOKEN_SIGNING_SECRET "+reason)
542540
}
543541
for i, prev := range c.TokenSigningSecretsPrevious {
544-
if d := distinctByteCount(prev); d > 0 && d < 16 {
545-
violations = append(violations, fmt.Sprintf("TOKEN_SIGNING_SECRETS_PREVIOUS[%d] has %d distinct bytes (<16); rolling-rotation cutover would regress entropy floor", i, d))
542+
if reason := weakSecretReason(prev); reason != "" {
543+
violations = append(violations, fmt.Sprintf("TOKEN_SIGNING_SECRETS_PREVIOUS[%d] %s", i, reason))
546544
}
547545
}
548546
if len(violations) > 0 {
@@ -567,17 +565,58 @@ func (c *Config) SecretWeaknessWarning() string {
567565
return c.secretWeakWarning
568566
}
569567

570-
// distinctByteCount returns the number of unique byte values in b.
571-
func distinctByteCount(b []byte) int {
568+
// weakSecretReason returns a non-empty human-readable reason when b
569+
// matches an obvious-weakness pattern, or "" when b looks sane.
570+
// Catches three classes:
571+
//
572+
// 1. All-same byte (period 1).
573+
// 2. Repeating period < len(b) — e.g. `abcabc…`,
574+
// `0123456789abcdef0123456789abcdef`.
575+
// 3. Tiny alphabet (< 8 distinct bytes). Closes shapes that
576+
// defeat the period check by having uneven run-lengths
577+
// (`aaaa…b`, `aaaaabbbbbcccccddddd…`) but obviously cluster
578+
// around a small symbol set.
579+
//
580+
// Threshold choice for class 3: 8 is the most defensive floor
581+
// that still has zero practical false-positive rate on real
582+
// random output. A 32-char hex secret over a 16-symbol alphabet
583+
// has expected ~14 distinct chars (P(<8 distinct in 64 chars) is
584+
// essentially zero); base64 (64 symbols) and raw bytes (256)
585+
// cluster even higher. 4 was the prior choice and accepted
586+
// shapes like 5-symbol clustered runs; 8 closes that and stays
587+
// well below the random-hex distribution.
588+
func weakSecretReason(b []byte) string {
589+
if len(b) < 2 {
590+
return ""
591+
}
592+
for period := 1; period*2 <= len(b); period++ {
593+
repeats := true
594+
for i := period; i < len(b); i++ {
595+
if b[i] != b[i%period] {
596+
repeats = false
597+
break
598+
}
599+
}
600+
if repeats {
601+
if period == 1 {
602+
return fmt.Sprintf("is %d copies of a single byte (effectively zero entropy)", len(b))
603+
}
604+
return fmt.Sprintf("repeats with period %d (truly random secrets are non-periodic; use manifests/scripts/generate-signing-secret.sh)", period)
605+
}
606+
}
607+
const minDistinct = 8
572608
var seen [256]bool
573-
n := 0
609+
distinct := 0
574610
for _, v := range b {
575611
if !seen[v] {
576612
seen[v] = true
577-
n++
613+
distinct++
614+
if distinct >= minDistinct {
615+
return ""
616+
}
578617
}
579618
}
580-
return n
619+
return fmt.Sprintf("uses only %d distinct byte values (use manifests/scripts/generate-signing-secret.sh)", distinct)
581620
}
582621

583622
// validateRedisKeyPrefix enforces ASCII-printable only (no cluster-hash

0 commit comments

Comments
 (0)