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 ishex(sha256(key))— never the plaintext key — and the stored hash ishmac-sha256:<hex(HMAC-SHA256(pepper, key))>when the deployment configures a credential pepper (#124) or legacysha256:<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 kindapi_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 perJwtProviderconfig: JWKS fetched from the provider’s URL, cached, refreshed afterrefresh_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 formin(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 unknownkid(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 permin(5s, refresh_secs)— forged random-kid tokens cannot drive a JWKS fetch storm.iss/exp/nbfare validated withleeway_secsskew tolerance, andaudis validated ONLY when the provider configures an audience (#124, maintainer decision): a provider withoutaudienceaccepts tokens that carry any (or no)audclaim; the algorithm allowlist (default RS256/ES256) is enforced BEFORE any signature work (noneandHS*are asymmetric-confusion bait and never allowed). - mTLS (#124): the client certificate of the connection itself.
On a TERMINATE listener with
client_ca_fileset, 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 aClientCertificate. The credential record maps it to a consumer: anmtlscredential’s SELECTOR is its match value — the subject CommonName when the credential is configuredby subject, or the certificate’s SHA-256 fingerprint whenby 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):
| Header | Content |
|---|---|
X-Dwara-Key-Id | the credential’s key_id (public selector; 1..=128 visible-ASCII bytes) |
X-Dwara-Timestamp | decimal Unix epoch seconds of signing (digits only) |
X-Dwara-Nonce | opaque client string, 16..=256 visible-ASCII bytes, unique per request within the replay window (use >= 128 bits of entropy) |
X-Dwara-Body-Sha256 | lowercase hex SHA-256 of the request body (the empty body signs e3b0c4...b855, SHA-256 of the empty string) |
X-Dwara-Signature | lowercase 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 presentedDesign 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=2and?b=2&a=1are 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
Hostheader 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-Typeand 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
- Header presence/format parse (401 on any malformed element).
- Timestamp inside
±max_clock_skew_secs(default 300s, the §4.6 recommendation; gatewayhmac_authblock, 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. - Key lookup, then
HMAC-SHA256(secret, canonical)compared to the presented signature withsubtle::ConstantTimeEqover 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. - Nonce replay check, AFTER a successful MAC: the nonce is
remembered under
key_id + '\n' + noncefor 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§
- Authn
Request - The request material an
Authenticatormay 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, seeIdentity::body_digest). - Client
Certificate - 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, soDebugredacts them (the selector-redaction precedent). DW-035 extends it with the issuer CN, not-after timestamp, and colon-separated fingerprint needed for theX-Client-Cert-*forwarding headers and the gateway-level mTLS consumer mapping. - Composite
Authenticator - 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.
- Hmac
Credential - 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. ManualDebugredacts it. - Identity
- The resolved caller identity of one request.
None(fromAuthenticator::authenticate) is Anonymous — no credential family applied. - Jwks
Cache Entry - 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.
- Known
Credential - One verifiable credential as the authenticator sees it.
- Nonce
Cache - Per-instance replay-nonce store (DW-036): sharded keyed TTL map, the
GCRA shard store’s shape (
GcraShardStore).Self::check_and_insertis 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 atcrate::config::limits::MAX_NONCE_CACHE_ENTRIES_PER_SHARDentries first drops expired entries, then evicts soonest-expiry-first (the documented fail-open-under-flood trade; see the module docs).
Enums§
- Auth
Error - Authentication failure.
Invalidis a caller-side problem (401);Unavailableis a gateway-side problem (500). - Credential
Registry - Where credential records come from: the state store (hot-cached; the
DWARA_STATE_DBdeployment) 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::ConstantTimeEqover the encoded digests — comparing hex strings byte-wise is length-equal and timing-uniform). Supported formats:hmac-sha256:<hex>(#124; verified ONLY when apepperis configured — a missing pepper fails closed for peppered entries), legacysha256:<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).