Module authn

Module authn 

Source
Expand description

Request authentication (DW-019, feature analysis section 4.6; pepper and mTLS hardening per #124; HMAC request signing per DW-036).

Five credential families behind one Authenticator trait:

  • API keys: X-API-Key: <key>. The lookup SELECTOR is hex(sha256(key)) — never the plaintext key — and the stored hash is hmac-sha256:<hex(HMAC-SHA256(pepper, key))> when the deployment configures a credential pepper (#124) or legacy sha256:<hex(sha256(key))> otherwise, verified with a constant-time comparison (subtle) in either case. Optional memory-hard verification: a credential whose stored hash is a PHC string ($argon2id$..., admin-supplied through the state store) is verified with argon2id. Trade-off (documented choice): sha256/HMAC+ct-compare is the pragmatic gateway standard — an argon2 verify is memory-hard and tens of milliseconds, far too slow for a per-request hot path, so config-declared keys are always fast-path hashed at seed time and argon2id is opt-in per credential.
  • Basic: Authorization: Basic base64(user:pass). The username is the selector (hex(sha256(user)), the same selector space as API keys) and the password is verified against the stored hash through the SAME hashing path as API keys (peppered when a pepper is configured). Basic credentials therefore live in the state store (config declares API keys, not username/password pairs); the resolved identity is reported with kind api_key. REQUIREMENT: store-managed Basic credentials must store argon2id PHC strings ($argon2id$...) — a human-chosen password hashed with plain unsalted sha256 is offline-dictionary bait (see the hardening notes below). Note also that usernames remain enumerable: the selector is a deterministic hash of the username with no secret input, so an attacker with the store can confirm username guesses offline.
  • JWT: Authorization: Bearer <token>, verified per JwtProvider config: JWKS fetched from the provider’s URL, cached, refreshed after refresh_secs (the refresh happens BEFORE a stale cached set is used, so retired issuer keys cannot keep verifying forever; a failed refresh degrades to the cached keys, and a RECENTLY failed refresh backs off for min(5s, refresh_secs) — subsequent stale lookups skip the fetch and answer from the cache immediately, so a down/slow endpoint cannot chain every Bearer request through a serialized 5s fetch timeout) AND on an unknown kid (rotation: a fresh key id appearing mid-flight triggers a re-fetch and the token then verifies — no restart, no failure). Refresh-triggered (unknown-kid) fetches are throttled to one per min(5s, refresh_secs) — forged random-kid tokens cannot drive a JWKS fetch storm. iss/exp/nbf are validated with leeway_secs skew tolerance, and aud is validated ONLY when the provider configures an audience (#124, maintainer decision): a provider without audience accepts tokens that carry any (or no) aud claim; the algorithm allowlist (default RS256/ES256) is enforced BEFORE any signature work (none and HS* are asymmetric-confusion bait and never allowed).
  • mTLS (#124): the client certificate of the connection itself. On a TERMINATE listener with client_ca_file set, the TLS layer verifies any presented certificate against that bundle (rustls; an unverified certificate fails the handshake and never reaches authn) and the listener forwards the verified certificate here as a ClientCertificate. The credential record maps it to a consumer: an mtls credential’s SELECTOR is its match value — the subject CommonName when the credential is configured by subject, or the certificate’s SHA-256 fingerprint when by fingerprint — and a presented certificate matches when its subject CN or fingerprint equals that selector. Passthrough listeners never speak HTTP, and cleartext listeners have no certificates, so the family is inert there by construction.
  • HMAC request signing (DW-036): a per-request signature over the request line, payload digest, timestamp, and nonce, presented in the X-Dwara-* header family — see the dedicated section below for the canonical-string contract (interop depends on it).

Accepted formats (composite dispatch on request shape): X-API-Key wins over Authorization; within Authorization, Basic and Bearer are distinguished by the scheme token; a presented X-Dwara-Signature engages the HMAC family after the Authorization schemes; the client certificate is the AMBIENT family — consulted only when NO header credential was presented (a header expresses explicit intent and wins; the certificate is connection-level context). A Bearer header with no JWT provider stays pass-through (not interpreted), so such a request still falls through to the HMAC headers and then the certificate family. A gateway with NO consumers and NO JWT providers has authentication disabled: the authenticator resolves Anonymous for everything and Authorization is forwarded upstream untouched (pass-through mode). Once ANY credential is configured, the gateway INTERPRETS Authorization — except that Bearer stays pass-through unless a JWT provider exists (a gateway fronting an OAuth-protected upstream without its own JWT config must keep forwarding tokens).

§HMAC request signing (DW-036)

A consumer with an hmac credential (key_id + secret, the secret inline or a ${...} reference, DW-045) signs every request. The secret never becomes a stored hash: recomputing an HMAC needs the raw key bytes, so the RESOLVED secret lives only in this module’s in-memory key map (zeroized on drop) and the state store never sees an hmac row. The pepper (#124) does not apply — it guards stored hashes, and there is none.

§Wire format

Five request headers, all REQUIRED when X-Dwara-Signature is presented (any missing or malformed one is a 401, like any other presented-but-invalid credential):

HeaderContent
X-Dwara-Key-Idthe credential’s key_id (public selector; 1..=128 visible-ASCII bytes)
X-Dwara-Timestampdecimal Unix epoch seconds of signing (digits only)
X-Dwara-Nonceopaque client string, 16..=256 visible-ASCII bytes, unique per request within the replay window (use >= 128 bits of entropy)
X-Dwara-Body-Sha256lowercase hex SHA-256 of the request body (the empty body signs e3b0c4...b855, SHA-256 of the empty string)
X-Dwara-Signaturelowercase hex HMAC-SHA256(secret, canonical string)

The custom X-Dwara-* family (not an Authorization scheme) follows the codebase’s X-API-Key precedent: discrete headers make each signed element explicit and keep the signature material inspectable without parsing a parameterized scheme. Like the other credential headers they are forwarded upstream untouched (only X-Consumer-* are stripped).

§Canonical string (v1) — the interop contract

A version line followed by the seven signed elements, each pair joined by exactly one \n byte (no trailing newline). No element may itself contain \n — the grammar guarantees it (visible ASCII excludes control characters, the timestamp is digits, the digest is hex, and hyper’s parser rejects raw control bytes in a request target):

dwara-hmac-v1
<key id>            the X-Dwara-Key-Id value, exactly as presented
<method>            the HTTP method, uppercased (GET, POST, ...)
<path>              the request path EXACTLY as received: percent-encoding preserved, no normalization
<query>             the raw query string as received (no leading '?'), or an EMPTY line when absent
<timestamp>         the X-Dwara-Timestamp value, exactly as presented
<nonce>             the X-Dwara-Nonce value, exactly as presented
<body digest>       the X-Dwara-Body-Sha256 value, exactly as presented

Design decisions, each load-bearing:

  • Path/query exactly as received. The signer cannot know what normalization a proxy chain might apply, so the only lossless contract is the raw bytes the client put on the wire; the gateway re-reads them from its parsed (but un-normalized) request target. Query ORDER is signed: ?a=1&b=2 and ?b=2&a=1 are different canonical strings.
  • Body by digest, carried in a signed header. Signing the body inline would require buffering it (the gateway hashes nothing until it streams); the digest header lets the gateway verify the MAC over headers only and then enforce the digest while STREAMING the body to the upstream — zero buffering, any body size, and the stream is aborted (401 to the client, truncated request to the upstream) the moment the final byte’s hash disagrees. A tampered body therefore never completes upstream. The route’s max_body_bytes (DW-027) composes with this: the digesting wrapper sits inside the route’s limit wrapper, so an over-cap body is still rejected 413 first.
  • No other headers in v1. The signed set binds the request line (method/path/query), the payload (digest), freshness (timestamp), uniqueness (nonce), and identity (key id). The Host header is NOT signed: it is a routing input, so a party able to tamper headers between signer and gateway (a non-TLS listener or an untrusted proxy hop) could retarget a validly signed request to a different host-matched route while MAC and digest still verify — TLS termination makes that party mostly hypothetical, and the gateway rebuilds Host from the upstream pick, so no cross-host forwarding occurs. Content-Type and friends are not authn inputs here, and signing arbitrary header lists drags in header-selection and canonicalization ambiguity every signer must replicate exactly. The versioned first line leaves room for a v2 with opt-in header (including Host) coverage without breaking v1 signers.

§Verification order and failure posture

  1. Header presence/format parse (401 on any malformed element).
  2. Timestamp inside ±max_clock_skew_secs (default 300s, the §4.6 recommendation; gateway hmac_auth block, validated 1..=3600). Checked BEFORE any HMAC work — an expired window is not a MAC problem, and refusing early keeps the hot path cheap for stale traffic. Outside the window: 401.
  3. Key lookup, then HMAC-SHA256(secret, canonical) compared to the presented signature with subtle::ConstantTimeEq over the full 32-byte digests — no early return on a byte mismatch. A key-miss computes a dummy HMAC first (fixed zero key) so the timing shape of “unknown key” matches “wrong signature” and key-existence is not readable from latency; both answer the same 401 shape as every other family.
  4. Nonce replay check, AFTER a successful MAC: the nonce is remembered under key_id + '\n' + nonce for twice the skew window (a timestamp stays acceptable for at most one full window after its request was first seen, so the doubled TTL covers the boundary with margin). A remembered nonce inside its TTL: 401. Burned only on VALID signatures — junk traffic cannot flood legitimate nonces out of the cache.

§Replay window boundary (single instance, M2)

The nonce cache is in-memory, sharded (NONCE_CACHE_SHARDS locks, the GCRA store’s pattern), TTL-expired, and capped at crate::config::limits::MAX_NONCE_CACHE_ENTRIES_PER_SHARD entries per shard with soonest-expiry-first eviction — under a nonce flood the cache degrades fail-open to eviction (the documented GCRA trade: an availability DoS must not become a gateway outage), and replay protection is only as strong as the cache’s retention. It is also PER-INSTANCE: dwara M2 is a single-process deployment, and a multi-instance fleet behind one VIP would let a replayed request hit a cold instance. A shared nonce store is the enterprise/Redis seam (DW-031’s world), not this module’s; the boundary is documented here so operators do not mistake per-instance replay protection for fleet-wide.

Identity-to-consumer mapping: API keys and Basic map via the credential record; JWTs map via the provider’s consumer binding, or by matching a consumer’s jwt credential issuer (with audience containment when the credential lists audiences) against the token’s iss; client certificates map via the mtls credential match value. Every resolved identity carries the consumer’s GROUPS (config consumers from the config record, store-managed consumers from the store’s consumers.groups, #124) so group-based authorization applies uniformly.

Consumer-identity headers (spoof prevention): the proxy strips every client-supplied X-Consumer-* header and injects a trusted X-Consumer-Name upstream when authentication resolved a consumer — see proxy (the strip/inject lives on the forward path).

§Hardening notes (residual risks and the road to closing them)

The stored-hash fast path is UNSALTED sha256 when no pepper is configured, and the selector is always unsalted sha256 of the presented material. That is deliberate for machine-generated config keys (constant-time, index-friendly, and a search over the stored hashes is not a dictionary attack when the key is 256 bits of entropy), but it leaves a residual offline-dictionary risk for WEAK secrets: an attacker who exfiltrates the store can brute-force low-entropy keys or passwords against the unsalted selectors/hashes at sha256 speed, offline, and confirm username guesses via the deterministic Basic selector. Mitigations in place: the DB file is mode 0600, hashes/selectors are redacted from Debug, store-managed credentials may use argon2id PHC strings (REQUIRED for Basic passwords), and — #124 — a per-deployment PEPPER turns every NEW stored hash into hmac-sha256:<hex> so a DB leak alone cannot verify guesses (the search also needs the pepper, held outside the DB). The pepper is resolved through the SecretSource extension seam ABOVE this domain (the binary resolves it and threads raw bytes down; security must not import extensions), is never logged, never appears in Debug output, and never appears in error text. Transition semantics: legacy sha256:<hex> entries keep verifying; on SUCCESSFUL legacy verification with a pepper configured, the store row is re-hashed to the peppered format in place (no credential re-issue needed); hmac-sha256: entries with NO pepper configured fail closed (legacy-only mode) with a clear log line.

Structs§

AuthnRequest
The request material an Authenticator may consume (DW-036): headers plus the request TARGET — method, path, query — the HMAC family signs. Borrowed for one authenticate call; the request body is deliberately NOT here (authn never buffers; the HMAC body digest is enforced on the forward path, see Identity::body_digest).
ClientCertificate
The VERIFIED client certificate of a connection, as the authenticator consumes it (#124, DW-035). Built by the TLS frontend from the certificate rustls already verified against the listener’s client_ca_file; the match VALUES (subject CN, fingerprint) double as credential selectors, so Debug redacts them (the selector-redaction precedent). DW-035 extends it with the issuer CN, not-after timestamp, and colon-separated fingerprint needed for the X-Client-Cert-* forwarding headers and the gateway-level mTLS consumer mapping.
CompositeAuthenticator
The gateway’s single authenticator: dispatches on the request’s credential shape (X-API-Key / Basic / Bearer / verified client certificate) and consults the credential registry plus the configured JWT providers.
HmacCredential
One HMAC signing key as the authenticator holds it (DW-036). The secret is the RESOLVED config value (inline or ${...} reference, resolved at build time): raw key bytes, never a hash, never logged, zeroized when the last holder drops. Manual Debug redacts it.
Identity
The resolved caller identity of one request. None (from Authenticator::authenticate) is Anonymous — no credential family applied.
JwksCacheEntry
Shared JWKS cache for one provider URL: the cached key set plus the refresh bookkeeping. Entries live on the dataplane keyed by URL, so a config reload reuses the cache (rotation state survives reloads).
JwtVerifier
One configured JWT provider at runtime: config + HTTP client + cache.
KnownCredential
One verifiable credential as the authenticator sees it.
NonceCache
Per-instance replay-nonce store (DW-036): sharded keyed TTL map, the GCRA shard store’s shape (GcraShardStore). Self::check_and_insert is the single critical section — one shard lock covers the expired-read and the insert, so two concurrent presentations of one nonce linearize (exactly one wins). Entries expire after the caller-supplied TTL (twice the clock-skew window, set per insert so a skew change on reload applies to new nonces without rebuilding the cache); a shard at crate::config::limits::MAX_NONCE_CACHE_ENTRIES_PER_SHARD entries first drops expired entries, then evicts soonest-expiry-first (the documented fail-open-under-flood trade; see the module docs).

Enums§

AuthError
Authentication failure. Invalid is a caller-side problem (401); Unavailable is a gateway-side problem (500).
CredentialRegistry
Where credential records come from: the state store (hot-cached; the DWARA_STATE_DB deployment) or config consumers hashed in-memory at startup. Both paths unify behind one lookup API (a private selector-keyed search shared by the API-key, Basic, and mTLS paths).

Constants§

CONSUMER_HEADER_PREFIX
Whether the gateway treats X-Consumer-* request headers as spoof material: always — the proxy strips them and injects its own.
NONCE_CACHE_SHARDS
Number of independent shard locks in the nonce cache. Fixed (not CPU-count-derived) so the worst-case entry bound (NONCE_CACHE_SHARDS * MAX_NONCE_CACHE_ENTRIES_PER_SHARD) is the same number on every machine — the GCRA store’s rationale.
X_CONSUMER_NAME
The trusted consumer-identity header the gateway injects upstream.
X_DWARA_BODY_SHA256
X_DWARA_KEY_ID
X_DWARA_NONCE
X_DWARA_SIGNATURE
The X-Dwara-* header family (DW-036). See the module docs for the canonical-string contract; these consts are the single source of the header names so the verifier and the challenge cannot drift.
X_DWARA_TIMESTAMP

Traits§

Authenticator
One pluggable authenticator (dyn-compatible seam). Ok(None) is Anonymous; Err(AuthError::Invalid(..)) means a credential was PRESENTED and rejected.

Functions§

canonical_string
Build the canonical string (DW-036 v1). The elements are the PRESENTED header values and the request target EXACTLY as received — the signer’s bytes and the verifier’s bytes must agree without any normalization step (see the module docs for the grammar and the rationale). Public because the grammar is the interop contract: tests re-implement it independently to pin the documentation, and operators may build signing tooling against the same function the gateway verifies with.
parse_algorithms
Public for testing the JWT algorithm-allowlist contract.
verify_secret
Verify a presented secret against a stored hash, in constant time for the fast paths (subtle::ConstantTimeEq over the encoded digests — comparing hex strings byte-wise is length-equal and timing-uniform). Supported formats: hmac-sha256:<hex> (#124; verified ONLY when a pepper is configured — a missing pepper fails closed for peppered entries), legacy sha256:<hex> (always verified, pepper or not: the transition keeps old rows valid), and PHC argon2id strings ($argon2id$..., pepper-independent). Unknown formats verify false (never accept).