1use crate::uri::{normalize_htu, normalize_method};
2use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
3use base64::Engine;
4use serde::Deserialize;
5use sha2::{Digest, Sha256};
6use subtle::ConstantTimeEq;
7use time::OffsetDateTime;
8
9use crate::jwk::{thumbprint_ec_p256, verifying_key_from_p256_xy};
10use crate::replay::{ReplayContext, ReplayStore};
11use crate::DpopError;
12use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
13
14#[derive(Deserialize)]
15struct DpopHeader {
16 typ: String,
17 alg: String,
18 jwk: Jwk,
19}
20#[derive(Deserialize)]
21struct Jwk {
22 kty: String,
23 crv: String,
24 x: String,
25 y: String,
26}
27
28#[derive(Clone, Debug)]
29pub enum NonceMode {
30 Disabled,
31 RequireEqual {
33 expected_nonce: String, },
35 Hmac {
37 secret: std::sync::Arc<[u8]>, max_age_secs: i64, bind_htu_htm: bool,
40 bind_jkt: bool,
41 },
42}
43
44#[derive(Debug, Clone)]
45pub struct VerifyOptions {
46 pub max_age_secs: i64,
47 pub future_skew_secs: i64,
48 pub nonce_mode: NonceMode,
49}
50impl Default for VerifyOptions {
51 fn default() -> Self {
52 Self {
53 max_age_secs: 300,
54 future_skew_secs: 5,
55 nonce_mode: NonceMode::Disabled,
56 }
57 }
58}
59
60#[derive(Debug)]
61pub struct VerifiedDpop {
62 pub jkt: String,
63 pub jti: String,
64 pub iat: i64,
65}
66
67pub async fn verify_proof<S: ReplayStore + ?Sized>(
70 store: &mut S,
71 client_id: Option<&str>,
72 dpop_compact_jws: &str,
73 expected_htu: &str,
74 expected_htm: &str,
75 maybe_access_token: Option<&str>,
76 opts: VerifyOptions,
77) -> Result<VerifiedDpop, DpopError> {
78 let mut it = dpop_compact_jws.split('.');
79 let (h_b64, p_b64, s_b64) = match (it.next(), it.next(), it.next()) {
80 (Some(h), Some(p), Some(s)) if it.next().is_none() => (h, p, s),
81 _ => return Err(DpopError::MalformedJws),
82 };
83
84 let hdr: DpopHeader = {
86 let bytes = B64.decode(h_b64).map_err(|_| DpopError::MalformedJws)?;
87 let val: serde_json::Value =
88 serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?;
89 if val.get("jwk").and_then(|j| j.get("d")).is_some() {
91 return Err(DpopError::BadJwk("jwk must not include 'd'"));
92 }
93 serde_json::from_value(val).map_err(|_| DpopError::MalformedJws)?
94 };
95
96 if hdr.typ != "dpop+jwt" {
97 return Err(DpopError::MalformedJws);
98 }
99 match hdr.alg.as_str() {
102 "ES256" => { }
103 "none" => return Err(DpopError::InvalidAlg("none".into())),
105 a if a.starts_with("HS") => return Err(DpopError::InvalidAlg(a.into())),
106 other => return Err(DpopError::UnsupportedAlg(other.into())),
107 }
108 if hdr.jwk.kty != "EC" || hdr.jwk.crv != "P-256" {
109 return Err(DpopError::BadJwk("expect EC P-256"));
110 }
111
112 let vk: VerifyingKey = verifying_key_from_p256_xy(&hdr.jwk.x, &hdr.jwk.y)?;
113
114 let signing_input = {
116 let mut s = String::with_capacity(h_b64.len() + 1 + p_b64.len());
117 s.push_str(h_b64);
118 s.push('.');
119 s.push_str(p_b64);
120 s
121 };
122
123 let sig_bytes = B64.decode(s_b64).map_err(|_| DpopError::InvalidSignature)?;
124 if sig_bytes.len() != 64 {
126 return Err(DpopError::InvalidSignature);
127 }
128 let sig = Signature::from_slice(&sig_bytes).map_err(|_| DpopError::InvalidSignature)?;
129 vk.verify(signing_input.as_bytes(), &sig)
130 .map_err(|_| DpopError::InvalidSignature)?;
131
132 let claims: serde_json::Value = {
133 let bytes = B64.decode(p_b64).map_err(|_| DpopError::MalformedJws)?;
134 serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?
135 };
136
137 let jti = claims
138 .get("jti")
139 .and_then(|v| v.as_str())
140 .ok_or(DpopError::MissingClaim("jti"))?;
141 if jti.len() > 512 {
142 return Err(DpopError::JtiTooLong);
143 }
144 let iat = claims
145 .get("iat")
146 .and_then(|v| v.as_i64())
147 .ok_or(DpopError::MissingClaim("iat"))?;
148 let htm = claims
149 .get("htm")
150 .and_then(|v| v.as_str())
151 .ok_or(DpopError::MissingClaim("htm"))?;
152 let htu = claims
153 .get("htu")
154 .and_then(|v| v.as_str())
155 .ok_or(DpopError::MissingClaim("htu"))?;
156
157 let want_htm = normalize_method(expected_htm)?; let got_htm = normalize_method(htm)?; if got_htm != want_htm {
161 return Err(DpopError::HtmMismatch);
162 }
163
164 let want_htu = normalize_htu(expected_htu)?; let got_htu = normalize_htu(htu)?;
166 if got_htu != want_htu {
167 return Err(DpopError::HtuMismatch);
168 }
169
170 if let Some(at) = maybe_access_token {
172 let want = Sha256::digest(at.as_bytes());
174 let got_b64 = claims
176 .get("ath")
177 .and_then(|v| v.as_str())
178 .ok_or(DpopError::MissingAth)?;
179 let got = B64
180 .decode(got_b64.as_bytes())
181 .map_err(|_| DpopError::AthMalformed)?;
182 if got.len() != want.len() || !bool::from(got.ct_eq(&want[..])) {
184 return Err(DpopError::AthMismatch);
185 }
186 }
187
188 let now = OffsetDateTime::now_utc().unix_timestamp();
190 if iat > now + opts.future_skew_secs {
191 return Err(DpopError::FutureSkew);
192 }
193 if now - iat > opts.max_age_secs {
194 return Err(DpopError::Stale);
195 }
196
197 let mut hasher = Sha256::new();
199 hasher.update(jti.as_bytes());
200 let mut jti_hash = [0u8; 32];
201 jti_hash.copy_from_slice(&hasher.finalize());
202
203 let jkt = thumbprint_ec_p256(&hdr.jwk.x, &hdr.jwk.y)?;
204
205 let nonce_claim = claims.get("nonce").and_then(|v| v.as_str());
206
207 match &opts.nonce_mode {
208 NonceMode::Disabled => { }
209 NonceMode::RequireEqual { expected_nonce } => {
210 let n = nonce_claim.ok_or(DpopError::MissingNonce)?;
211 if n != expected_nonce {
212 let fresh = expected_nonce.to_string(); return Err(DpopError::UseDpopNonce { nonce: fresh });
214 }
215 }
216 NonceMode::Hmac {
217 secret,
218 max_age_secs,
219 bind_htu_htm,
220 bind_jkt,
221 } => {
222 let n = match nonce_claim {
223 Some(s) => s,
224 None => {
225 let now = time::OffsetDateTime::now_utc().unix_timestamp();
227 let ctx = crate::nonce::NonceCtx {
228 htu: if *bind_htu_htm {
229 Some(want_htu.as_str())
230 } else {
231 None
232 },
233 htm: if *bind_htu_htm {
234 Some(want_htm.as_str())
235 } else {
236 None
237 },
238 jkt: if *bind_jkt { Some(jkt.as_str()) } else { None },
239 };
240 let fresh = crate::nonce::issue_nonce(secret, now, &ctx);
241 return Err(DpopError::UseDpopNonce { nonce: fresh });
242 }
243 };
244 let now = time::OffsetDateTime::now_utc().unix_timestamp();
245 let ctx = crate::nonce::NonceCtx {
246 htu: if *bind_htu_htm {
247 Some(want_htu.as_str())
248 } else {
249 None
250 },
251 htm: if *bind_htu_htm {
252 Some(want_htm.as_str())
253 } else {
254 None
255 },
256 jkt: if *bind_jkt { Some(jkt.as_str()) } else { None },
257 };
258 if let Err(_) = crate::nonce::verify_nonce(secret, n, now, *max_age_secs, &ctx) {
259 let fresh = crate::nonce::issue_nonce(secret, now, &ctx);
261 return Err(DpopError::UseDpopNonce { nonce: fresh });
262 }
263 }
264 }
265
266 let ok = store
267 .insert_once(
268 jti_hash,
269 ReplayContext {
270 client_id,
271 jkt: Some(&jkt),
272 htm: Some(htm),
273 htu: Some(htu),
274 iat,
275 },
276 )
277 .await?;
278 if !ok {
279 return Err(DpopError::Replay);
280 }
281
282 Ok(VerifiedDpop {
283 jkt,
284 jti: jti.to_string(),
285 iat,
286 })
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::jwk::thumbprint_ec_p256;
293 use crate::nonce::issue_nonce;
294 use p256::ecdsa::{signature::Signer, Signature, SigningKey};
295 use rand_core::OsRng;
296 use std::sync::Arc;
297
298 fn gen_es256_key() -> (SigningKey, String, String) {
301 let sk = SigningKey::random(&mut OsRng);
302 let vk = VerifyingKey::from(&sk);
303 let ep = vk.to_encoded_point(false);
304 let x = B64.encode(ep.x().unwrap());
305 let y = B64.encode(ep.y().unwrap());
306 (sk, x, y)
307 }
308
309 fn make_jws(
310 sk: &SigningKey,
311 header_val: serde_json::Value,
312 claims_val: serde_json::Value,
313 ) -> String {
314 let h = serde_json::to_vec(&header_val).unwrap();
315 let p = serde_json::to_vec(&claims_val).unwrap();
316 let h_b64 = B64.encode(h);
317 let p_b64 = B64.encode(p);
318 let signing_input = format!("{h_b64}.{p_b64}");
319 let sig: Signature = sk.sign(signing_input.as_bytes());
320 let s_b64 = B64.encode(sig.to_bytes());
321 format!("{h_b64}.{p_b64}.{s_b64}")
322 }
323
324 #[derive(Default)]
325 struct MemoryStore(std::collections::HashSet<[u8; 32]>);
326
327 #[async_trait::async_trait]
328 impl ReplayStore for MemoryStore {
329 async fn insert_once(
330 &mut self,
331 jti_hash: [u8; 32],
332 _ctx: ReplayContext<'_>,
333 ) -> Result<bool, DpopError> {
334 Ok(self.0.insert(jti_hash))
335 }
336 }
337 #[test]
339 fn thumbprint_has_expected_length_and_no_padding() {
340 let x = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
342 let y = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
343 let t1 = thumbprint_ec_p256(x, y).expect("thumbprint");
344 let t2 = thumbprint_ec_p256(x, y).expect("thumbprint");
345 assert_eq!(t1, t2);
347 assert_eq!(t1.len(), 43);
348 assert!(!t1.contains('='));
349 }
350
351 #[test]
352 fn decoding_key_rejects_wrong_sizes() {
353 let bad_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 31]);
355 let good_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
356 let res = crate::jwk::verifying_key_from_p256_xy(&bad_x, &good_y);
357 assert!(res.is_err(), "expected error for bad y");
358
359 let good_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
361 let bad_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 33]);
362 let res = crate::jwk::verifying_key_from_p256_xy(&good_x, &bad_y);
363 assert!(res.is_err(), "expected error for bad y");
364 }
365
366 #[tokio::test]
367 async fn replay_store_trait_basic() {
368 use async_trait::async_trait;
369 use std::collections::HashSet;
370
371 struct MemoryStore(HashSet<[u8; 32]>);
372
373 #[async_trait]
374 impl ReplayStore for MemoryStore {
375 async fn insert_once(
376 &mut self,
377 jti_hash: [u8; 32],
378 _ctx: ReplayContext<'_>,
379 ) -> Result<bool, DpopError> {
380 Ok(self.0.insert(jti_hash))
381 }
382 }
383
384 let mut s = MemoryStore(HashSet::new());
385 let first = s
386 .insert_once(
387 [42u8; 32],
388 ReplayContext {
389 client_id: None,
390 jkt: Some("j"),
391 htm: Some("POST"),
392 htu: Some("https://ex"),
393 iat: 0,
394 },
395 )
396 .await
397 .unwrap();
398 let second = s
399 .insert_once(
400 [42u8; 32],
401 ReplayContext {
402 client_id: None,
403 jkt: Some("j"),
404 htm: Some("POST"),
405 htu: Some("https://ex"),
406 iat: 0,
407 },
408 )
409 .await
410 .unwrap();
411 assert!(first);
412 assert!(!second); }
414 #[tokio::test]
415 async fn verify_valid_es256_proof() {
416 let (sk, x, y) = gen_es256_key();
417 let now = OffsetDateTime::now_utc().unix_timestamp();
418 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
419 let p = serde_json::json!({"jti":"j1","iat":now,"htm":"GET","htu":"https://api.example.com/resource"});
420 let jws = make_jws(&sk, h, p);
421
422 let mut store = MemoryStore::default();
423 let res = verify_proof(
424 &mut store,
425 None,
426 &jws,
427 "https://api.example.com/resource",
428 "GET",
429 None,
430 VerifyOptions::default(),
431 )
432 .await;
433 assert!(res.is_ok(), "{res:?}");
434 }
435
436 #[tokio::test]
437 async fn client_id_is_forwarded_to_replay_store() {
438 struct CaptureStore {
439 captured_client: Option<String>,
440 }
441
442 #[async_trait::async_trait]
443 impl ReplayStore for CaptureStore {
444 async fn insert_once(
445 &mut self,
446 _jti_hash: [u8; 32],
447 ctx: ReplayContext<'_>,
448 ) -> Result<bool, DpopError> {
449 self.captured_client = ctx.client_id.map(str::to_string);
450 Ok(true)
451 }
452 }
453
454 let (sk, x, y) = gen_es256_key();
455 let now = OffsetDateTime::now_utc().unix_timestamp();
456 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
457 let p = serde_json::json!({"jti":"client-test","iat":now,"htm":"GET","htu":"https://client.example/token"});
458 let jws = make_jws(&sk, h, p);
459
460 let mut store = CaptureStore {
461 captured_client: None,
462 };
463 verify_proof(
464 &mut store,
465 Some("client-123"),
466 &jws,
467 "https://client.example/token",
468 "GET",
469 None,
470 VerifyOptions::default(),
471 )
472 .await
473 .expect("verification succeeds");
474 assert_eq!(store.captured_client.as_deref(), Some("client-123"));
475 }
476
477 #[tokio::test]
478 async fn method_normalization_allows_lowercase_claim() {
479 let (sk, x, y) = gen_es256_key();
480 let now = OffsetDateTime::now_utc().unix_timestamp();
481 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
482 let p = serde_json::json!({"jti":"j2","iat":now,"htm":"get","htu":"https://ex.com/a"});
483 let jws = make_jws(&sk, h, p);
484
485 let mut store = MemoryStore::default();
486 assert!(verify_proof(
487 &mut store,
488 None,
489 &jws,
490 "https://ex.com/a",
491 "GET",
492 None,
493 VerifyOptions::default()
494 )
495 .await
496 .is_ok());
497 }
498
499 #[tokio::test]
500 async fn htu_normalizes_dot_segments_and_default_ports_and_strips_qf() {
501 let (sk, x, y) = gen_es256_key();
502 let now = OffsetDateTime::now_utc().unix_timestamp();
503 let claim_htu = "https://EX.COM:443/a/../b?q=1#frag";
505 let expect_htu = "https://ex.com/b";
506 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
507 let p = serde_json::json!({"jti":"j3","iat":now,"htm":"GET","htu":claim_htu});
508 let jws = make_jws(&sk, h, p);
509
510 let mut store = MemoryStore::default();
511 assert!(verify_proof(
512 &mut store,
513 None,
514 &jws,
515 expect_htu,
516 "GET",
517 None,
518 VerifyOptions::default()
519 )
520 .await
521 .is_ok());
522 }
523
524 #[tokio::test]
525 async fn htu_path_case_mismatch_fails() {
526 let (sk, x, y) = gen_es256_key();
527 let now = OffsetDateTime::now_utc().unix_timestamp();
528 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
529 let p = serde_json::json!({"jti":"j4","iat":now,"htm":"GET","htu":"https://ex.com/API"});
530 let jws = make_jws(&sk, h, p);
531
532 let mut store = MemoryStore::default();
533 let err = verify_proof(
534 &mut store,
535 None,
536 &jws,
537 "https://ex.com/api",
538 "GET",
539 None,
540 VerifyOptions::default(),
541 )
542 .await
543 .unwrap_err();
544 matches!(err, DpopError::HtuMismatch);
545 }
546
547 #[tokio::test]
548 async fn alg_none_rejected() {
549 let (sk, x, y) = gen_es256_key();
550 let now = OffsetDateTime::now_utc().unix_timestamp();
551 let h = serde_json::json!({"typ":"dpop+jwt","alg":"none","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
553 let p = serde_json::json!({"jti":"j5","iat":now,"htm":"GET","htu":"https://ex.com/a"});
554 let jws = make_jws(&sk, h, p);
555
556 let mut store = MemoryStore::default();
557 let err = verify_proof(
558 &mut store,
559 None,
560 &jws,
561 "https://ex.com/a",
562 "GET",
563 None,
564 VerifyOptions::default(),
565 )
566 .await
567 .unwrap_err();
568 matches!(err, DpopError::InvalidAlg(_));
569 }
570
571 #[tokio::test]
572 async fn alg_hs256_rejected() {
573 let (sk, x, y) = gen_es256_key();
574 let now = OffsetDateTime::now_utc().unix_timestamp();
575 let h = serde_json::json!({"typ":"dpop+jwt","alg":"HS256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
576 let p = serde_json::json!({"jti":"j6","iat":now,"htm":"GET","htu":"https://ex.com/a"});
577 let jws = make_jws(&sk, h, p);
578
579 let mut store = MemoryStore::default();
580 let err = verify_proof(
581 &mut store,
582 None,
583 &jws,
584 "https://ex.com/a",
585 "GET",
586 None,
587 VerifyOptions::default(),
588 )
589 .await
590 .unwrap_err();
591 matches!(err, DpopError::InvalidAlg(_));
592 }
593
594 #[tokio::test]
595 async fn jwk_with_private_d_rejected() {
596 let (sk, x, y) = gen_es256_key();
597 let now = OffsetDateTime::now_utc().unix_timestamp();
598 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y,"d":"AAAA"}});
600 let p = serde_json::json!({"jti":"j7","iat":now,"htm":"GET","htu":"https://ex.com/a"});
601 let jws = make_jws(&sk, h, p);
602
603 let mut store = MemoryStore::default();
604 let err = verify_proof(
605 &mut store,
606 None,
607 &jws,
608 "https://ex.com/a",
609 "GET",
610 None,
611 VerifyOptions::default(),
612 )
613 .await
614 .unwrap_err();
615 matches!(err, DpopError::BadJwk(_));
616 }
617
618 #[tokio::test]
619 async fn ath_binding_ok_and_mismatch_and_padded_rejected() {
620 let (sk, x, y) = gen_es256_key();
621 let now = OffsetDateTime::now_utc().unix_timestamp();
622 let at = "access.token.string";
623 let ath = B64.encode(Sha256::digest(at.as_bytes()));
624 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
625
626 let p_ok = serde_json::json!({"jti":"j8","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
628 let jws_ok = make_jws(&sk, h.clone(), p_ok);
629 let mut store = MemoryStore::default();
630 assert!(verify_proof(
631 &mut store,
632 None,
633 &jws_ok,
634 "https://ex.com/a",
635 "GET",
636 Some(at),
637 VerifyOptions::default()
638 )
639 .await
640 .is_ok());
641
642 let p_bad = serde_json::json!({"jti":"j9","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
644 let jws_bad = make_jws(&sk, h.clone(), p_bad);
645 let mut store2 = MemoryStore::default();
646 let err = verify_proof(
647 &mut store2,
648 None,
649 &jws_bad,
650 "https://ex.com/a",
651 "GET",
652 Some("different.token"),
653 VerifyOptions::default(),
654 )
655 .await
656 .unwrap_err();
657 matches!(err, DpopError::AthMismatch);
658
659 let ath_padded = format!("{ath}==");
661 let p_pad = serde_json::json!({"jti":"j10","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath_padded});
662 let jws_pad = make_jws(&sk, h.clone(), p_pad);
663 let mut store3 = MemoryStore::default();
664 let err = verify_proof(
665 &mut store3,
666 None,
667 &jws_pad,
668 "https://ex.com/a",
669 "GET",
670 Some(at),
671 VerifyOptions::default(),
672 )
673 .await
674 .unwrap_err();
675 matches!(err, DpopError::AthMalformed);
676 }
677
678 #[tokio::test]
679 async fn freshness_future_skew_and_stale() {
680 let (sk, x, y) = gen_es256_key();
681 let now = OffsetDateTime::now_utc().unix_timestamp();
682 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
683
684 let p_future =
686 serde_json::json!({"jti":"jf","iat":now + 6,"htm":"GET","htu":"https://ex.com/a"});
687 let jws_future = make_jws(&sk, h.clone(), p_future);
688 let mut store1 = MemoryStore::default();
689 let opts = VerifyOptions {
690 max_age_secs: 300,
691 future_skew_secs: 5,
692 nonce_mode: NonceMode::Disabled,
693 };
694 let err = verify_proof(
695 &mut store1,
696 None,
697 &jws_future,
698 "https://ex.com/a",
699 "GET",
700 None,
701 opts,
702 )
703 .await
704 .unwrap_err();
705 matches!(err, DpopError::FutureSkew);
706
707 let p_stale =
709 serde_json::json!({"jti":"js","iat":now - 301,"htm":"GET","htu":"https://ex.com/a"});
710 let jws_stale = make_jws(&sk, h.clone(), p_stale);
711 let mut store2 = MemoryStore::default();
712 let opts = VerifyOptions {
713 max_age_secs: 300,
714 future_skew_secs: 5,
715 nonce_mode: NonceMode::Disabled,
716 };
717 let err = verify_proof(
718 &mut store2,
719 None,
720 &jws_stale,
721 "https://ex.com/a",
722 "GET",
723 None,
724 opts,
725 )
726 .await
727 .unwrap_err();
728 matches!(err, DpopError::Stale);
729 }
730
731 #[tokio::test]
732 async fn replay_same_jti_is_rejected() {
733 let (sk, x, y) = gen_es256_key();
734 let now = OffsetDateTime::now_utc().unix_timestamp();
735 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
736 let p = serde_json::json!({"jti":"jr","iat":now,"htm":"GET","htu":"https://ex.com/a"});
737 let jws = make_jws(&sk, h, p);
738
739 let mut store = MemoryStore::default();
740 let ok1 = verify_proof(
741 &mut store,
742 None,
743 &jws,
744 "https://ex.com/a",
745 "GET",
746 None,
747 VerifyOptions::default(),
748 )
749 .await;
750 assert!(ok1.is_ok());
751 let err = verify_proof(
752 &mut store,
753 None,
754 &jws,
755 "https://ex.com/a",
756 "GET",
757 None,
758 VerifyOptions::default(),
759 )
760 .await
761 .unwrap_err();
762 matches!(err, DpopError::Replay);
763 }
764
765 #[tokio::test]
766 async fn signature_tamper_detected() {
767 let (sk, x, y) = gen_es256_key();
768 let now = OffsetDateTime::now_utc().unix_timestamp();
769 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
770 let p = serde_json::json!({"jti":"jt","iat":now,"htm":"GET","htu":"https://ex.com/a"});
771 let mut jws = make_jws(&sk, h, p);
772
773 let bytes = unsafe { jws.as_bytes_mut() }; let mut dot_count = 0usize;
777 for i in 0..bytes.len() {
778 if bytes[i] == b'.' {
779 dot_count += 1;
780 if dot_count == 2 && i > 10 {
781 bytes[i - 5] ^= 0x01; break;
783 }
784 }
785 }
786
787 let mut store = MemoryStore::default();
788 let err = verify_proof(
789 &mut store,
790 None,
791 &jws,
792 "https://ex.com/a",
793 "GET",
794 None,
795 VerifyOptions::default(),
796 )
797 .await
798 .unwrap_err();
799 matches!(err, DpopError::InvalidSignature);
800 }
801
802 #[tokio::test]
803 async fn method_mismatch_rejected() {
804 let (sk, x, y) = gen_es256_key();
805 let now = OffsetDateTime::now_utc().unix_timestamp();
806 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
807 let p = serde_json::json!({"jti":"jm","iat":now,"htm":"POST","htu":"https://ex.com/a"});
808 let jws = make_jws(&sk, h, p);
809
810 let mut store = MemoryStore::default();
811 let err = verify_proof(
812 &mut store,
813 None,
814 &jws,
815 "https://ex.com/a",
816 "GET",
817 None,
818 VerifyOptions::default(),
819 )
820 .await
821 .unwrap_err();
822 matches!(err, DpopError::HtmMismatch);
823 }
824
825 #[test]
826 fn normalize_helpers_examples() {
827 assert_eq!(
829 normalize_htu("https://EX.com:443/a/./b/../c?x=1#frag").unwrap(),
830 "https://ex.com/a/c"
831 );
832 assert_eq!(normalize_method("get").unwrap(), "GET");
833 assert!(normalize_method("CUSTOM").is_err());
834 }
835
836 #[tokio::test]
837 async fn jti_too_long_rejected() {
838 let (sk, x, y) = gen_es256_key();
839 let now = OffsetDateTime::now_utc().unix_timestamp();
840 let too_long = "x".repeat(513);
841 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
842 let p = serde_json::json!({"jti":too_long,"iat":now,"htm":"GET","htu":"https://ex.com/a"});
843 let jws = make_jws(&sk, h, p);
844
845 let mut store = MemoryStore::default();
846 let err = verify_proof(
847 &mut store,
848 None,
849 &jws,
850 "https://ex.com/a",
851 "GET",
852 None,
853 VerifyOptions::default(),
854 )
855 .await
856 .unwrap_err();
857 matches!(err, DpopError::JtiTooLong);
858 }
859 #[tokio::test]
862 async fn nonce_require_equal_ok() {
863 let (sk, x, y) = gen_es256_key();
864 let now = OffsetDateTime::now_utc().unix_timestamp();
865 let expected_htu = "https://ex.com/a";
866 let expected_htm = "GET";
867
868 let expected_nonce = "nonce-123";
869 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
870 let p = serde_json::json!({
871 "jti":"n-reqeq-ok",
872 "iat":now,
873 "htm":expected_htm,
874 "htu":expected_htu,
875 "nonce": expected_nonce
876 });
877 let jws = make_jws(&sk, h, p);
878
879 let mut store = MemoryStore::default();
880 let opts = VerifyOptions {
881 max_age_secs: 300,
882 future_skew_secs: 5,
883 nonce_mode: NonceMode::RequireEqual {
884 expected_nonce: expected_nonce.to_string(),
885 },
886 };
887 assert!(verify_proof(
888 &mut store,
889 None,
890 &jws,
891 expected_htu,
892 expected_htm,
893 None,
894 opts
895 )
896 .await
897 .is_ok());
898 }
899
900 #[tokio::test]
901 async fn nonce_require_equal_missing_claim() {
902 let (sk, x, y) = gen_es256_key();
903 let now = OffsetDateTime::now_utc().unix_timestamp();
904 let expected_htu = "https://ex.com/a";
905 let expected_htm = "GET";
906
907 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
908 let p = serde_json::json!({
909 "jti":"n-reqeq-miss",
910 "iat":now,
911 "htm":expected_htm,
912 "htu":expected_htu
913 });
914 let jws = make_jws(&sk, h, p);
915
916 let mut store = MemoryStore::default();
917 let opts = VerifyOptions {
918 max_age_secs: 300,
919 future_skew_secs: 5,
920 nonce_mode: NonceMode::RequireEqual {
921 expected_nonce: "x".into(),
922 },
923 };
924 let err = verify_proof(
925 &mut store,
926 None,
927 &jws,
928 expected_htu,
929 expected_htm,
930 None,
931 opts,
932 )
933 .await
934 .unwrap_err();
935 matches!(err, DpopError::MissingNonce);
936 }
937
938 #[tokio::test]
939 async fn nonce_require_equal_mismatch_yields_usedpopnonce() {
940 let (sk, x, y) = gen_es256_key();
941 let now = OffsetDateTime::now_utc().unix_timestamp();
942 let expected_htu = "https://ex.com/a";
943 let expected_htm = "GET";
944
945 let claim_nonce = "client-value";
946 let expected_nonce = "server-expected";
947 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
948 let p = serde_json::json!({
949 "jti":"n-reqeq-mis",
950 "iat":now,
951 "htm":expected_htm,
952 "htu":expected_htu,
953 "nonce": claim_nonce
954 });
955 let jws = make_jws(&sk, h, p);
956
957 let mut store = MemoryStore::default();
958 let opts = VerifyOptions {
959 max_age_secs: 300,
960 future_skew_secs: 5,
961 nonce_mode: NonceMode::RequireEqual {
962 expected_nonce: expected_nonce.into(),
963 },
964 };
965 let err = verify_proof(
966 &mut store,
967 None,
968 &jws,
969 expected_htu,
970 expected_htm,
971 None,
972 opts,
973 )
974 .await
975 .unwrap_err();
976 if let DpopError::UseDpopNonce { nonce } = err {
978 assert_eq!(nonce, expected_nonce);
979 } else {
980 panic!("expected UseDpopNonce, got {err:?}");
981 }
982 }
983
984 #[tokio::test]
987 async fn nonce_hmac_ok_bound_all() {
988 let (sk, x, y) = gen_es256_key();
989 let now = OffsetDateTime::now_utc().unix_timestamp();
990 let expected_htu = "https://ex.com/a";
991 let expected_htm = "GET";
992
993 let jkt = thumbprint_ec_p256(&x, &y).unwrap();
995
996 let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
997 let ctx = crate::nonce::NonceCtx {
998 htu: Some(expected_htu),
999 htm: Some(expected_htm),
1000 jkt: Some(&jkt),
1001 };
1002 let nonce = issue_nonce(&secret, now, &ctx);
1003
1004 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1005 let p = serde_json::json!({
1006 "jti":"n-hmac-ok",
1007 "iat":now,
1008 "htm":expected_htm,
1009 "htu":expected_htu,
1010 "nonce": nonce
1011 });
1012 let jws = make_jws(&sk, h, p);
1013
1014 let mut store = MemoryStore::default();
1015 let opts = VerifyOptions {
1016 max_age_secs: 300,
1017 future_skew_secs: 5,
1018 nonce_mode: NonceMode::Hmac {
1019 secret: secret.clone(),
1020 max_age_secs: 300,
1021 bind_htu_htm: true,
1022 bind_jkt: true,
1023 },
1024 };
1025 assert!(verify_proof(
1026 &mut store,
1027 None,
1028 &jws,
1029 expected_htu,
1030 expected_htm,
1031 None,
1032 opts
1033 )
1034 .await
1035 .is_ok());
1036 }
1037
1038 #[tokio::test]
1039 async fn nonce_hmac_missing_claim_prompts_use_dpop_nonce() {
1040 let (sk, x, y) = gen_es256_key();
1041 let now = OffsetDateTime::now_utc().unix_timestamp();
1042 let expected_htu = "https://ex.com/a";
1043 let expected_htm = "GET";
1044
1045 let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
1046
1047 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1048 let p = serde_json::json!({
1049 "jti":"n-hmac-miss",
1050 "iat":now,
1051 "htm":expected_htm,
1052 "htu":expected_htu
1053 });
1054 let jws = make_jws(&sk, h, p);
1055
1056 let mut store = MemoryStore::default();
1057 let opts = VerifyOptions {
1058 max_age_secs: 300,
1059 future_skew_secs: 5,
1060 nonce_mode: NonceMode::Hmac {
1061 secret: secret.clone(),
1062 max_age_secs: 300,
1063 bind_htu_htm: true,
1064 bind_jkt: true,
1065 },
1066 };
1067 let err = verify_proof(
1068 &mut store,
1069 None,
1070 &jws,
1071 expected_htu,
1072 expected_htm,
1073 None,
1074 opts,
1075 )
1076 .await
1077 .unwrap_err();
1078 matches!(err, DpopError::UseDpopNonce { .. });
1079 }
1080
1081 #[tokio::test]
1082 async fn nonce_hmac_wrong_htu_prompts_use_dpop_nonce() {
1083 let (sk, x, y) = gen_es256_key();
1084 let now = OffsetDateTime::now_utc().unix_timestamp();
1085 let expected_htm = "GET";
1086 let expected_htu = "https://ex.com/correct";
1087
1088 let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1090 let secret: Arc<[u8]> = Arc::from(&b"k"[..]);
1091 let ctx_wrong = crate::nonce::NonceCtx {
1092 htu: Some("https://ex.com/wrong"),
1093 htm: Some(expected_htm),
1094 jkt: Some(&jkt),
1095 };
1096 let nonce = issue_nonce(&secret, now, &ctx_wrong);
1097
1098 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1099 let p = serde_json::json!({
1100 "jti":"n-hmac-htu-mis",
1101 "iat":now,
1102 "htm":expected_htm,
1103 "htu":expected_htu,
1104 "nonce": nonce
1105 });
1106 let jws = make_jws(&sk, h, p);
1107
1108 let mut store = MemoryStore::default();
1109 let opts = VerifyOptions {
1110 max_age_secs: 300,
1111 future_skew_secs: 5,
1112 nonce_mode: NonceMode::Hmac {
1113 secret: secret.clone(),
1114 max_age_secs: 300,
1115 bind_htu_htm: true,
1116 bind_jkt: true,
1117 },
1118 };
1119 let err = verify_proof(
1120 &mut store,
1121 None,
1122 &jws,
1123 expected_htu,
1124 expected_htm,
1125 None,
1126 opts,
1127 )
1128 .await
1129 .unwrap_err();
1130 matches!(err, DpopError::UseDpopNonce { .. });
1131 }
1132
1133 #[tokio::test]
1134 async fn nonce_hmac_wrong_jkt_prompts_use_dpop_nonce() {
1135 let (_sk_a, x_a, y_a) = gen_es256_key();
1137 let (sk_b, x_b, y_b) = gen_es256_key();
1138 let now = OffsetDateTime::now_utc().unix_timestamp();
1139 let expected_htu = "https://ex.com/a";
1140 let expected_htm = "GET";
1141
1142 let jkt_a = thumbprint_ec_p256(&x_a, &y_a).unwrap();
1143 let secret: Arc<[u8]> = Arc::from(&b"secret-2"[..]);
1144 let ctx = crate::nonce::NonceCtx {
1145 htu: Some(expected_htu),
1146 htm: Some(expected_htm),
1147 jkt: Some(&jkt_a), };
1149 let nonce = issue_nonce(&secret, now, &ctx);
1150
1151 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x_b,"y":y_b}});
1153 let p = serde_json::json!({
1154 "jti":"n-hmac-jkt-mis",
1155 "iat":now,
1156 "htm":expected_htm,
1157 "htu":expected_htu,
1158 "nonce": nonce
1159 });
1160 let jws = make_jws(&sk_b, h, p);
1161
1162 let mut store = MemoryStore::default();
1163 let opts = VerifyOptions {
1164 max_age_secs: 300,
1165 future_skew_secs: 5,
1166 nonce_mode: NonceMode::Hmac {
1167 secret: secret.clone(),
1168 max_age_secs: 300,
1169 bind_htu_htm: true,
1170 bind_jkt: true,
1171 },
1172 };
1173 let err = verify_proof(
1174 &mut store,
1175 None,
1176 &jws,
1177 expected_htu,
1178 expected_htm,
1179 None,
1180 opts,
1181 )
1182 .await
1183 .unwrap_err();
1184 matches!(err, DpopError::UseDpopNonce { .. });
1185 }
1186
1187 #[tokio::test]
1188 async fn nonce_hmac_stale_prompts_use_dpop_nonce() {
1189 let (sk, x, y) = gen_es256_key();
1190 let now = OffsetDateTime::now_utc().unix_timestamp();
1191 let expected_htu = "https://ex.com/a";
1192 let expected_htm = "GET";
1193
1194 let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1195 let secret: Arc<[u8]> = Arc::from(&b"secret-3"[..]);
1196 let issued_ts = now - 400;
1198 let nonce = issue_nonce(
1199 &secret,
1200 issued_ts,
1201 &crate::nonce::NonceCtx {
1202 htu: Some(expected_htu),
1203 htm: Some(expected_htm),
1204 jkt: Some(&jkt),
1205 },
1206 );
1207
1208 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1209 let p = serde_json::json!({
1210 "jti":"n-hmac-stale",
1211 "iat":now,
1212 "htm":expected_htm,
1213 "htu":expected_htu,
1214 "nonce": nonce
1215 });
1216 let jws = make_jws(&sk, h, p);
1217
1218 let mut store = MemoryStore::default();
1219 let opts = VerifyOptions {
1220 max_age_secs: 300,
1221 future_skew_secs: 5,
1222 nonce_mode: NonceMode::Hmac {
1223 secret: secret.clone(),
1224 max_age_secs: 300,
1225 bind_htu_htm: true,
1226 bind_jkt: true,
1227 },
1228 };
1229 let err = verify_proof(
1230 &mut store,
1231 None,
1232 &jws,
1233 expected_htu,
1234 expected_htm,
1235 None,
1236 opts,
1237 )
1238 .await
1239 .unwrap_err();
1240 matches!(err, DpopError::UseDpopNonce { .. });
1241 }
1242
1243 #[tokio::test]
1244 async fn nonce_hmac_future_skew_prompts_use_dpop_nonce() {
1245 let (sk, x, y) = gen_es256_key();
1246 let now = OffsetDateTime::now_utc().unix_timestamp();
1247 let expected_htu = "https://ex.com/a";
1248 let expected_htm = "GET";
1249
1250 let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1251 let secret: Arc<[u8]> = Arc::from(&b"secret-4"[..]);
1252 let issued_ts = now + 10;
1254 let nonce = issue_nonce(
1255 &secret,
1256 issued_ts,
1257 &crate::nonce::NonceCtx {
1258 htu: Some(expected_htu),
1259 htm: Some(expected_htm),
1260 jkt: Some(&jkt),
1261 },
1262 );
1263
1264 let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1265 let p = serde_json::json!({
1266 "jti":"n-hmac-future",
1267 "iat":now,
1268 "htm":expected_htm,
1269 "htu":expected_htu,
1270 "nonce": nonce
1271 });
1272 let jws = make_jws(&sk, h, p);
1273
1274 let mut store = MemoryStore::default();
1275 let opts = VerifyOptions {
1276 max_age_secs: 300,
1277 future_skew_secs: 5,
1278 nonce_mode: NonceMode::Hmac {
1279 secret: secret.clone(),
1280 max_age_secs: 300,
1281 bind_htu_htm: true,
1282 bind_jkt: true,
1283 },
1284 };
1285 let err = verify_proof(
1286 &mut store,
1287 None,
1288 &jws,
1289 expected_htu,
1290 expected_htm,
1291 None,
1292 opts,
1293 )
1294 .await
1295 .unwrap_err();
1296 matches!(err, DpopError::UseDpopNonce { .. });
1297 }
1298}