Module credentials

Module credentials 

Source
Expand description

Credential hashing: the selector and stored-hash formats of the credential schema contract (DW-018/DW-019, peppered per #124).

The lookup SELECTOR (hex(sha256(value))) and the stored-hash formats are shared by every credential holder — config-seeded consumers hashed at startup, the state store’s credential records, and the authenticator’s presentation path — so the formats are defined here with the schema instead of in any one consumer. A selector is never plaintext: an indexed store of selectors leaks nothing about key material.

§Stored-hash formats (#124)

  • hmac-sha256:<hex(HMAC-SHA256(pepper, secret)))> — the PEPPERED format every NEW write uses when a per-deployment pepper is configured (resolved through the SecretSource extension seam and threaded down as raw bytes; the security domain never touches the extension itself). A state-DB leak alone cannot verify guesses: the search also needs the pepper. Verification is constant-time over the hex encodings, exactly like the sha256 path.
  • sha256:<hex(sha256(secret))> — the LEGACY unpeppered format. Existing entries keep verifying during the transition; on successful legacy verification with a pepper configured, the store path re-hashes the row to the peppered format in place (the state domain’s StateStore::rehash_credential; config cannot link the state path — dependency direction is downward only), so the transition completes lazily without credential re-issue.
  • PHC argon2id strings ($argon2id$...) — the memory-hard store-managed Basic path, unchanged by the pepper.

The SELECTOR stays hex(sha256(value)) in both modes deliberately: it must remain computable from the presented material alone (no secret input) or indexed lookups could not find the row, and a sha256 selector leaks nothing about the secret. The pepper is SECRET material: never logged, never in Debug, never in error text.

§Secret references (DW-045)

A secret-bearing config field (today: consumers[].credentials[] api_key.key) may carry either the value INLINE or a ${...} REFERENCE resolved through the file/env vocabulary this module defines:

  • ${ENV_NAME} — an environment variable ([A-Za-z_][A-Za-z0-9_]*; the file: and redacted prefixes below are reserved and matched first, so they cannot collide with a variable name). Unset or empty fails closed.
  • ${file:/path/to/secret} — a file read at resolution time (the Docker/Kubernetes mounted-secret and systemd LoadCredential shape). ONE trailing newline is trimmed (\n or \r\n) — the convention of mounted secret files — and the remainder must be non-empty. The read is BOUNDED at MAX_SECRET_FILE_BYTES (1 MiB): a larger file fails closed naming the path and the limit.
  • ${redacted:sha256:<8hex>} — the REDACTION PLACEHOLDER this module’s redact_inline_secret emits wherever the gateway echoes configuration (admin GET /config, DW-045). It is not resolvable BY DESIGN: a GET-then-PATCH round trip that carries a placeholder back is rejected by validation with a precise issue instead of silently installing placeholder bytes as a live key. A short sha256 prefix lets operators compare WHICH key is deployed without seeing it.

Posture (pinned by tests): INLINE VALUES REMAIN ACCEPTED for backward compatibility — existing configs keep working — but they are redacted in every config echo; references are the recommended shape for new configs because the config file then never holds the secret bytes at all. A value that merely CONTAINS ${...} mid-string is NOT a reference (no shell-style partial expansion; it stays a literal), while a value that STARTS with ${ but is not a well-formed reference — including one that is never closed, like ${unclosed — is a validation error, never a silently installed literal key.

Read-time model: references resolve at CONFIG-COMPILE time — cold start and every hot reload/re-publish re-read the env/file (the same generation-follows-config contract as TLS cert material; a rotation needs a reload to apply). Validation resolves every reference and fails closed with a ValidationIssue naming the field; the credential consumers (authn registry build, store seeding) resolve again immediately after publish, so a secret that breaks between validate and build is skipped loudly (the microsecond-race fail-closed backstop, the same pattern as trusted_ca_file). The request path NEVER touches a secret source: resolved values are hashed into selectors/stored-hashes at build time and the plaintext is dropped. The SecretSource extension impls (EnvSecretSource, FileSecretSource) share this module’s reading rules so the seam and the config grammar cannot drift apart.

Enums§

SecretRef
A parsed ${...} secret reference (DW-045). See the module docs for the grammar, the read-time model, and the redaction-placeholder round-trip contract.

Constants§

MAX_SECRET_FILE_BYTES
Upper bound on one secret file’s size (DW-045, #46): secret files hold short key material, and the read must never be a memory-exhaustion vector — ${file:/dev/zero} reads NUL bytes forever (NULs are valid UTF-8), so an unbounded read runs until the allocator gives up and takes the process with it, on every reload. Bounded reads have precedent: the SNI ClientHello reassembler caps at 64 KiB (MAX_HELLO_BYTES in the security/TLS domain). At or under the cap the file is read whole; anything larger fails closed naming the path and the limit. The bound is enforced BOTH on the file’s metadata up front and on the bytes actually read (Read::take), so a file that grows between the two checks — or a non-regular file whose metadata size is 0, like /dev/zero — is still capped.

Functions§

credential_selector
Lookup selector for API-key and Basic credentials: hex(sha256(value)). The selector is therefore a HASH, never plaintext — an indexed store of selectors leaks nothing about key material (this closes the DW-018 finding that config-seeded selectors were the raw config values).
hmac_stored_hash
Peppered stored-hash format (#124): hmac-sha256:<hex(HMAC-SHA256(pepper, secret))>. Used for every NEW credential write (config seeding, store re-hash on legacy verify) when a pepper is configured; without one the legacy sha256_stored_hash format remains in force. An HMAC key shorter than the block size weakens the construction, so deployments should use at least 32 bytes of entropy (not enforced: a short pepper still strictly dominates no pepper).
parse_secret_reference
Classify a configured secret string (DW-045):
read_secret_file
Read one secret file (DW-045): the whole file as UTF-8 with a SINGLE trailing newline trimmed (\n or \r\n — the mounted-secret-file convention; interior newlines are preserved), bounded at MAX_SECRET_FILE_BYTES. Shared by the config grammar and the FileSecretSource extension impl so the two cannot drift. The error names the path, never any content.
redact_inline_secret
The redaction placeholder for an INLINE secret value (DW-045): ${redacted:sha256:<first 8 hex of sha256>}. The short prefix lets an operator compare which key a generation carries without ever seeing the key; the placeholder itself is unresolvable by design, so echoing it back through a config-publishing surface is rejected fail-closed instead of installing placeholder bytes as a live key. Reference-shaped values are returned UNCHANGED (an env-var name or file path is not secret bytes — the config file already carries it).
resolve_configured_secret
Resolve a configured secret string (DW-045): literals pass through untouched; well-formed references resolve at CALL time; malformed reference-shaped values fail closed. Every consumer of secret-bearing config fields (snapshot validation, the authn registry build, store seeding) goes through this one entry point so no path can bypass resolution.
sha256_hex
sha256 digest, hex-encoded (the API-key/Basic hashing path).
sha256_stored_hash
Stored-hash format for the fast path: sha256:<hex(sha256(secret))>.