1use 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
84const JWT_TYP: &str = "dpop+jwt";
86
87pub(crate) const JWT_ALG: &str = "ES256";
97
98const JWK_KTY: &str = "EC";
99const JWK_CRV: &str = "P-256";
100
101type Coordinate = [u8; 32];
104
105#[derive(Debug, Deserialize)]
113struct ProofHeader {
114 alg: String,
115 typ: Option<String>,
116 jwk: Option<RawJwk>,
117}
118
119#[derive(Debug, Clone, Deserialize)]
124struct RawJwk {
125 kty: String,
126 crv: Option<String>,
127 x: Option<String>,
128 y: Option<String>,
129}
130
131#[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#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum DpopError {
155 MoreThanOneProof,
157 Malformed,
159 InvalidEncoding(&'static str),
163 MissingClaim(&'static str),
165 JtiTooLong {
168 length: usize,
170 },
171 WrongTyp(Option<String>),
173 UnsupportedAlgorithm(String),
175 UnsupportedKey,
178 BadSignature,
180 MethodMismatch {
182 proof: String,
184 expected: String,
186 },
187 UriMismatch {
190 proof: String,
192 expected: String,
194 },
195 ClockSkew {
197 iat: OffsetDateTime,
199 now: OffsetDateTime,
201 window: Duration,
203 },
204 PredatesStart {
210 iat: OffsetDateTime,
212 started: OffsetDateTime,
214 },
215 Replayed {
218 jti: String,
220 },
221 ReplayWindowFull,
226 ThumbprintMismatch,
229 AccessTokenHashMismatch,
232 NonceRequired {
236 fresh: String,
238 },
239 NonceMismatch {
242 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 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
355pub const DPOP_NONCE: HeaderName = HeaderName::from_static("dpop-nonce");
358
359pub const DPOP: HeaderName = HeaderName::from_static("dpop");
361
362pub 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#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct VerifiedProof {
380 pub jti: String,
382 pub iat: OffsetDateTime,
384 pub thumbprint: String,
388}
389
390pub struct DpopVerifier {
400 skew: Duration,
406 nonce_ttl: Duration,
409 require_nonce: bool,
412 started: OffsetDateTime,
425 replay: Mutex<SpentKeys>,
433 nonces: Mutex<NonceState>,
434}
435
436#[derive(Default)]
440struct NonceState {
441 current: Option<(String, OffsetDateTime)>,
442 previous: Option<(String, OffsetDateTime)>,
443}
444
445impl NonceState {
446 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 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 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 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 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 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 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 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 if now < self.started + self.skew && iat < self.started {
632 return Err(DpopError::PredatesStart {
633 iat,
634 started: self.started,
635 });
636 }
637
638 if self.require_nonce {
640 self.check_nonce_at(claims.nonce.as_deref(), now)?;
641 }
642
643 {
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 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 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 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
741pub fn jwk_thumbprint(x: &Coordinate, y: &Coordinate) -> String {
750 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
760fn 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
779fn 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
792fn 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
811fn 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
819impl 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 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 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 #[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 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 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 #[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 #[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 #[test]
1137 fn the_seam_checks_ath_against_the_token_the_request_presented() {
1138 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 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 #[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 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 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 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 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 #[test]
1362 fn a_signature_spelled_another_way_is_refused() {
1363 let key = SigningKey::random(&mut rand_core::OsRng);
1364 let v = verifier();
1365 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 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 #[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 #[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 #[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}