Expand description
DPoP proof verification (RFC 9449), the verifier side.
jacquard’s OAuth client (jacquard-oauth, pinned alongside
jacquard-common) builds DPoP proofs and never checks one; this module is
the other half. It exists so that an authorization server or resource
server built on top of didbot-serve – sibling work under
crates/didbot-serve/src/oauth/, see plan/oauth.md – can hold one
DpopVerifier and call DpopVerifier::verify once per protected
request, rather than re-deriving RFC 9449 §4.3’s checklist at each call
site.
§What a proof is
A DPoP proof is a compact JWS: three base64url segments, header. payload.signature, with no . inside a segment. This verifier accepts
exactly the shape jacquard’s client mints (jacquard-oauth-0.12.1’s
dpop::build_dpop_proof), because that shape is this ecosystem’s proof of
what a conformant client actually sends:
- Header:
{"alg":"ES256","typ":"dpop+jwt","jwk":{"kty":"EC","crv": "P-256","x":..,"y":..}}– an EC P-256 public key embedded in the header, never akidpointing elsewhere. Only ES256 is accepted; nothing else is a DPoP key jacquard’s client can produce, andnoneis never entertained. - Payload:
jti,iat,htm,htu, and optionallyath(a hash of the access token this proof accompanies) andnonce(echoing a server- issued value). jacquard’s proofs carry noexp– RFC 9449 does not require one, freshness isiatplus the verifier’s own window instead, which isDpopVerifier::verify’s clock-skew check below. - Signature: a 64-byte compact
r || spair overheader.payload, ES256.
§What this module checks, and in what order
presented_proof makes RFC 9449 §4.3’s first check before any other:
a request carries one DPoP header. DpopVerifier::verify then checks
typ, alg, the embedded key and the signature, so nothing in the
payload is read before it is known to be signed. Then htm, htu, iat, the nonce and jti replay, and last
the two bindings: the proof’s key against the thumbprint an access token
is bound to, and ath against the token itself. RFC 9449 §4.3 allows any
order; the nonce comes before replay and ath, as in the reference
provider. Structure or signature failures are reported before content is
trusted, and a content mismatch never reads as a signature failure – see
DpopError’s variants, each of which names the one thing that did not
check out.
§Replay is a two-step
verify reads the replay window and DpopVerifier::accept writes it.
A caller verifies the proof, resolves the credential it arrived with, and
only then accepts: that is what keeps an entry from being something an
unauthenticated caller can spend, since every route that takes a proof
can be reached with a freshly generated key and no credential at all.
The window is in memory, so a restart empties it. For the first
clock-skew window after one, a proof stamped before the process started
is refused with DpopError::PredatesStart — its own answer, so it
never reads as a clock disagreeing. Past that window the clock check
refuses every proof old enough to be worth replaying.
§Nonces
A verifier built with require_nonce refuses a proof that carries no
nonce it accepts, and names the one to retry with. It hands out one
nonce at a time, the same to every client, for nonce_ttl; the first
time one is asked for after that, a fresh one replaces it. The replaced
nonce is still accepted for one more nonce_ttl, so a request in flight
across a rotation is not refused for it. A refusal replaces nothing, so a
caller sending wrong nonces cannot move the one an honest client holds.
Structs§
- Dpop
Verifier - Proof checking, nonce issuance, and the replay cache, held together
because the replay window and the clock-skew window are the same number
– see
DpopVerifier::new. - Nonce
State 🔒 - The nonce this verifier hands out, and the one it handed out before it,
each with the instant it stops or stopped being handed out. Both are
accepted until one
nonce_ttlpast that instant. - Proof
Claims 🔒 - The DPoP proof payload this verifier reads. RFC 9449 §4.2’s required
claims, plus the two optional ones (
ath,nonce) this module checks when a caller asks it to. - Proof
Header 🔒 - The DPoP proof header this verifier is willing to read.
- RawJwk 🔒
- An embedded JWK, exactly as RFC 7517 §4 and RFC 7518 §6.2 name an EC
public key’s members. Decoded lazily –
xandystay base64url text here, becauseCoordinatedecoding has its own error to report andRawJwkitself has none to give it. - Verified
Proof - What a successfully verified proof establishes.
Enums§
- Dpop
Error - Why a presented DPoP proof, or the binding it claims, was refused.
Constants§
- DPOP
DPoP, the request header a proof travels in – RFC 9449 §4.1.- DPOP_
NONCE DPoP-Nonce, the response header a server hands a client a nonce in – RFC 9449 §8.- JWK_CRV 🔒
- JWK_KTY 🔒
- JWT_ALG 🔒
- The one JWS algorithm this verifier accepts.
- JWT_TYP 🔒
- The
typheader RFC 9449 §4.2 requires on every DPoP proof JWT.
Functions§
- decode_
ec_ 🔒point - Decodes a JWK’s
x/ymembers into raw coordinates, refusing anything that is not an EC P-256 key with both present and exactly 32 bytes long. - jwk_
thumbprint - The RFC 7638 JWK thumbprint of an EC P-256 public key, from its raw coordinates.
- normalize_
htu 🔒 - Strips query and fragment and lowercases scheme and host, so that
htucomparison follows RFC 9449 §4.3’s “the same server can use different forms” allowance without demanding byte-identical strings for two URLs that name the same resource. - p256_
verifying_ 🔒key - Builds a verifying key from raw coordinates, the same SEC1-uncompressed
route
didbot_key::secp256r1::VerifyingKey::from_multibasetakes – see that module’s doc for why a key that arrives from elsewhere is judged rather than normalized. - presented_
proof - The DPoP proof a request carries, if it carries one.
- random_
nonce 🔒 - A fresh nonce: 18 CSPRNG-drawn bytes, base64url. The same entropy source
didbot_pds::credential’s bearer tokens draw from.
Type Aliases§
- Coordinate 🔒
- A raw, uninterpreted 32-byte EC coordinate, base64url-decoded from a JWK’s
xorymember.