Skip to main content

didbot_serve/oauth/
dpop.rs

1//! DPoP proof verification (RFC 9449), the verifier side.
2//!
3//! jacquard's OAuth client (`jacquard-oauth`, pinned alongside
4//! `jacquard-common`) builds DPoP proofs and never checks one; this module is
5//! the other half. It exists so that an authorization server or resource
6//! server built on top of `didbot-serve` -- sibling work under
7//! `crates/didbot-serve/src/oauth/`, see `plan/oauth.md` -- can hold one
8//! [`DpopVerifier`] and call [`DpopVerifier::verify`] once per protected
9//! request, rather than re-deriving RFC 9449 §4.3's checklist at each call
10//! site.
11//!
12//! # What a proof is
13//!
14//! A DPoP proof is a compact JWS: three base64url segments, `header.
15//! payload.signature`, with no `.` inside a segment. This verifier accepts
16//! exactly the shape jacquard's client mints (`jacquard-oauth-0.12.1`'s
17//! `dpop::build_dpop_proof`), because that shape is this ecosystem's proof of
18//! what a conformant client actually sends:
19//!
20//! * Header: `{"alg":"ES256","typ":"dpop+jwt","jwk":{"kty":"EC","crv":
21//!   "P-256","x":..,"y":..}}` -- an EC P-256 public key embedded in the
22//!   header, never a `kid` pointing elsewhere. Only ES256 is accepted;
23//!   nothing else is a DPoP key jacquard's client can produce, and `none` is
24//!   never entertained.
25//! * Payload: `jti`, `iat`, `htm`, `htu`, and optionally `ath` (a hash of the
26//!   access token this proof accompanies) and `nonce` (echoing a server-
27//!   issued value). jacquard's proofs carry no `exp` -- RFC 9449 does not
28//!   require one, freshness is `iat` plus the verifier's own window instead,
29//!   which is [`DpopVerifier::verify`]'s clock-skew check below.
30//! * Signature: a 64-byte compact `r || s` pair over `header.payload`, ES256.
31//!
32//! # What this module checks, and in what order
33//!
34//! [`presented_proof`] makes RFC 9449 §4.3's first check before any other:
35//! a request carries one `DPoP` header. [`DpopVerifier::verify`] then checks
36//! `typ`, `alg`, the embedded key and the signature, so nothing in the
37//! payload is read before it is known to be signed. Then `htm`, `htu`, `iat`, the nonce and `jti` replay, and last
38//! the two bindings: the proof's key against the thumbprint an access token
39//! is bound to, and `ath` against the token itself. RFC 9449 §4.3 allows any
40//! order; the nonce comes before replay and `ath`, as in the reference
41//! provider. Structure or signature failures are reported before content is
42//! trusted, and a content mismatch never reads as a signature failure -- see
43//! [`DpopError`]'s variants, each of which names the one thing that did not
44//! check out.
45//!
46//! # Replay is a two-step
47//!
48//! `verify` reads the replay window and [`DpopVerifier::accept`] writes it.
49//! A caller verifies the proof, resolves the credential it arrived with, and
50//! only then accepts: that is what keeps an entry from being something an
51//! unauthenticated caller can spend, since every route that takes a proof
52//! can be reached with a freshly generated key and no credential at all.
53//!
54//! The window is in memory, so a restart empties it. For the first
55//! clock-skew window after one, a proof stamped before the process started
56//! is refused with [`DpopError::PredatesStart`] — its own answer, so it
57//! never reads as a clock disagreeing. Past that window the clock check
58//! refuses every proof old enough to be worth replaying.
59//!
60//! # Nonces
61//!
62//! A verifier built with `require_nonce` refuses a proof that carries no
63//! nonce it accepts, and names the one to retry with. It hands out one
64//! nonce at a time, the same to every client, for `nonce_ttl`; the first
65//! time one is asked for after that, a fresh one replaces it. The replaced
66//! nonce is still accepted for one more `nonce_ttl`, so a request in flight
67//! across a rotation is not refused for it. A refusal replaces nothing, so a
68//! caller sending wrong nonces cannot move the one an honest client holds.
69
70use std::sync::Mutex;
71
72use axum::http::{HeaderMap, HeaderName, Method};
73use base64::prelude::*;
74use p256::ecdsa::signature::Verifier as _;
75use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey};
76use rand_core::{OsRng, RngCore};
77use serde::Deserialize;
78use sha2::{Digest, Sha256};
79use time::{Duration, OffsetDateTime};
80
81use crate::auth::constant_time_eq;
82use crate::replay::{Spent, SpentKeys, MAX_JTI_BYTES, REPLAY_CAPACITY};
83
84/// The `typ` header RFC 9449 §4.2 requires on every DPoP proof JWT.
85const JWT_TYP: &str = "dpop+jwt";
86
87/// The one JWS algorithm this verifier accepts.
88///
89/// DPoP proofs in the wild are frequently ES256 -- P-256 is one of the two
90/// curves atproto signing keys use, and it is the curve `jacquard-oauth`'s
91/// client mints (`jacquard_oauth::dpop::build_dpop_proof` refuses to build
92/// with anything else). `didbot-key`'s `secp256r1` module documents why this
93/// workspace mints no P-256 *signing* key but does verify them: a key that
94/// arrives from somewhere else is not ours to normalize, only to judge. The
95/// same reasoning applies here.
96pub(crate) const JWT_ALG: &str = "ES256";
97
98const JWK_KTY: &str = "EC";
99const JWK_CRV: &str = "P-256";
100
101/// A raw, uninterpreted 32-byte EC coordinate, base64url-decoded from a JWK's
102/// `x` or `y` member.
103type Coordinate = [u8; 32];
104
105/// The DPoP proof header this verifier is willing to read.
106///
107/// Only the members RFC 9449 requires. An unrecognised extra header member
108/// (`kid`, `x5c`, and so on) is ignored rather than refused: nothing here
109/// reads it, and a proof jacquard's client would never emit one, but refusing
110/// on an unknown member the RFC does not forbid would be a stricter reading
111/// than the spec supports.
112#[derive(Debug, Deserialize)]
113struct ProofHeader {
114    alg: String,
115    typ: Option<String>,
116    jwk: Option<RawJwk>,
117}
118
119/// An embedded JWK, exactly as RFC 7517 §4 and RFC 7518 §6.2 name an EC
120/// public key's members. Decoded lazily -- `x` and `y` stay base64url text
121/// here, because [`Coordinate`] decoding has its own error to report and
122/// [`RawJwk`] itself has none to give it.
123#[derive(Debug, Clone, Deserialize)]
124struct RawJwk {
125    kty: String,
126    crv: Option<String>,
127    x: Option<String>,
128    y: Option<String>,
129}
130
131/// The DPoP proof payload this verifier reads. RFC 9449 §4.2's required
132/// claims, plus the two optional ones (`ath`, `nonce`) this module checks
133/// when a caller asks it to.
134#[derive(Debug, Deserialize)]
135struct ProofClaims {
136    jti: Option<String>,
137    htm: Option<String>,
138    htu: Option<String>,
139    iat: Option<i64>,
140    #[serde(default)]
141    ath: Option<String>,
142    #[serde(default)]
143    nonce: Option<String>,
144}
145
146/// Why a presented DPoP proof, or the binding it claims, was refused.
147///
148/// Each variant names the one check that failed rather than folding every
149/// failure into a single "invalid proof" -- in particular
150/// [`DpopError::ClockSkew`] carries both timestamps and the window, so a
151/// refusal caused by clock drift between two honest clocks reads as exactly
152/// that rather than as a forged signature.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum DpopError {
155    /// The request carried more than one `DPoP` header.
156    MoreThanOneProof,
157    /// Not three `.`-separated segments.
158    Malformed,
159    /// A segment was not valid, unpadded base64url, or the JSON it decoded
160    /// to did not have the shape this module reads. The `&'static str` names
161    /// which segment.
162    InvalidEncoding(&'static str),
163    /// A required claim (`jti`, `htm`, `htu`, or `iat`) was absent.
164    MissingClaim(&'static str),
165    /// The `jti` is longer than [`MAX_JTI_BYTES`], so the replay window
166    /// will not keep it.
167    JtiTooLong {
168        /// How long it was, in bytes.
169        length: usize,
170    },
171    /// `typ` was missing, or present and not `"dpop+jwt"`.
172    WrongTyp(Option<String>),
173    /// `alg` was not `"ES256"`.
174    UnsupportedAlgorithm(String),
175    /// The embedded `jwk` did not name an EC P-256 key with well-formed `x`
176    /// and `y` members, or did not decode to a point on the curve.
177    UnsupportedKey,
178    /// The signature did not verify over `header.payload`.
179    BadSignature,
180    /// `htm` did not match the request's method.
181    MethodMismatch {
182        /// What the proof claimed.
183        proof: String,
184        /// What the request actually was.
185        expected: String,
186    },
187    /// `htu` did not match the request's target, once both are normalized
188    /// (query and fragment dropped, scheme and host lowercased).
189    UriMismatch {
190        /// What the proof claimed.
191        proof: String,
192        /// What the caller told this verifier to expect.
193        expected: String,
194    },
195    /// `iat` fell outside the accepted clock-skew window.
196    ClockSkew {
197        /// The proof's own `iat`, as an absolute time.
198        iat: OffsetDateTime,
199        /// This verifier's own clock at the moment it checked.
200        now: OffsetDateTime,
201        /// The window on either side of `now` that would have been accepted.
202        window: Duration,
203    },
204    /// The proof was stamped before this verifier began accepting proofs,
205    /// and it is still inside the first clock-skew window since. Its own
206    /// refusal rather than [`DpopError::ClockSkew`], because the clocks
207    /// agree: the proof is simply older than the replay window can speak
208    /// for.
209    PredatesStart {
210        /// The proof's own `iat`, as an absolute time.
211        iat: OffsetDateTime,
212        /// When this verifier began accepting proofs.
213        started: OffsetDateTime,
214    },
215    /// This `jti` was already accepted once, inside the window during which
216    /// it is still considered fresh.
217    Replayed {
218        /// The repeated `jti`.
219        jti: String,
220    },
221    /// The replay window is holding as many `jti`s as it will, so this
222    /// verifier cannot promise this one has not been seen. Refused rather
223    /// than admitted: a replay cache that forgets to make room is a replay
224    /// cache that lets one through. See [`REPLAY_CAPACITY`].
225    ReplayWindowFull,
226    /// A binding was requested (`expected_thumbprint` in
227    /// [`DpopVerifier::verify`]) and the proof's key does not have it.
228    ThumbprintMismatch,
229    /// An access token was supplied to check against and the proof's `ath`
230    /// does not hash to it -- either absent or wrong.
231    AccessTokenHashMismatch,
232    /// This verifier requires a nonce and the proof carried none. The client
233    /// resolves this by retrying with the carried nonce, which is now the
234    /// only nonce this verifier accepts.
235    NonceRequired {
236        /// The nonce to retry with.
237        fresh: String,
238    },
239    /// A nonce was presented and it is not one this verifier currently
240    /// accepts. Same resolution as [`DpopError::NonceRequired`].
241    NonceMismatch {
242        /// The nonce to retry with.
243        fresh: String,
244    },
245}
246
247impl std::fmt::Display for DpopError {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        match self {
250            DpopError::MoreThanOneProof => write!(
251                f,
252                "a request carries one DPoP proof, and this one carries more than one"
253            ),
254            DpopError::Malformed => {
255                write!(
256                    f,
257                    "a DPoP proof is three '.'-separated segments; this was not"
258                )
259            }
260            DpopError::InvalidEncoding(part) => {
261                write!(
262                    f,
263                    "the proof's {part} did not decode as unpadded base64url JSON"
264                )
265            }
266            DpopError::MissingClaim(claim) => {
267                write!(f, "the proof's payload has no `{claim}` claim")
268            }
269            DpopError::JtiTooLong { length } => write!(
270                f,
271                "the proof's `jti` is {length} bytes, longer than the {MAX_JTI_BYTES} this \
272                 verifier keeps"
273            ),
274            DpopError::WrongTyp(got) => write!(
275                f,
276                "the proof's `typ` header must be {JWT_TYP:?}, got {got:?}"
277            ),
278            DpopError::UnsupportedAlgorithm(got) => write!(
279                f,
280                "the proof's `alg` header must be {JWT_ALG:?}, got {got:?}"
281            ),
282            DpopError::UnsupportedKey => write!(
283                f,
284                "the proof's embedded `jwk` is not a well-formed EC P-256 public key"
285            ),
286            DpopError::BadSignature => {
287                write!(
288                    f,
289                    "the proof's signature does not verify against its own embedded key"
290                )
291            }
292            DpopError::MethodMismatch { proof, expected } => write!(
293                f,
294                "the proof's `htm` claim is {proof:?}, this request is {expected:?}"
295            ),
296            DpopError::UriMismatch { proof, expected } => write!(
297                f,
298                "the proof's `htu` claim is {proof:?}, this request targets {expected:?}"
299            ),
300            DpopError::ClockSkew { iat, now, window } => write!(
301                f,
302                "the proof was issued at {iat} and checked at {now}, outside the \
303                 {window}-second clock-skew window either side of it",
304                window = window.whole_seconds(),
305            ),
306            DpopError::PredatesStart { iat, started } => write!(
307                f,
308                "the proof was issued at {iat}, before this server began accepting proofs \
309                 at {started}; mint a fresh proof"
310            ),
311            DpopError::Replayed { jti } => {
312                write!(f, "the proof's `jti` {jti:?} has already been used")
313            }
314            DpopError::ReplayWindowFull => write!(
315                f,
316                "this verifier's replay window is full and cannot accept another proof right now"
317            ),
318            DpopError::ThumbprintMismatch => write!(
319                f,
320                "the proof's key does not have the thumbprint this credential is bound to"
321            ),
322            DpopError::AccessTokenHashMismatch => write!(
323                f,
324                "the proof's `ath` claim does not hash the access token it accompanies"
325            ),
326            DpopError::NonceRequired { .. } => {
327                write!(
328                    f,
329                    "this verifier requires a DPoP nonce and the proof carried none"
330                )
331            }
332            DpopError::NonceMismatch { .. } => write!(
333                f,
334                "the proof's `nonce` claim is not one this verifier currently accepts"
335            ),
336        }
337    }
338}
339
340impl std::error::Error for DpopError {}
341
342impl DpopError {
343    /// The nonce a client should retry with, for the two refusals that
344    /// carry one.
345    pub fn fresh_nonce(&self) -> Option<&str> {
346        match self {
347            DpopError::NonceRequired { fresh } | DpopError::NonceMismatch { fresh } => {
348                Some(fresh.as_str())
349            }
350            _ => None,
351        }
352    }
353}
354
355/// `DPoP-Nonce`, the response header a server hands a client a nonce in --
356/// RFC 9449 §8.
357pub const DPOP_NONCE: HeaderName = HeaderName::from_static("dpop-nonce");
358
359/// `DPoP`, the request header a proof travels in -- RFC 9449 §4.1.
360pub const DPOP: HeaderName = HeaderName::from_static("dpop");
361
362/// The DPoP proof a request carries, if it carries one.
363///
364/// RFC 9449 §4.3's first check: a request with more than one `DPoP` header
365/// is refused, so which proof the server checked is never a guess.
366pub fn presented_proof(headers: &HeaderMap) -> Result<Option<&str>, DpopError> {
367    let mut proofs = headers.get_all(DPOP).iter();
368    let Some(proof) = proofs.next() else {
369        return Ok(None);
370    };
371    if proofs.next().is_some() {
372        return Err(DpopError::MoreThanOneProof);
373    }
374    proof.to_str().map(Some).map_err(|_| DpopError::Malformed)
375}
376
377/// What a successfully verified proof establishes.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct VerifiedProof {
380    /// The proof's `jti`, now recorded in the replay cache.
381    pub jti: String,
382    /// The proof's `iat`, as an absolute time.
383    pub iat: OffsetDateTime,
384    /// The RFC 7638 thumbprint of the key the proof was signed with. An
385    /// authorization server mints an access token's `cnf.jkt` from this; a
386    /// resource server compares it against one.
387    pub thumbprint: String,
388}
389
390/// Proof checking, nonce issuance, and the replay cache, held together
391/// because the replay window and the clock-skew window are the same number
392/// -- see [`DpopVerifier::new`].
393///
394/// One instance covers one nonce space. This deployment's authorization
395/// server and resource server are one origin and hold one verifier between
396/// them, which the atproto profile allows: "The Resource Server and
397/// Authorization Server may share nonces (especially if they are the same
398/// server)".
399pub struct DpopVerifier {
400    /// How far from this verifier's own clock a proof's `iat` may sit,
401    /// either direction, and also how long a `jti` is retained in the replay
402    /// cache: past that point the proof itself would fail the clock check on
403    /// a second presentation, so retaining it longer catches nothing a resend
404    /// would not already be refused for.
405    skew: Duration,
406    /// How long a nonce is handed out before a fresh one replaces it. The
407    /// replaced nonce is accepted for as long again.
408    nonce_ttl: Duration,
409    /// Whether [`DpopVerifier::verify`] requires a nonce, and so whether
410    /// this verifier hands any out.
411    require_nonce: bool,
412    /// When this verifier began accepting proofs, to the whole second.
413    ///
414    /// The replay window is in memory, so a restart empties it and a proof
415    /// captured just before the restart would verify a second time. For
416    /// the first `skew` after this instant a proof stamped earlier than it
417    /// is refused, which is exactly the window the emptied cache could
418    /// have covered. Past that, no proof old enough to matter still
419    /// passes the clock check.
420    ///
421    /// Whole seconds because `iat` is whole seconds: a proof minted inside
422    /// the second this verifier started reads as minted at the start, and
423    /// is accepted.
424    started: OffsetDateTime,
425    /// `jti` values already accepted, and when each one falls out of the
426    /// window in which it could still be replayed usefully.
427    ///
428    /// An entry is spent by [`DpopVerifier::accept`], which a caller reaches
429    /// only once the credential the proof arrived with has checked out, so
430    /// filling [`REPLAY_CAPACITY`] takes that many authenticated requests
431    /// inside one clock-skew window.
432    replay: Mutex<SpentKeys>,
433    nonces: Mutex<NonceState>,
434}
435
436/// The nonce this verifier hands out, and the one it handed out before it,
437/// each with the instant it stops or stopped being handed out. Both are
438/// accepted until one `nonce_ttl` past that instant.
439#[derive(Default)]
440struct NonceState {
441    current: Option<(String, OffsetDateTime)>,
442    previous: Option<(String, OffsetDateTime)>,
443}
444
445impl NonceState {
446    /// Whether `presented` is a nonce held here that is still accepted at
447    /// `now`, `ttl` past the instant it stopped being handed out.
448    fn honours(&self, presented: &str, now: OffsetDateTime, ttl: Duration) -> bool {
449        [&self.current, &self.previous]
450            .into_iter()
451            .flatten()
452            .any(|(nonce, until)| nonce == presented && now < *until + ttl)
453    }
454}
455
456impl DpopVerifier {
457    /// Builds a verifier with the given clock-skew/replay window, nonce
458    /// lifetime, and whether a nonce is required at all.
459    /// `OAuthState::default` supplies this deployment's numbers.
460    pub fn new(skew: Duration, nonce_ttl: Duration, require_nonce: bool) -> Self {
461        Self::started_at(skew, nonce_ttl, require_nonce, OffsetDateTime::now_utc())
462    }
463
464    /// [`DpopVerifier::new`] with the instant it began accepting proofs
465    /// given rather than read off the clock, so a test can put a proof on
466    /// either side of it.
467    fn started_at(
468        skew: Duration,
469        nonce_ttl: Duration,
470        require_nonce: bool,
471        started: OffsetDateTime,
472    ) -> Self {
473        let started = OffsetDateTime::from_unix_timestamp(started.unix_timestamp())
474            .unwrap_or(OffsetDateTime::UNIX_EPOCH);
475        Self {
476            skew,
477            nonce_ttl,
478            require_nonce,
479            started,
480            replay: Mutex::new(SpentKeys::new(REPLAY_CAPACITY, started)),
481            nonces: Mutex::new(NonceState::default()),
482        }
483    }
484
485    /// The nonce to hand a client in `DPoP-Nonce`: the one this verifier
486    /// hands out now, replaced with a fresh one once it has been handed out
487    /// for `nonce_ttl`.
488    pub fn current_nonce(&self) -> String {
489        self.current_nonce_at(OffsetDateTime::now_utc())
490    }
491
492    fn current_nonce_at(&self, now: OffsetDateTime) -> String {
493        let mut state = self.nonces.lock().unwrap_or_else(|p| p.into_inner());
494        if let Some((nonce, until)) = &state.current {
495            if now < *until {
496                return nonce.clone();
497            }
498        }
499        let fresh = random_nonce();
500        state.previous = state.current.replace((fresh.clone(), now + self.nonce_ttl));
501        fresh
502    }
503
504    /// Verifies a DPoP proof JWT against a request.
505    ///
506    /// `method` and `htu` describe the request this proof must be bound to;
507    /// `htu` is a caller-built canonical absolute URL (scheme, host, and
508    /// path -- no query, no fragment) because assembling one correctly behind
509    /// a reverse proxy is a deployment concern this module has no way to get
510    /// right on its own; see `axum::extract::OriginalUri` and the `Host`
511    /// header for how a caller typically builds it.
512    ///
513    /// `access_token`, if given, is checked against the proof's `ath` claim
514    /// (RFC 9449 §4.3's binding of a proof to a specific bearer of a
515    /// specific access token). `expected_thumbprint`, if given, is checked
516    /// against the RFC 7638 thumbprint of the proof's own key -- the check
517    /// that makes a copied access token useless to whoever copied it,
518    /// because their proof is signed by a different key with a different
519    /// thumbprint.
520    pub fn verify(
521        &self,
522        proof_jwt: &str,
523        method: &Method,
524        htu: &str,
525        access_token: Option<&str>,
526        expected_thumbprint: Option<&str>,
527    ) -> Result<VerifiedProof, DpopError> {
528        self.verify_at(
529            proof_jwt,
530            method,
531            htu,
532            access_token,
533            expected_thumbprint,
534            OffsetDateTime::now_utc(),
535        )
536    }
537
538    fn verify_at(
539        &self,
540        proof_jwt: &str,
541        method: &Method,
542        htu: &str,
543        access_token: Option<&str>,
544        expected_thumbprint: Option<&str>,
545        now: OffsetDateTime,
546    ) -> Result<VerifiedProof, DpopError> {
547        let mut segments = proof_jwt.split('.');
548        let (Some(header_b64), Some(payload_b64), Some(sig_b64), None) = (
549            segments.next(),
550            segments.next(),
551            segments.next(),
552            segments.next(),
553        ) else {
554            return Err(DpopError::Malformed);
555        };
556
557        // -- header: typ, alg, jwk -------------------------------------
558        // `URL_SAFE_NO_PAD` refuses padding and a final character with
559        // unused bits set, so each segment has one spelling.
560        let header_json = BASE64_URL_SAFE_NO_PAD
561            .decode(header_b64)
562            .map_err(|_| DpopError::InvalidEncoding("header"))?;
563        let header: ProofHeader = serde_json::from_slice(&header_json)
564            .map_err(|_| DpopError::InvalidEncoding("header"))?;
565        if header.typ.as_deref() != Some(JWT_TYP) {
566            return Err(DpopError::WrongTyp(header.typ));
567        }
568        if header.alg != JWT_ALG {
569            return Err(DpopError::UnsupportedAlgorithm(header.alg));
570        }
571        let jwk = header.jwk.ok_or(DpopError::UnsupportedKey)?;
572        let (x, y) = decode_ec_point(&jwk)?;
573
574        // -- signature ----------------------------------------------------
575        let verifying_key = p256_verifying_key(&x, &y)?;
576        let sig_bytes = BASE64_URL_SAFE_NO_PAD
577            .decode(sig_b64)
578            .map_err(|_| DpopError::InvalidEncoding("signature"))?;
579        let sig_bytes: [u8; 64] = sig_bytes.try_into().map_err(|_| DpopError::BadSignature)?;
580        let signature =
581            P256Signature::from_slice(&sig_bytes).map_err(|_| DpopError::BadSignature)?;
582        let signing_input = format!("{header_b64}.{payload_b64}");
583        verifying_key
584            .verify(signing_input.as_bytes(), &signature)
585            .map_err(|_| DpopError::BadSignature)?;
586
587        // -- payload: only trusted once the signature above verified -----
588        let payload_json = BASE64_URL_SAFE_NO_PAD
589            .decode(payload_b64)
590            .map_err(|_| DpopError::InvalidEncoding("payload"))?;
591        let claims: ProofClaims = serde_json::from_slice(&payload_json)
592            .map_err(|_| DpopError::InvalidEncoding("payload"))?;
593        let jti = claims.jti.ok_or(DpopError::MissingClaim("jti"))?;
594        if jti.len() > MAX_JTI_BYTES {
595            return Err(DpopError::JtiTooLong { length: jti.len() });
596        }
597        let htm = claims.htm.ok_or(DpopError::MissingClaim("htm"))?;
598        let claimed_htu = claims.htu.ok_or(DpopError::MissingClaim("htu"))?;
599        let iat_ts = claims.iat.ok_or(DpopError::MissingClaim("iat"))?;
600
601        if !htm.eq_ignore_ascii_case(method.as_str()) {
602            return Err(DpopError::MethodMismatch {
603                proof: htm,
604                expected: method.as_str().to_owned(),
605            });
606        }
607        match (normalize_htu(&claimed_htu), normalize_htu(htu)) {
608            (Some(a), Some(b)) if a == b => {}
609            _ => {
610                return Err(DpopError::UriMismatch {
611                    proof: claimed_htu,
612                    expected: htu.to_owned(),
613                })
614            }
615        }
616
617        let iat = OffsetDateTime::from_unix_timestamp(iat_ts)
618            .map_err(|_| DpopError::InvalidEncoding("payload"))?;
619        if iat < now - self.skew || iat > now + self.skew {
620            return Err(DpopError::ClockSkew {
621                iat,
622                now,
623                window: self.skew,
624            });
625        }
626        // The replay window below is in memory. For the first `skew` after
627        // this verifier started, it cannot speak for a proof minted before
628        // it existed, so such a proof is refused rather than admitted on an
629        // empty cache's say-so. After that window every such proof already
630        // fails the check above.
631        if now < self.started + self.skew && iat < self.started {
632            return Err(DpopError::PredatesStart {
633                iat,
634                started: self.started,
635            });
636        }
637
638        // -- nonce, before replay and `ath`, as the reference provider --
639        if self.require_nonce {
640            self.check_nonce_at(claims.nonce.as_deref(), now)?;
641        }
642
643        // -- replay, over jti, read but not written ------------------------
644        //
645        // An already-recorded `jti` is refused here, before anything else is
646        // spent on this proof. Recording one is [`DpopVerifier::accept`]'s,
647        // called once the credential the proof arrived with has checked out
648        // — see that method for why an entry is not the verifier's to spend
649        // on an unauthenticated caller's say-so.
650        //
651        // Swept once per window rather than on every proof: a full scan per
652        // call makes one caller's cost grow with how many other callers
653        // there are. Amortized this way an entry outlives its window by at
654        // most one more.
655        {
656            let mut replay = self.replay.lock().unwrap_or_else(|p| p.into_inner());
657            replay.sweep_if_due(now, self.skew);
658            if replay.holds(&jti) {
659                return Err(DpopError::Replayed { jti });
660            }
661        }
662
663        // -- the bindings: the key an access token is bound to, and ath ----
664        let thumbprint = jwk_thumbprint(&x, &y);
665        if let Some(expected) = expected_thumbprint {
666            if !constant_time_eq(thumbprint.as_bytes(), expected.as_bytes()) {
667                return Err(DpopError::ThumbprintMismatch);
668            }
669        }
670        if let Some(token) = access_token {
671            let expected_ath = BASE64_URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes()));
672            match &claims.ath {
673                Some(ath) if constant_time_eq(ath.as_bytes(), expected_ath.as_bytes()) => {}
674                _ => return Err(DpopError::AccessTokenHashMismatch),
675            }
676        }
677
678        Ok(VerifiedProof {
679            jti,
680            iat,
681            thumbprint,
682        })
683    }
684
685    /// Records a verified proof's `jti`, so that presenting the same proof
686    /// again is [`DpopError::Replayed`].
687    ///
688    /// Called once the credential the proof arrived with has checked out —
689    /// the access token at a repo write, the authorization code or the
690    /// refresh token at `POST /oauth/token`. Before that, nothing about the
691    /// caller is known: `POST /oauth/token`'s refresh grant is reachable
692    /// with any freshly generated key, so an entry spent at
693    /// [`DpopVerifier::verify`] is an entry an unauthenticated caller spends,
694    /// and the window would fill with proofs naming no credential at all.
695    ///
696    /// [`DpopError::ReplayWindowFull`] rather than an eviction, for
697    /// [`REPLAY_CAPACITY`]'s own reason: dropping an entry that still names
698    /// a live proof is how a full cache becomes a way to get a replay
699    /// accepted.
700    pub fn accept(&self, proof: &VerifiedProof) -> Result<(), DpopError> {
701        self.accept_at(proof, OffsetDateTime::now_utc())
702    }
703
704    fn accept_at(&self, proof: &VerifiedProof, now: OffsetDateTime) -> Result<(), DpopError> {
705        let mut replay = self.replay.lock().unwrap_or_else(|p| p.into_inner());
706        replay
707            .spend(proof.jti.clone(), proof.iat + self.skew, now)
708            .map_err(|refusal| match refusal {
709                Spent::Replayed => DpopError::Replayed {
710                    jti: proof.jti.clone(),
711                },
712                Spent::WindowFull => DpopError::ReplayWindowFull,
713            })
714    }
715
716    /// Accepts a nonce this verifier handed out and still honours. A
717    /// refusal names the nonce every client is being handed, and replaces
718    /// nothing.
719    fn check_nonce_at(
720        &self,
721        presented: Option<&str>,
722        now: OffsetDateTime,
723    ) -> Result<(), DpopError> {
724        let accepted = presented.is_some_and(|presented| {
725            self.nonces
726                .lock()
727                .unwrap_or_else(|p| p.into_inner())
728                .honours(presented, now, self.nonce_ttl)
729        });
730        if accepted {
731            return Ok(());
732        }
733        let fresh = self.current_nonce_at(now);
734        match presented {
735            None => Err(DpopError::NonceRequired { fresh }),
736            Some(_) => Err(DpopError::NonceMismatch { fresh }),
737        }
738    }
739}
740
741/// The RFC 7638 JWK thumbprint of an EC P-256 public key, from its raw
742/// coordinates.
743///
744/// A standalone entry point as well as [`DpopVerifier::verify`]'s internal
745/// step: an authorization server mints a token's `cnf.jkt` from the DPoP
746/// proof presented at the token endpoint, before any resource-server
747/// verifier is in the picture, and needs this without a whole [`DpopVerifier`]
748/// to get it.
749pub fn jwk_thumbprint(x: &Coordinate, y: &Coordinate) -> String {
750    // RFC 7638 §3.2: the JSON object over exactly the required members for
751    // the key type, ordered lexicographically by member name, no
752    // insignificant whitespace. For an EC key that is `crv`, `kty`, `x`,
753    // `y` -- already alphabetical, so no separate sort is needed here.
754    let x = BASE64_URL_SAFE_NO_PAD.encode(x);
755    let y = BASE64_URL_SAFE_NO_PAD.encode(y);
756    let canonical = format!(r#"{{"crv":"{JWK_CRV}","kty":"{JWK_KTY}","x":"{x}","y":"{y}"}}"#);
757    BASE64_URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))
758}
759
760/// Decodes a JWK's `x`/`y` members into raw coordinates, refusing anything
761/// that is not an EC P-256 key with both present and exactly 32 bytes long.
762fn decode_ec_point(jwk: &RawJwk) -> Result<(Coordinate, Coordinate), DpopError> {
763    if jwk.kty != JWK_KTY || jwk.crv.as_deref() != Some(JWK_CRV) {
764        return Err(DpopError::UnsupportedKey);
765    }
766    let x = jwk.x.as_deref().ok_or(DpopError::UnsupportedKey)?;
767    let y = jwk.y.as_deref().ok_or(DpopError::UnsupportedKey)?;
768    let x = BASE64_URL_SAFE_NO_PAD
769        .decode(x)
770        .map_err(|_| DpopError::UnsupportedKey)?;
771    let y = BASE64_URL_SAFE_NO_PAD
772        .decode(y)
773        .map_err(|_| DpopError::UnsupportedKey)?;
774    let x: Coordinate = x.try_into().map_err(|_| DpopError::UnsupportedKey)?;
775    let y: Coordinate = y.try_into().map_err(|_| DpopError::UnsupportedKey)?;
776    Ok((x, y))
777}
778
779/// Builds a verifying key from raw coordinates, the same SEC1-uncompressed
780/// route `didbot_key::secp256r1::VerifyingKey::from_multibase` takes -- see
781/// that module's doc for why a key that arrives from elsewhere is judged
782/// rather than normalized.
783fn p256_verifying_key(x: &Coordinate, y: &Coordinate) -> Result<P256VerifyingKey, DpopError> {
784    let mut sec1 = [0u8; 65];
785    sec1[0] = 0x04;
786    sec1[1..33].copy_from_slice(x);
787    sec1[33..65].copy_from_slice(y);
788    let public = p256::PublicKey::from_sec1_bytes(&sec1).map_err(|_| DpopError::UnsupportedKey)?;
789    Ok(public.into())
790}
791
792/// Strips query and fragment and lowercases scheme and host, so that `htu`
793/// comparison follows RFC 9449 §4.3's "the same server can use different
794/// forms" allowance without demanding byte-identical strings for two URLs
795/// that name the same resource.
796fn normalize_htu(raw: &str) -> Option<String> {
797    let raw = raw.split(['?', '#']).next().unwrap_or(raw);
798    let (scheme, rest) = raw.split_once("://")?;
799    let (authority, path) = match rest.find('/') {
800        Some(idx) => (&rest[..idx], &rest[idx..]),
801        None => (rest, ""),
802    };
803    Some(format!(
804        "{}://{}{}",
805        scheme.to_ascii_lowercase(),
806        authority.to_ascii_lowercase(),
807        path
808    ))
809}
810
811/// A fresh nonce: 18 CSPRNG-drawn bytes, base64url. The same entropy source
812/// `didbot_pds::credential`'s bearer tokens draw from.
813fn random_nonce() -> String {
814    let mut bytes = [0u8; 18];
815    OsRng.fill_bytes(&mut bytes);
816    BASE64_URL_SAFE_NO_PAD.encode(bytes)
817}
818
819// -- the seam every route holds ---------------------------------------------
820//
821// `oauth::token`, `crate::auth` and the OAuth routes hold a `dyn
822// dpop_seam::DpopVerifier`. The adapter verifies a proof against the request
823// and the access token alone, with no thumbprint binding of its own: that
824// binding happens one layer up, once a token's `cnf.jkt` is on hand to check
825// it against (`oauth::token::OAuthTokenStore::validate_access`). A nonce
826// refusal crosses the seam with the nonce to retry with, so each caller can
827// answer it the way RFC 9449 asks of it.
828impl crate::oauth::dpop_seam::DpopVerifier for DpopVerifier {
829    fn verify(
830        &self,
831        proof: &str,
832        http_method: &str,
833        http_uri: &str,
834        access_token: Option<&str>,
835    ) -> Result<crate::oauth::dpop_seam::VerifiedProof, crate::oauth::dpop_seam::DpopError> {
836        let method: Method = http_method.parse().map_err(|_| {
837            crate::oauth::dpop_seam::DpopError::Refused(format!(
838                "`{http_method}` is not a valid HTTP method"
839            ))
840        })?;
841        self.verify(proof, &method, http_uri, access_token, None)
842            .map(|verified| crate::oauth::dpop_seam::VerifiedProof {
843                thumbprint: crate::oauth::dpop_seam::DpopThumbprint(verified.thumbprint),
844                jti: verified.jti,
845                iat: verified.iat,
846            })
847            .map_err(|err| match err.fresh_nonce() {
848                Some(nonce) => crate::oauth::dpop_seam::DpopError::UseNonce {
849                    nonce: nonce.to_owned(),
850                    message: err.to_string(),
851                },
852                None => crate::oauth::dpop_seam::DpopError::Refused(err.to_string()),
853            })
854    }
855
856    fn accept(
857        &self,
858        proof: &crate::oauth::dpop_seam::VerifiedProof,
859    ) -> Result<(), crate::oauth::dpop_seam::DpopError> {
860        self.accept(&VerifiedProof {
861            jti: proof.jti.clone(),
862            iat: proof.iat,
863            thumbprint: proof.thumbprint.0.clone(),
864        })
865        .map_err(|err| crate::oauth::dpop_seam::DpopError::Refused(err.to_string()))
866    }
867
868    fn nonce(&self) -> Option<String> {
869        self.require_nonce.then(|| self.current_nonce())
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use p256::ecdsa::signature::Signer as _;
877    use p256::ecdsa::SigningKey;
878    use serde_json::json;
879
880    /// A proof builder that mirrors `jacquard-oauth-0.12.1`'s
881    /// `dpop::build_dpop_proof` byte-for-byte in its default shape, but takes
882    /// every field explicitly so a test can bend exactly one of them.
883    struct ProofBuilder {
884        key: SigningKey,
885        typ: &'static str,
886        alg: &'static str,
887        htm: String,
888        htu: String,
889        jti: String,
890        iat: i64,
891        nonce: Option<String>,
892        ath: Option<String>,
893    }
894
895    impl ProofBuilder {
896        fn new(key: &SigningKey, htm: &str, htu: &str) -> Self {
897            Self {
898                key: key.clone(),
899                typ: JWT_TYP,
900                alg: JWT_ALG,
901                htm: htm.to_owned(),
902                htu: htu.to_owned(),
903                jti: "test-jti-0".to_owned(),
904                iat: OffsetDateTime::now_utc().unix_timestamp(),
905                nonce: None,
906                ath: None,
907            }
908        }
909
910        fn jti(mut self, jti: &str) -> Self {
911            self.jti = jti.to_owned();
912            self
913        }
914
915        fn iat(mut self, iat: OffsetDateTime) -> Self {
916            self.iat = iat.unix_timestamp();
917            self
918        }
919
920        fn nonce(mut self, nonce: &str) -> Self {
921            self.nonce = Some(nonce.to_owned());
922            self
923        }
924
925        fn ath(mut self, access_token: &str) -> Self {
926            self.ath = Some(BASE64_URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes())));
927            self
928        }
929
930        fn build(&self) -> String {
931            let verifying_key = P256VerifyingKey::from(&self.key);
932            let point = verifying_key.to_encoded_point(false);
933            let x = BASE64_URL_SAFE_NO_PAD.encode(point.x().expect("uncompressed point has x"));
934            let y = BASE64_URL_SAFE_NO_PAD.encode(point.y().expect("uncompressed point has y"));
935            let header = json!({
936                "alg": self.alg,
937                "typ": self.typ,
938                "jwk": {"kty": "EC", "crv": "P-256", "x": x, "y": y},
939            });
940            let mut payload = json!({
941                "jti": self.jti,
942                "iat": self.iat,
943                "htm": self.htm,
944                "htu": self.htu,
945            });
946            if let Some(nonce) = &self.nonce {
947                payload["nonce"] = json!(nonce);
948            }
949            if let Some(ath) = &self.ath {
950                payload["ath"] = json!(ath);
951            }
952            let header_b64 = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
953            let payload_b64 = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
954            let signing_input = format!("{header_b64}.{payload_b64}");
955            let signature: P256Signature = self.key.sign(signing_input.as_bytes());
956            let sig_b64 = BASE64_URL_SAFE_NO_PAD.encode(signature.to_bytes());
957            format!("{signing_input}.{sig_b64}")
958        }
959    }
960
961    fn method() -> Method {
962        Method::POST
963    }
964
965    const HTU: &str = "https://pds.example/xrpc/com.atproto.repo.createRecord";
966
967    /// A verifier with the server's windows and no nonce, for the tests
968    /// about every other check. Each test builds it before it stamps a
969    /// proof: a proof stamped in a second before the verifier started is
970    /// refused as [`DpopError::PredatesStart`].
971    fn verifier() -> DpopVerifier {
972        DpopVerifier::new(Duration::minutes(5), Duration::minutes(1), false)
973    }
974
975    #[test]
976    fn a_well_formed_proof_verifies_and_reports_its_thumbprint() {
977        let key = SigningKey::random(&mut rand_core::OsRng);
978        let v = verifier();
979        let proof = ProofBuilder::new(&key, "POST", HTU).build();
980        let verified = v
981            .verify(&proof, &method(), HTU, None, None)
982            .expect("a proof matching the request verifies");
983        assert_eq!(verified.jti, "test-jti-0");
984        assert!(!verified.thumbprint.is_empty());
985    }
986
987    /// The replay window does not survive a restart, so for the first
988    /// skew window after one a proof minted before the restart is refused
989    /// with its own answer. The same proof shape minted afterwards
990    /// verifies.
991    #[test]
992    fn a_proof_minted_before_this_process_started_is_refused_after_a_restart() {
993        let key = SigningKey::random(&mut rand_core::OsRng);
994        let started = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("a clock");
995        let verifier =
996            DpopVerifier::started_at(Duration::minutes(5), Duration::minutes(1), false, started);
997
998        let captured = ProofBuilder::new(&key, "POST", HTU)
999            .iat(started - Duration::minutes(1))
1000            .build();
1001        let err = verifier
1002            .verify_at(
1003                &captured,
1004                &method(),
1005                HTU,
1006                None,
1007                None,
1008                started + Duration::minutes(1),
1009            )
1010            .expect_err("a proof older than this process is refused");
1011        assert!(
1012            matches!(err, DpopError::PredatesStart { .. }),
1013            "a restart's own refusal, not clock skew: {err:?}"
1014        );
1015
1016        // Minted after the start, inside the same window: accepted.
1017        let fresh = ProofBuilder::new(&key, "POST", HTU)
1018            .iat(started + Duration::seconds(30))
1019            .build();
1020        verifier
1021            .verify_at(
1022                &fresh,
1023                &method(),
1024                HTU,
1025                None,
1026                None,
1027                started + Duration::minutes(1),
1028            )
1029            .expect("a proof minted after the start verifies");
1030
1031        // Past the window, the clock check alone decides.
1032        let later = ProofBuilder::new(&key, "POST", HTU)
1033            .iat(started + Duration::minutes(10))
1034            .build();
1035        verifier
1036            .verify_at(
1037                &later,
1038                &method(),
1039                HTU,
1040                None,
1041                None,
1042                started + Duration::minutes(10),
1043            )
1044            .expect("past the first window nothing is refused for predating the start");
1045    }
1046
1047    #[test]
1048    fn a_tampered_payload_is_refused() {
1049        let key = SigningKey::random(&mut rand_core::OsRng);
1050        let v = verifier();
1051        let proof = ProofBuilder::new(&key, "POST", HTU).build();
1052        let (header, rest) = proof.split_once('.').unwrap();
1053        let (payload, sig) = rest.split_once('.').unwrap();
1054        let mut claims: serde_json::Value =
1055            serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
1056        claims["htm"] = json!("GET");
1057        let tampered_payload = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
1058        let tampered = format!("{header}.{tampered_payload}.{sig}");
1059        let err = v.verify(&tampered, &method(), HTU, None, None).unwrap_err();
1060        assert_eq!(err, DpopError::BadSignature);
1061    }
1062
1063    #[test]
1064    fn a_replayed_jti_is_refused_the_second_time() {
1065        let key = SigningKey::random(&mut rand_core::OsRng);
1066        let v = verifier();
1067        let proof = ProofBuilder::new(&key, "POST", HTU)
1068            .jti("replay-me")
1069            .build();
1070        let accepted = v
1071            .verify(&proof, &method(), HTU, None, None)
1072            .expect("first presentation verifies");
1073        v.accept(&accepted).expect("and is recorded");
1074        let err = v.verify(&proof, &method(), HTU, None, None).unwrap_err();
1075        assert_eq!(
1076            err,
1077            DpopError::Replayed {
1078                jti: "replay-me".to_owned()
1079            }
1080        );
1081    }
1082
1083    /// A `jti` is the caller's string, and the window keeps it. One past
1084    /// [`MAX_JTI_BYTES`] is refused before it could be kept; one at the
1085    /// bound verifies.
1086    #[test]
1087    fn a_jti_past_the_bound_is_refused() {
1088        let key = SigningKey::random(&mut rand_core::OsRng);
1089        let v = verifier();
1090        let past = ProofBuilder::new(&key, "POST", HTU)
1091            .jti(&"j".repeat(MAX_JTI_BYTES + 1))
1092            .build();
1093        assert_eq!(
1094            v.verify(&past, &method(), HTU, None, None).unwrap_err(),
1095            DpopError::JtiTooLong {
1096                length: MAX_JTI_BYTES + 1
1097            }
1098        );
1099        let at = ProofBuilder::new(&key, "POST", HTU)
1100            .jti(&"j".repeat(MAX_JTI_BYTES))
1101            .build();
1102        v.verify(&at, &method(), HTU, None, None)
1103            .expect("a jti at the bound verifies");
1104    }
1105
1106    /// **The entry an unauthenticated caller used to spend.** A proof's
1107    /// `jti` was recorded the moment the proof verified, and every route
1108    /// that takes one can be reached with a freshly generated key and no
1109    /// credential. Verification alone now records nothing: the caller
1110    /// resolves the credential the proof arrived with and calls
1111    /// [`DpopVerifier::accept`], which is the only thing that spends an
1112    /// entry.
1113    #[test]
1114    fn verifying_a_proof_records_nothing_until_it_is_accepted() {
1115        let key = SigningKey::random(&mut rand_core::OsRng);
1116        let v = verifier();
1117        for n in 0..8 {
1118            let proof = ProofBuilder::new(&key, "POST", HTU)
1119                .jti(&format!("unauthenticated-{n}"))
1120                .build();
1121            v.verify(&proof, &method(), HTU, None, None)
1122                .expect("the proof itself is well formed");
1123        }
1124        assert_eq!(
1125            v.replay.lock().unwrap().len(),
1126            0,
1127            "a proof that authenticated nothing cost a replay-window entry"
1128        );
1129    }
1130
1131    /// **The binding this closes.** The seam `auth.rs` and `token.rs` hold
1132    /// used to verify a proof against the request alone, with no access
1133    /// token to check `ath` against — so a proof minted for one token
1134    /// authenticated a request carrying another. RFC 9449 §7.1 requires the
1135    /// check, and the seam now passes the token the request presented.
1136    #[test]
1137    fn the_seam_checks_ath_against_the_token_the_request_presented() {
1138        // Named explicitly: this type has an inherent `verify` too, and the
1139        // seam's is the one every route reaches it through.
1140        use crate::oauth::dpop_seam::DpopVerifier as Seam;
1141        let key = SigningKey::random(&mut rand_core::OsRng);
1142        let v = verifier();
1143
1144        let for_another_token = ProofBuilder::new(&key, "POST", HTU)
1145            .jti("ath-wrong")
1146            .ath("some-other-token")
1147            .build();
1148        let err =
1149            Seam::verify(&v, &for_another_token, "POST", HTU, Some("the-real-token")).unwrap_err();
1150        assert!(
1151            format!("{err}").contains("ath"),
1152            "a proof bound to another token was accepted: {err}"
1153        );
1154
1155        let none_at_all = ProofBuilder::new(&key, "POST", HTU)
1156            .jti("ath-absent")
1157            .build();
1158        assert!(
1159            Seam::verify(&v, &none_at_all, "POST", HTU, Some("the-real-token")).is_err(),
1160            "a proof carrying no `ath` was accepted alongside an access token"
1161        );
1162
1163        let bound = ProofBuilder::new(&key, "POST", HTU)
1164            .jti("ath-right")
1165            .ath("the-real-token")
1166            .build();
1167        assert!(Seam::verify(&v, &bound, "POST", HTU, Some("the-real-token")).is_ok());
1168
1169        // `/oauth/token` has no access token yet, and passes `None`.
1170        let unbound = ProofBuilder::new(&key, "POST", HTU).jti("no-token").build();
1171        assert!(Seam::verify(&v, &unbound, "POST", HTU, None).is_ok());
1172    }
1173
1174    /// **The unbounded window this closes.** Past [`REPLAY_CAPACITY`] a
1175    /// proof is refused rather than admitted unchecked or allowed to evict a
1176    /// live entry — either of which would be a way to get a replay
1177    /// accepted.
1178    #[test]
1179    fn a_full_replay_window_refuses_rather_than_forgetting() {
1180        let key = SigningKey::random(&mut rand_core::OsRng);
1181        let v = verifier();
1182        {
1183            let mut replay = v.replay.lock().unwrap();
1184            let now = OffsetDateTime::now_utc();
1185            let expiry = now + Duration::minutes(5);
1186            for n in 0..REPLAY_CAPACITY {
1187                replay
1188                    .spend(format!("held-{n}"), expiry, now)
1189                    .expect("the window fills one key at a time");
1190            }
1191        }
1192        let proof = ProofBuilder::new(&key, "POST", HTU)
1193            .jti("one-too-many")
1194            .build();
1195        let accepted = v
1196            .verify(&proof, &method(), HTU, None, None)
1197            .expect("the proof itself checks out");
1198        assert_eq!(
1199            v.accept(&accepted).unwrap_err(),
1200            DpopError::ReplayWindowFull
1201        );
1202        // A `jti` already in the window still reads as the replay it is,
1203        // rather than as the window being full.
1204        let held = ProofBuilder::new(&key, "POST", HTU).jti("held-0").build();
1205        assert_eq!(
1206            v.verify(&held, &method(), HTU, None, None).unwrap_err(),
1207            DpopError::Replayed {
1208                jti: "held-0".to_owned()
1209            }
1210        );
1211    }
1212
1213    #[test]
1214    fn a_stale_nonce_is_refused_and_names_a_fresh_one() {
1215        let key = SigningKey::random(&mut rand_core::OsRng);
1216        let v = DpopVerifier::new(Duration::minutes(5), Duration::minutes(1), true);
1217        let proof = ProofBuilder::new(&key, "POST", HTU).build();
1218        let err = v.verify(&proof, &method(), HTU, None, None).unwrap_err();
1219        let DpopError::NonceRequired { fresh } = err else {
1220            panic!("expected NonceRequired, got {err:?}");
1221        };
1222        assert!(!fresh.is_empty());
1223
1224        // Retrying with a nonce this verifier never issued is a mismatch,
1225        // not a second "required" -- and it too names a fresh nonce to
1226        // retry with.
1227        let wrong = ProofBuilder::new(&key, "POST", HTU)
1228            .jti("second-attempt")
1229            .nonce("not-a-real-nonce")
1230            .build();
1231        let err = v.verify(&wrong, &method(), HTU, None, None).unwrap_err();
1232        assert!(matches!(err, DpopError::NonceMismatch { .. }));
1233
1234        // The nonce actually issued is accepted.
1235        let good = ProofBuilder::new(&key, "POST", HTU)
1236            .jti("third-attempt")
1237            .nonce(&fresh)
1238            .build();
1239        v.verify(&good, &method(), HTU, None, None)
1240            .expect("the freshly issued nonce is accepted");
1241    }
1242
1243    #[test]
1244    fn a_wrong_htm_is_refused() {
1245        let key = SigningKey::random(&mut rand_core::OsRng);
1246        let v = verifier();
1247        let proof = ProofBuilder::new(&key, "GET", HTU).build();
1248        let err = v.verify(&proof, &method(), HTU, None, None).unwrap_err();
1249        assert_eq!(
1250            err,
1251            DpopError::MethodMismatch {
1252                proof: "GET".to_owned(),
1253                expected: "POST".to_owned(),
1254            }
1255        );
1256    }
1257
1258    #[test]
1259    fn a_wrong_htu_is_refused() {
1260        let key = SigningKey::random(&mut rand_core::OsRng);
1261        let v = verifier();
1262        let proof =
1263            ProofBuilder::new(&key, "POST", "https://pds.example/xrpc/other.method").build();
1264        let err = v.verify(&proof, &method(), HTU, None, None).unwrap_err();
1265        assert!(matches!(err, DpopError::UriMismatch { .. }));
1266    }
1267
1268    #[test]
1269    fn a_proof_outside_the_clock_window_is_refused_by_name() {
1270        let key = SigningKey::random(&mut rand_core::OsRng);
1271        let v = verifier();
1272        let stale_iat = OffsetDateTime::now_utc() - Duration::minutes(10);
1273        let proof = ProofBuilder::new(&key, "POST", HTU).iat(stale_iat).build();
1274        let err = v.verify(&proof, &method(), HTU, None, None).unwrap_err();
1275        let DpopError::ClockSkew { iat, now, window } = err else {
1276            panic!("expected ClockSkew, got {err:?}");
1277        };
1278        // Both timestamps are on the error, not folded into a generic
1279        // signature failure.
1280        assert_eq!(iat.unix_timestamp(), stale_iat.unix_timestamp());
1281        assert!(now > iat);
1282        assert_eq!(window, Duration::minutes(5));
1283    }
1284
1285    #[test]
1286    fn a_wrong_typ_is_refused() {
1287        let key = SigningKey::random(&mut rand_core::OsRng);
1288        let v = verifier();
1289        let mut builder = ProofBuilder::new(&key, "POST", HTU);
1290        builder.typ = "JWT";
1291        let proof = builder.build();
1292        let err = v.verify(&proof, &method(), HTU, None, None).unwrap_err();
1293        assert_eq!(err, DpopError::WrongTyp(Some("JWT".to_owned())));
1294    }
1295
1296    #[test]
1297    fn an_unsupported_algorithm_is_refused() {
1298        let key = SigningKey::random(&mut rand_core::OsRng);
1299        let v = verifier();
1300        let mut builder = ProofBuilder::new(&key, "POST", HTU);
1301        builder.alg = "none";
1302        let proof = builder.build();
1303        let err = v.verify(&proof, &method(), HTU, None, None).unwrap_err();
1304        assert_eq!(err, DpopError::UnsupportedAlgorithm("none".to_owned()));
1305    }
1306
1307    #[test]
1308    fn thumbprint_binding_refuses_a_different_key() {
1309        let key = SigningKey::random(&mut rand_core::OsRng);
1310        let other_key = SigningKey::random(&mut rand_core::OsRng);
1311        let bound_thumbprint = {
1312            let vk = P256VerifyingKey::from(&other_key);
1313            let point = vk.to_encoded_point(false);
1314            let x: Coordinate = (*point.x().unwrap()).into();
1315            let y: Coordinate = (*point.y().unwrap()).into();
1316            jwk_thumbprint(&x, &y)
1317        };
1318        let v = verifier();
1319        let proof = ProofBuilder::new(&key, "POST", HTU).build();
1320        let err = v
1321            .verify(&proof, &method(), HTU, None, Some(&bound_thumbprint))
1322            .unwrap_err();
1323        assert_eq!(err, DpopError::ThumbprintMismatch);
1324    }
1325
1326    #[test]
1327    fn an_access_token_hash_mismatch_is_refused() {
1328        let key = SigningKey::random(&mut rand_core::OsRng);
1329        let v = verifier();
1330        let proof = ProofBuilder::new(&key, "POST", HTU)
1331            .ath("the-real-token")
1332            .build();
1333        let err = v
1334            .verify(&proof, &method(), HTU, Some("a-different-token"), None)
1335            .unwrap_err();
1336        assert_eq!(err, DpopError::AccessTokenHashMismatch);
1337    }
1338
1339    #[test]
1340    fn a_missing_access_token_hash_is_refused_when_one_is_expected() {
1341        let key = SigningKey::random(&mut rand_core::OsRng);
1342        let v = verifier();
1343        let proof = ProofBuilder::new(&key, "POST", HTU).build();
1344        let err = v
1345            .verify(&proof, &method(), HTU, Some("the-real-token"), None)
1346            .unwrap_err();
1347        assert_eq!(err, DpopError::AccessTokenHashMismatch);
1348    }
1349
1350    #[test]
1351    fn a_malformed_proof_is_refused_before_any_decoding() {
1352        let err = verifier()
1353            .verify("not-a-jwt", &method(), HTU, None, None)
1354            .unwrap_err();
1355        assert_eq!(err, DpopError::Malformed);
1356    }
1357
1358    /// A signature has one spelling. Padded, with an unused bit set in its
1359    /// final character, or in the standard alphabet, it is the same 64 bytes
1360    /// to a lenient decoder and would verify. Each is refused.
1361    #[test]
1362    fn a_signature_spelled_another_way_is_refused() {
1363        let key = SigningKey::random(&mut rand_core::OsRng);
1364        let v = verifier();
1365        // One whose signature holds `-` or `_`, the two characters the
1366        // standard alphabet spells differently.
1367        let proof = (0..32)
1368            .map(|n| {
1369                ProofBuilder::new(&key, "POST", HTU)
1370                    .jti(&format!("spelling-{n}"))
1371                    .build()
1372            })
1373            .find(|proof| {
1374                proof
1375                    .rsplit('.')
1376                    .next()
1377                    .is_some_and(|sig| sig.contains(['-', '_']))
1378            })
1379            .expect("a signature holds `-` or `_` within a few tries");
1380        assert!(v.verify(&proof, &method(), HTU, None, None).is_ok());
1381        let (signed, sig) = proof.rsplit_once('.').expect("three segments");
1382
1383        // 86 characters carry 516 bits for 512, so the final character is
1384        // `A`, `Q`, `g` or `w`, and the letter after it sets an unused bit.
1385        let (head, last) = sig.split_at(sig.len() - 1);
1386        let unused_bit = char::from(last.as_bytes()[0] + 1);
1387        for respelled in [
1388            format!("{sig}=="),
1389            format!("{head}{unused_bit}"),
1390            sig.replace('-', "+").replace('_', "/"),
1391        ] {
1392            assert_eq!(
1393                v.verify(&format!("{signed}.{respelled}"), &method(), HTU, None, None),
1394                Err(DpopError::InvalidEncoding("signature")),
1395                "{respelled}"
1396            );
1397        }
1398    }
1399
1400    /// A nonce is handed out for `nonce_ttl` and replaced the first time one
1401    /// is asked for after that. The replaced nonce still verifies for one
1402    /// more `nonce_ttl`, so a request in flight across the rotation lands,
1403    /// and not after.
1404    #[test]
1405    fn a_replaced_nonce_is_accepted_for_one_more_lifetime() {
1406        let key = SigningKey::random(&mut rand_core::OsRng);
1407        let started = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("a clock");
1408        let ttl = Duration::minutes(1);
1409        let v = DpopVerifier::started_at(Duration::minutes(5), ttl, true, started);
1410        let presented = |nonce: &str, jti: &str, now: OffsetDateTime| {
1411            let proof = ProofBuilder::new(&key, "POST", HTU)
1412                .jti(jti)
1413                .iat(now)
1414                .nonce(nonce)
1415                .build();
1416            v.verify_at(&proof, &method(), HTU, None, None, now)
1417        };
1418
1419        let first = v.current_nonce_at(started);
1420        assert_eq!(v.current_nonce_at(started + Duration::seconds(59)), first);
1421        let second = v.current_nonce_at(started + ttl);
1422        assert_ne!(
1423            second, first,
1424            "a nonce is replaced once its lifetime is over"
1425        );
1426
1427        presented(&first, "in-flight", started + ttl + Duration::seconds(59))
1428            .expect("the replaced nonce still verifies");
1429        assert!(matches!(
1430            presented(&first, "too-late", started + ttl * 2),
1431            Err(DpopError::NonceMismatch { .. })
1432        ));
1433        presented(&second, "its-successor", started + ttl * 2)
1434            .expect("the nonce that replaced it still verifies");
1435    }
1436
1437    /// **The rotation a stranger could force.** A refusal used to mint a
1438    /// fresh nonce, and only two are held, so two wrong nonces from anyone
1439    /// displaced the one every honest client held. A refusal now names the
1440    /// nonce being handed out and replaces nothing.
1441    #[test]
1442    fn a_refused_nonce_moves_nothing() {
1443        let key = SigningKey::random(&mut rand_core::OsRng);
1444        let v = DpopVerifier::new(Duration::minutes(5), Duration::minutes(1), true);
1445        let held = v.current_nonce();
1446        for n in 0..8 {
1447            let wrong = ProofBuilder::new(&key, "POST", HTU)
1448                .jti(&format!("wrong-{n}"))
1449                .nonce("not-a-nonce-this-server-issued")
1450                .build();
1451            assert_eq!(
1452                v.verify(&wrong, &method(), HTU, None, None),
1453                Err(DpopError::NonceMismatch {
1454                    fresh: held.clone()
1455                })
1456            );
1457        }
1458        let honest = ProofBuilder::new(&key, "POST", HTU)
1459            .jti("honest")
1460            .nonce(&held)
1461            .build();
1462        v.verify(&honest, &method(), HTU, None, None)
1463            .expect("the nonce an honest client holds still verifies");
1464    }
1465
1466    /// The seam every route holds carries a nonce refusal with the nonce to
1467    /// retry with, and hands out that same nonce for a response header. A
1468    /// verifier that requires no nonce hands none out.
1469    #[test]
1470    fn the_seam_carries_the_nonce_to_retry_with() {
1471        use crate::oauth::dpop_seam::{DpopError as SeamError, DpopVerifier as Seam};
1472        let key = SigningKey::random(&mut rand_core::OsRng);
1473        let v = DpopVerifier::new(Duration::minutes(5), Duration::minutes(1), true);
1474
1475        let bare = ProofBuilder::new(&key, "POST", HTU).jti("no-nonce").build();
1476        let Err(SeamError::UseNonce { nonce, .. }) = Seam::verify(&v, &bare, "POST", HTU, None)
1477        else {
1478            panic!("a proof with no nonce is a nonce challenge");
1479        };
1480        assert_eq!(Seam::nonce(&v), Some(nonce.clone()));
1481
1482        let retried = ProofBuilder::new(&key, "POST", HTU)
1483            .jti("retried")
1484            .nonce(&nonce)
1485            .build();
1486        assert!(Seam::verify(&v, &retried, "POST", HTU, None).is_ok());
1487
1488        assert_eq!(Seam::nonce(&verifier()), None);
1489    }
1490}