dpop_verifier/
verify.rs

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, VerifyingKey};
13
14// Constants for signature and token validation
15const ECDSA_P256_SIGNATURE_LENGTH: usize = 64;
16#[cfg(feature = "eddsa")]
17const ED25519_SIGNATURE_LENGTH: usize = 64;
18const JTI_HASH_LENGTH: usize = 32;
19const JTI_MAX_LENGTH: usize = 512;
20
21#[derive(Deserialize)]
22struct DpopHeader {
23    typ: String,
24    alg: String,
25    jwk: Jwk,
26}
27
28#[derive(Deserialize)]
29#[serde(untagged)]
30enum Jwk {
31    EcP256 {
32        kty: String,
33        crv: String,
34        x: String,
35        y: String,
36    },
37    #[cfg(feature = "eddsa")]
38    OkpEd25519 { kty: String, crv: String, x: String },
39}
40
41#[derive(Clone, Debug)]
42pub enum NonceMode {
43    Disabled,
44    /// Require exact equality against `expected_nonce`
45    RequireEqual {
46        expected_nonce: String, // the nonce you previously issued
47    },
48    /// Stateless HMAC-based nonces: encode ts+rand+ctx and MAC it
49    Hmac {
50        secret: secrecy::SecretBox<[u8]>,
51        max_age_seconds: i64,
52        bind_htu_htm: bool,
53        bind_jkt: bool,
54        bind_client: bool,
55    },
56}
57
58#[derive(Debug, Clone)]
59pub struct VerifyOptions {
60    pub max_age_seconds: i64,
61    pub future_skew_seconds: i64,
62    pub nonce_mode: NonceMode,
63    pub client_binding: Option<ClientBinding>,
64}
65impl Default for VerifyOptions {
66    fn default() -> Self {
67        Self {
68            max_age_seconds: 300,
69            future_skew_seconds: 5,
70            nonce_mode: NonceMode::Disabled,
71            client_binding: None,
72        }
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct ClientBinding {
78    pub client_id: String,
79}
80
81impl ClientBinding {
82    pub fn new(client_id: impl Into<String>) -> Self {
83        Self {
84            client_id: client_id.into(),
85        }
86    }
87}
88
89#[derive(Debug)]
90pub struct VerifiedDpop {
91    pub jkt: String,
92    pub jti: String,
93    pub iat: i64,
94}
95
96/// Helper struct for type-safe JTI hash handling
97struct JtiHash([u8; JTI_HASH_LENGTH]);
98
99impl JtiHash {
100    /// Create a JTI hash from the SHA-256 digest
101    fn from_jti(jti: &str) -> Self {
102        let mut hasher = Sha256::new();
103        hasher.update(jti.as_bytes());
104        let digest = hasher.finalize();
105        let mut hash = [0u8; JTI_HASH_LENGTH];
106        hash.copy_from_slice(&digest[..JTI_HASH_LENGTH]);
107        JtiHash(hash)
108    }
109
110    /// Get the inner array
111    fn as_array(&self) -> [u8; JTI_HASH_LENGTH] {
112        self.0
113    }
114}
115
116/// Parsed DPoP token structure
117struct DpopToken {
118    header: DpopHeader,
119    payload_b64: String,
120    signature_bytes: Vec<u8>,
121    signing_input: String,
122}
123
124/// Structured DPoP claims
125#[derive(Deserialize)]
126struct DpopClaims {
127    jti: String,
128    iat: i64,
129    htm: String,
130    htu: String,
131    #[serde(default)]
132    ath: Option<String>,
133    #[serde(default)]
134    nonce: Option<String>,
135}
136
137/// Main DPoP verifier with builder pattern
138pub struct DpopVerifier {
139    options: VerifyOptions,
140}
141
142impl DpopVerifier {
143    /// Create a new DPoP verifier with default options
144    pub fn new() -> Self {
145        Self {
146            options: VerifyOptions::default(),
147        }
148    }
149
150    /// Set the maximum age for DPoP proofs (in seconds)
151    pub fn with_max_age_seconds(mut self, max_age_seconds: i64) -> Self {
152        self.options.max_age_seconds = max_age_seconds;
153        self
154    }
155
156    /// Set the future skew tolerance (in seconds)
157    pub fn with_future_skew_seconds(mut self, future_skew_seconds: i64) -> Self {
158        self.options.future_skew_seconds = future_skew_seconds;
159        self
160    }
161
162    /// Set the nonce mode
163    pub fn with_nonce_mode(mut self, nonce_mode: NonceMode) -> Self {
164        self.options.nonce_mode = nonce_mode;
165        self
166    }
167
168    /// Bind verification to a specific client identifier
169    pub fn with_client_binding(mut self, client_id: impl Into<String>) -> Self {
170        self.options.client_binding = Some(ClientBinding {
171            client_id: client_id.into(),
172        });
173        self
174    }
175
176    /// Remove any configured client binding
177    pub fn without_client_binding(mut self) -> Self {
178        self.options.client_binding = None;
179        self
180    }
181
182    /// Verify a DPoP proof
183    pub async fn verify<S: ReplayStore + ?Sized>(
184        &self,
185        store: &mut S,
186        dpop_compact_jws: &str,
187        expected_htu: &str,
188        expected_htm: &str,
189        access_token: Option<&str>,
190    ) -> Result<VerifiedDpop, DpopError> {
191        // Parse the token
192        let token = self.parse_token(dpop_compact_jws)?;
193
194        // Validate header
195        self.validate_header(&token.header)?;
196
197        // Verify signature and compute JKT
198        let jkt = self.verify_signature_and_compute_jkt(&token)?;
199
200        // Parse claims
201        let claims: DpopClaims = {
202            let bytes = B64
203                .decode(&token.payload_b64)
204                .map_err(|_| DpopError::MalformedJws)?;
205            serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?
206        };
207
208        // Validate JTI length
209        if claims.jti.len() > JTI_MAX_LENGTH {
210            return Err(DpopError::JtiTooLong);
211        }
212
213        // Validate HTTP binding (HTM/HTU)
214        let (expected_htm_normalized, expected_htu_normalized) =
215            self.validate_http_binding(&claims, expected_htm, expected_htu)?;
216
217        // Validate access token binding if present
218        if let Some(token) = access_token {
219            self.validate_access_token_binding(&claims, token)?;
220        }
221
222        // Check timestamp freshness
223        self.check_timestamp_freshness(claims.iat)?;
224
225        let client_binding = self
226            .options
227            .client_binding
228            .as_ref()
229            .map(|binding| binding.client_id.as_str());
230
231        // Validate nonce if required
232        self.validate_nonce_if_required(
233            &claims,
234            &expected_htu_normalized,
235            &expected_htm_normalized,
236            &jkt,
237            client_binding,
238        )?;
239
240        // Prevent replay
241        let jti_hash = JtiHash::from_jti(&claims.jti);
242        self.prevent_replay(store, jti_hash, &claims, &jkt, client_binding)
243            .await?;
244
245        Ok(VerifiedDpop {
246            jkt,
247            jti: claims.jti,
248            iat: claims.iat,
249        })
250    }
251
252    /// Parse compact JWS into token components
253    fn parse_token(&self, dpop_compact_jws: &str) -> Result<DpopToken, DpopError> {
254        let mut jws_parts = dpop_compact_jws.split('.');
255        let (header_b64, payload_b64, signature_b64) =
256            match (jws_parts.next(), jws_parts.next(), jws_parts.next()) {
257                (Some(h), Some(p), Some(s)) if jws_parts.next().is_none() => (h, p, s),
258                _ => return Err(DpopError::MalformedJws),
259            };
260
261        // Decode JOSE header
262        let header: DpopHeader = {
263            let bytes = B64
264                .decode(header_b64)
265                .map_err(|_| DpopError::MalformedJws)?;
266            let val: serde_json::Value =
267                serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?;
268            // MUST NOT include private JWK material
269            if val.get("jwk").and_then(|j| j.get("d")).is_some() {
270                return Err(DpopError::BadJwk("jwk must not include 'd'"));
271            }
272            serde_json::from_value(val).map_err(|_| DpopError::MalformedJws)?
273        };
274
275        let signing_input = format!("{}.{}", header_b64, payload_b64);
276        let signature_bytes = B64
277            .decode(signature_b64)
278            .map_err(|_| DpopError::InvalidSignature)?;
279
280        Ok(DpopToken {
281            header,
282            payload_b64: payload_b64.to_string(),
283            signature_bytes,
284            signing_input,
285        })
286    }
287
288    /// Validate the DPoP header (typ and alg checks)
289    fn validate_header(&self, header: &DpopHeader) -> Result<(), DpopError> {
290        if header.typ != "dpop+jwt" {
291            return Err(DpopError::MalformedJws);
292        }
293        Ok(())
294    }
295
296    /// Verify signature and compute JKT (JSON Key Thumbprint)
297    fn verify_signature_and_compute_jkt(&self, token: &DpopToken) -> Result<String, DpopError> {
298        let jkt = match (token.header.alg.as_str(), &token.header.jwk) {
299            ("ES256", Jwk::EcP256 { kty, crv, x, y }) if kty == "EC" && crv == "P-256" => {
300                if token.signature_bytes.len() != ECDSA_P256_SIGNATURE_LENGTH {
301                    return Err(DpopError::InvalidSignature);
302                }
303
304                let verifying_key: VerifyingKey = verifying_key_from_p256_xy(x, y)?;
305                let signature = p256::ecdsa::Signature::from_slice(&token.signature_bytes)
306                    .map_err(|_| DpopError::InvalidSignature)?;
307                verifying_key
308                    .verify(token.signing_input.as_bytes(), &signature)
309                    .map_err(|_| DpopError::InvalidSignature)?;
310                // compute EC thumbprint
311                thumbprint_ec_p256(x, y)?
312            }
313
314            #[cfg(feature = "eddsa")]
315            ("EdDSA", Jwk::OkpEd25519 { kty, crv, x }) if kty == "OKP" && crv == "Ed25519" => {
316                use ed25519_dalek::{Signature as EdSig, VerifyingKey as EdVk};
317                use signature::Verifier as _;
318
319                if token.signature_bytes.len() != ED25519_SIGNATURE_LENGTH {
320                    return Err(DpopError::InvalidSignature);
321                }
322
323                let verifying_key: EdVk = crate::jwk::verifying_key_from_okp_ed25519(x)?;
324                let signature = EdSig::from_slice(&token.signature_bytes)
325                    .map_err(|_| DpopError::InvalidSignature)?;
326                verifying_key
327                    .verify(token.signing_input.as_bytes(), &signature)
328                    .map_err(|_| DpopError::InvalidSignature)?;
329                crate::jwk::thumbprint_okp_ed25519(x)?
330            }
331
332            ("EdDSA", _) => return Err(DpopError::BadJwk("expect OKP/Ed25519 for EdDSA")),
333            ("ES256", _) => return Err(DpopError::BadJwk("expect EC/P-256 for ES256")),
334            ("none", _) => return Err(DpopError::InvalidAlg("none".into())),
335            (a, _) if a.starts_with("HS") => return Err(DpopError::InvalidAlg(a.into())),
336            (other, _) => return Err(DpopError::UnsupportedAlg(other.into())),
337        };
338
339        Ok(jkt)
340    }
341
342    /// Validate HTTP method and URI binding
343    fn validate_http_binding(
344        &self,
345        claims: &DpopClaims,
346        expected_htm: &str,
347        expected_htu: &str,
348    ) -> Result<(String, String), DpopError> {
349        // Strict method & URI checks (normalize both sides, then exact compare)
350        let expected_htm_normalized = normalize_method(expected_htm)?;
351        let actual_htm_normalized = normalize_method(&claims.htm)?;
352        if actual_htm_normalized != expected_htm_normalized {
353            return Err(DpopError::HtmMismatch);
354        }
355
356        let expected_htu_normalized = normalize_htu(expected_htu)?;
357        let actual_htu_normalized = normalize_htu(&claims.htu)?;
358        if actual_htu_normalized != expected_htu_normalized {
359            return Err(DpopError::HtuMismatch);
360        }
361
362        Ok((expected_htm_normalized, expected_htu_normalized))
363    }
364
365    /// Validate access token hash binding
366    fn validate_access_token_binding(
367        &self,
368        claims: &DpopClaims,
369        access_token: &str,
370    ) -> Result<(), DpopError> {
371        // Compute expected SHA-256 bytes of the exact token octets
372        let expected_hash = Sha256::digest(access_token.as_bytes());
373
374        // Decode provided ath (must be base64url no-pad)
375        let ath_b64 = claims.ath.as_ref().ok_or(DpopError::MissingAth)?;
376        let actual_hash = B64
377            .decode(ath_b64.as_bytes())
378            .map_err(|_| DpopError::AthMalformed)?;
379
380        // Constant-time compare of raw digests
381        if actual_hash.len() != expected_hash.len()
382            || !bool::from(actual_hash.ct_eq(&expected_hash[..]))
383        {
384            return Err(DpopError::AthMismatch);
385        }
386
387        Ok(())
388    }
389
390    /// Check timestamp freshness with configured limits
391    fn check_timestamp_freshness(&self, iat: i64) -> Result<(), DpopError> {
392        let current_time = OffsetDateTime::now_utc().unix_timestamp();
393        if iat > current_time + self.options.future_skew_seconds {
394            return Err(DpopError::FutureSkew);
395        }
396        if current_time - iat > self.options.max_age_seconds {
397            return Err(DpopError::Stale);
398        }
399        Ok(())
400    }
401
402    /// Validate nonce if required by configuration
403    fn validate_nonce_if_required(
404        &self,
405        claims: &DpopClaims,
406        expected_htu_normalized: &str,
407        expected_htm_normalized: &str,
408        jkt: &str,
409        client_binding: Option<&str>,
410    ) -> Result<(), DpopError> {
411        match &self.options.nonce_mode {
412            NonceMode::Disabled => { /* do nothing */ }
413            NonceMode::RequireEqual { expected_nonce } => {
414                let nonce_value = claims.nonce.as_ref().ok_or(DpopError::MissingNonce)?;
415                if nonce_value != expected_nonce {
416                    let fresh_nonce = expected_nonce.to_string();
417                    return Err(DpopError::UseDpopNonce { nonce: fresh_nonce });
418                }
419            }
420            NonceMode::Hmac {
421                secret,
422                max_age_seconds,
423                bind_htu_htm,
424                bind_jkt,
425                bind_client,
426            } => {
427                let nonce_value = match &claims.nonce {
428                    Some(s) => s.as_str(),
429                    None => {
430                        // Missing → ask client to retry with nonce
431                        let current_time = time::OffsetDateTime::now_utc().unix_timestamp();
432                        let nonce_ctx = crate::nonce::NonceCtx {
433                            htu: if *bind_htu_htm {
434                                Some(expected_htu_normalized)
435                            } else {
436                                None
437                            },
438                            htm: if *bind_htu_htm {
439                                Some(expected_htm_normalized)
440                            } else {
441                                None
442                            },
443                            jkt: if *bind_jkt { Some(jkt) } else { None },
444                            client: if *bind_client { client_binding } else { None },
445                        };
446                        let fresh_nonce =
447                            crate::nonce::issue_nonce(secret, current_time, &nonce_ctx)?;
448                        return Err(DpopError::UseDpopNonce { nonce: fresh_nonce });
449                    }
450                };
451
452                let current_time = time::OffsetDateTime::now_utc().unix_timestamp();
453                let nonce_ctx = crate::nonce::NonceCtx {
454                    htu: if *bind_htu_htm {
455                        Some(expected_htu_normalized)
456                    } else {
457                        None
458                    },
459                    htm: if *bind_htu_htm {
460                        Some(expected_htm_normalized)
461                    } else {
462                        None
463                    },
464                    jkt: if *bind_jkt { Some(jkt) } else { None },
465                    client: if *bind_client { client_binding } else { None },
466                };
467
468                if crate::nonce::verify_nonce(
469                    secret,
470                    nonce_value,
471                    current_time,
472                    *max_age_seconds,
473                    &nonce_ctx,
474                )
475                .is_err()
476                {
477                    // On invalid/stale → emit NEW nonce so client can retry immediately
478                    let fresh_nonce = crate::nonce::issue_nonce(secret, current_time, &nonce_ctx)?;
479                    return Err(DpopError::UseDpopNonce { nonce: fresh_nonce });
480                }
481            }
482        }
483        Ok(())
484    }
485
486    /// Prevent replay attacks using the replay store
487    async fn prevent_replay<S: ReplayStore + ?Sized>(
488        &self,
489        store: &mut S,
490        jti_hash: JtiHash,
491        claims: &DpopClaims,
492        jkt: &str,
493        client_binding: Option<&str>,
494    ) -> Result<(), DpopError> {
495        let is_first_use = store
496            .insert_once(
497                jti_hash.as_array(),
498                ReplayContext {
499                    jkt: Some(jkt),
500                    htm: Some(&claims.htm),
501                    htu: Some(&claims.htu),
502                    client_id: client_binding,
503                    iat: claims.iat,
504                },
505            )
506            .await?;
507
508        if !is_first_use {
509            return Err(DpopError::Replay);
510        }
511
512        Ok(())
513    }
514}
515
516impl Default for DpopVerifier {
517    fn default() -> Self {
518        Self::new()
519    }
520}
521
522/// Verify DPoP proof and record the jti to prevent replays.
523///
524/// # Deprecated
525/// This function is maintained for backward compatibility. New code should use `DpopVerifier` instead.
526/// See the `DpopVerifier` documentation for usage examples.
527#[deprecated(since = "2.0.0", note = "Use DpopVerifier instead")]
528pub async fn verify_proof<S: ReplayStore + ?Sized>(
529    store: &mut S,
530    dpop_compact_jws: &str,
531    expected_htu: &str,
532    expected_htm: &str,
533    access_token: Option<&str>,
534    opts: VerifyOptions,
535) -> Result<VerifiedDpop, DpopError> {
536    let verifier = DpopVerifier { options: opts };
537    verifier
538        .verify(
539            store,
540            dpop_compact_jws,
541            expected_htu,
542            expected_htm,
543            access_token,
544        )
545        .await
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use crate::jwk::thumbprint_ec_p256;
552    use crate::nonce::issue_nonce;
553    use p256::ecdsa::{signature::Signer, Signature, SigningKey};
554    use rand_core::OsRng;
555    use secrecy::SecretBox;
556
557    // ---- helpers ----------------------------------------------------------------
558
559    fn gen_es256_key() -> (SigningKey, String, String) {
560        let signing_key = SigningKey::random(&mut OsRng);
561        let verifying_key = VerifyingKey::from(&signing_key);
562        let encoded_point = verifying_key.to_encoded_point(false);
563        let x_coordinate = B64.encode(encoded_point.x().unwrap());
564        let y_coordinate = B64.encode(encoded_point.y().unwrap());
565        (signing_key, x_coordinate, y_coordinate)
566    }
567
568    fn make_jws(
569        signing_key: &SigningKey,
570        header_json: serde_json::Value,
571        claims_json: serde_json::Value,
572    ) -> String {
573        let header_bytes = serde_json::to_vec(&header_json).unwrap();
574        let payload_bytes = serde_json::to_vec(&claims_json).unwrap();
575        let header_b64 = B64.encode(header_bytes);
576        let payload_b64 = B64.encode(payload_bytes);
577        let signing_input = format!("{header_b64}.{payload_b64}");
578        let signature: Signature = signing_key.sign(signing_input.as_bytes());
579        let signature_b64 = B64.encode(signature.to_bytes());
580        format!("{header_b64}.{payload_b64}.{signature_b64}")
581    }
582
583    #[derive(Default)]
584    struct MemoryStore(std::collections::HashSet<[u8; 32]>);
585
586    #[async_trait::async_trait]
587    impl ReplayStore for MemoryStore {
588        async fn insert_once(
589            &mut self,
590            jti_hash: [u8; 32],
591            _ctx: ReplayContext<'_>,
592        ) -> Result<bool, DpopError> {
593            Ok(self.0.insert(jti_hash))
594        }
595    }
596    // ---- tests ------------------------------------------------------------------
597    #[test]
598    fn thumbprint_has_expected_length_and_no_padding() {
599        // 32 zero bytes -> base64url = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" (43 chars)
600        let x = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
601        let y = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
602        let t1 = thumbprint_ec_p256(x, y).expect("thumbprint");
603        let t2 = thumbprint_ec_p256(x, y).expect("thumbprint");
604        // deterministic and base64url w/out '=' padding; sha256 -> 43 chars
605        assert_eq!(t1, t2);
606        assert_eq!(t1.len(), 43);
607        assert!(!t1.contains('='));
608    }
609
610    #[test]
611    fn decoding_key_rejects_wrong_sizes() {
612        // 31-byte x (trimmed), 32-byte y
613        let bad_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 31]);
614        let good_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
615        let res = crate::jwk::verifying_key_from_p256_xy(&bad_x, &good_y);
616        assert!(res.is_err(), "expected error for bad y");
617
618        // 32-byte x, 33-byte y
619        let good_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
620        let bad_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 33]);
621        let res = crate::jwk::verifying_key_from_p256_xy(&good_x, &bad_y);
622        assert!(res.is_err(), "expected error for bad y");
623    }
624
625    #[tokio::test]
626    async fn replay_store_trait_basic() {
627        use async_trait::async_trait;
628        use std::collections::HashSet;
629
630        struct MemoryStore(HashSet<[u8; 32]>);
631
632        #[async_trait]
633        impl ReplayStore for MemoryStore {
634            async fn insert_once(
635                &mut self,
636                jti_hash: [u8; 32],
637                _ctx: ReplayContext<'_>,
638            ) -> Result<bool, DpopError> {
639                Ok(self.0.insert(jti_hash))
640            }
641        }
642
643        let mut s = MemoryStore(HashSet::new());
644        let first = s
645            .insert_once(
646                [42u8; 32],
647                ReplayContext {
648                    jkt: Some("j"),
649                    htm: Some("POST"),
650                    htu: Some("https://ex"),
651                    client_id: None,
652                    iat: 0,
653                },
654            )
655            .await
656            .unwrap();
657        let second = s
658            .insert_once(
659                [42u8; 32],
660                ReplayContext {
661                    jkt: Some("j"),
662                    htm: Some("POST"),
663                    htu: Some("https://ex"),
664                    client_id: None,
665                    iat: 0,
666                },
667            )
668            .await
669            .unwrap();
670        assert!(first);
671        assert!(!second); // replay detected
672    }
673    #[tokio::test]
674    async fn verify_valid_es256_proof() {
675        let (sk, x, y) = gen_es256_key();
676        let now = OffsetDateTime::now_utc().unix_timestamp();
677        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
678        let p = serde_json::json!({"jti":"j1","iat":now,"htm":"GET","htu":"https://api.example.com/resource"});
679        let jws = make_jws(&sk, h, p);
680
681        let mut store = MemoryStore::default();
682        let res = verify_proof(
683            &mut store,
684            &jws,
685            "https://api.example.com/resource",
686            "GET",
687            None,
688            VerifyOptions::default(),
689        )
690        .await;
691        assert!(res.is_ok(), "{res:?}");
692    }
693
694    #[tokio::test]
695    async fn method_normalization_allows_lowercase_claim() {
696        let (sk, x, y) = gen_es256_key();
697        let now = OffsetDateTime::now_utc().unix_timestamp();
698        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
699        let p = serde_json::json!({"jti":"j2","iat":now,"htm":"get","htu":"https://ex.com/a"});
700        let jws = make_jws(&sk, h, p);
701
702        let mut store = MemoryStore::default();
703        assert!(verify_proof(
704            &mut store,
705            &jws,
706            "https://ex.com/a",
707            "GET",
708            None,
709            VerifyOptions::default()
710        )
711        .await
712        .is_ok());
713    }
714
715    #[tokio::test]
716    async fn htu_normalizes_dot_segments_and_default_ports_and_strips_qf() {
717        let (sk, x, y) = gen_es256_key();
718        let now = OffsetDateTime::now_utc().unix_timestamp();
719        // claim has :443, dot-segment, query and fragment
720        let claim_htu = "https://EX.COM:443/a/../b?q=1#frag";
721        let expect_htu = "https://ex.com/b";
722        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
723        let p = serde_json::json!({"jti":"j3","iat":now,"htm":"GET","htu":claim_htu});
724        let jws = make_jws(&sk, h, p);
725
726        let mut store = MemoryStore::default();
727        assert!(verify_proof(
728            &mut store,
729            &jws,
730            expect_htu,
731            "GET",
732            None,
733            VerifyOptions::default()
734        )
735        .await
736        .is_ok());
737    }
738
739    #[tokio::test]
740    async fn htu_path_case_mismatch_fails() {
741        let (sk, x, y) = gen_es256_key();
742        let now = OffsetDateTime::now_utc().unix_timestamp();
743        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
744        let p = serde_json::json!({"jti":"j4","iat":now,"htm":"GET","htu":"https://ex.com/API"});
745        let jws = make_jws(&sk, h, p);
746
747        let mut store = MemoryStore::default();
748        let err = verify_proof(
749            &mut store,
750            &jws,
751            "https://ex.com/api",
752            "GET",
753            None,
754            VerifyOptions::default(),
755        )
756        .await
757        .unwrap_err();
758        matches!(err, DpopError::HtuMismatch);
759    }
760
761    #[tokio::test]
762    async fn alg_none_rejected() {
763        let (sk, x, y) = gen_es256_key();
764        let now = OffsetDateTime::now_utc().unix_timestamp();
765        // still sign, but "alg":"none" must be rejected before/independent of signature
766        let h = serde_json::json!({"typ":"dpop+jwt","alg":"none","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
767        let p = serde_json::json!({"jti":"j5","iat":now,"htm":"GET","htu":"https://ex.com/a"});
768        let jws = make_jws(&sk, h, p);
769
770        let mut store = MemoryStore::default();
771        let err = verify_proof(
772            &mut store,
773            &jws,
774            "https://ex.com/a",
775            "GET",
776            None,
777            VerifyOptions::default(),
778        )
779        .await
780        .unwrap_err();
781        matches!(err, DpopError::InvalidAlg(_));
782    }
783
784    #[tokio::test]
785    async fn alg_hs256_rejected() {
786        let (sk, x, y) = gen_es256_key();
787        let now = OffsetDateTime::now_utc().unix_timestamp();
788        let h = serde_json::json!({"typ":"dpop+jwt","alg":"HS256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
789        let p = serde_json::json!({"jti":"j6","iat":now,"htm":"GET","htu":"https://ex.com/a"});
790        let jws = make_jws(&sk, h, p);
791
792        let mut store = MemoryStore::default();
793        let err = verify_proof(
794            &mut store,
795            &jws,
796            "https://ex.com/a",
797            "GET",
798            None,
799            VerifyOptions::default(),
800        )
801        .await
802        .unwrap_err();
803        matches!(err, DpopError::InvalidAlg(_));
804    }
805
806    #[tokio::test]
807    async fn jwk_with_private_d_rejected() {
808        let (sk, x, y) = gen_es256_key();
809        let now = OffsetDateTime::now_utc().unix_timestamp();
810        // inject "d" (any string) -> must be rejected
811        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y,"d":"AAAA"}});
812        let p = serde_json::json!({"jti":"j7","iat":now,"htm":"GET","htu":"https://ex.com/a"});
813        let jws = make_jws(&sk, h, p);
814
815        let mut store = MemoryStore::default();
816        let err = verify_proof(
817            &mut store,
818            &jws,
819            "https://ex.com/a",
820            "GET",
821            None,
822            VerifyOptions::default(),
823        )
824        .await
825        .unwrap_err();
826        matches!(err, DpopError::BadJwk(_));
827    }
828
829    #[tokio::test]
830    async fn ath_binding_ok_and_mismatch_and_padded_rejected() {
831        let (sk, x, y) = gen_es256_key();
832        let now = OffsetDateTime::now_utc().unix_timestamp();
833        let at = "access.token.string";
834        let ath = B64.encode(Sha256::digest(at.as_bytes()));
835        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
836
837        // OK
838        let p_ok = serde_json::json!({"jti":"j8","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
839        let jws_ok = make_jws(&sk, h.clone(), p_ok);
840        let mut store = MemoryStore::default();
841        assert!(verify_proof(
842            &mut store,
843            &jws_ok,
844            "https://ex.com/a",
845            "GET",
846            Some(at),
847            VerifyOptions::default()
848        )
849        .await
850        .is_ok());
851
852        // Mismatch
853        let p_bad = serde_json::json!({"jti":"j9","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
854        let jws_bad = make_jws(&sk, h.clone(), p_bad);
855        let mut store2 = MemoryStore::default();
856        let err = verify_proof(
857            &mut store2,
858            &jws_bad,
859            "https://ex.com/a",
860            "GET",
861            Some("different.token"),
862            VerifyOptions::default(),
863        )
864        .await
865        .unwrap_err();
866        matches!(err, DpopError::AthMismatch);
867
868        // Padded ath should be rejected as malformed (engine is URL_SAFE_NO_PAD)
869        let ath_padded = format!("{ath}==");
870        let p_pad = serde_json::json!({"jti":"j10","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath_padded});
871        let jws_pad = make_jws(&sk, h.clone(), p_pad);
872        let mut store3 = MemoryStore::default();
873        let err = verify_proof(
874            &mut store3,
875            &jws_pad,
876            "https://ex.com/a",
877            "GET",
878            Some(at),
879            VerifyOptions::default(),
880        )
881        .await
882        .unwrap_err();
883        matches!(err, DpopError::AthMalformed);
884    }
885
886    #[tokio::test]
887    async fn freshness_future_skew_and_stale() {
888        let (sk, x, y) = gen_es256_key();
889        let now = OffsetDateTime::now_utc().unix_timestamp();
890        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
891
892        // Future skew just over limit
893        let future_skew_seconds = 5;
894        let p_future = serde_json::json!({
895            "jti":"jf",
896            "iat":now + future_skew_seconds + 5,
897            "htm":"GET",
898            "htu":"https://ex.com/a"
899        });
900        let jws_future = make_jws(&sk, h.clone(), p_future);
901        let mut store1 = MemoryStore::default();
902        let opts = VerifyOptions {
903            max_age_seconds: 300,
904            future_skew_seconds,
905            nonce_mode: NonceMode::Disabled,
906            client_binding: None,
907        };
908        let err = verify_proof(
909            &mut store1,
910            &jws_future,
911            "https://ex.com/a",
912            "GET",
913            None,
914            opts,
915        )
916        .await
917        .unwrap_err();
918        matches!(err, DpopError::FutureSkew);
919
920        // Stale just over limit
921        let p_stale =
922            serde_json::json!({"jti":"js","iat":now - 301,"htm":"GET","htu":"https://ex.com/a"});
923        let jws_stale = make_jws(&sk, h.clone(), p_stale);
924        let mut store2 = MemoryStore::default();
925        let opts = VerifyOptions {
926            max_age_seconds: 300,
927            future_skew_seconds,
928            nonce_mode: NonceMode::Disabled,
929            client_binding: None,
930        };
931        let err = verify_proof(
932            &mut store2,
933            &jws_stale,
934            "https://ex.com/a",
935            "GET",
936            None,
937            opts,
938        )
939        .await
940        .unwrap_err();
941        matches!(err, DpopError::Stale);
942    }
943
944    #[tokio::test]
945    async fn replay_same_jti_is_rejected() {
946        let (sk, x, y) = gen_es256_key();
947        let now = OffsetDateTime::now_utc().unix_timestamp();
948        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
949        let p = serde_json::json!({"jti":"jr","iat":now,"htm":"GET","htu":"https://ex.com/a"});
950        let jws = make_jws(&sk, h, p);
951
952        let mut store = MemoryStore::default();
953        let ok1 = verify_proof(
954            &mut store,
955            &jws,
956            "https://ex.com/a",
957            "GET",
958            None,
959            VerifyOptions::default(),
960        )
961        .await;
962        assert!(ok1.is_ok());
963        let err = verify_proof(
964            &mut store,
965            &jws,
966            "https://ex.com/a",
967            "GET",
968            None,
969            VerifyOptions::default(),
970        )
971        .await
972        .unwrap_err();
973        matches!(err, DpopError::Replay);
974    }
975
976    #[tokio::test]
977    async fn signature_tamper_detected() {
978        let (sk, x, y) = gen_es256_key();
979        let now = OffsetDateTime::now_utc().unix_timestamp();
980        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
981        let p = serde_json::json!({"jti":"jt","iat":now,"htm":"GET","htu":"https://ex.com/a"});
982        let mut jws = make_jws(&sk, h, p);
983
984        // Flip one byte in the payload section (keep base64url valid length)
985        let bytes = unsafe { jws.as_bytes_mut() }; // alternative: rebuild string
986                                                   // Find the second '.' and flip a safe ASCII char before it
987        let mut dot_count = 0usize;
988        for i in 0..bytes.len() {
989            if bytes[i] == b'.' {
990                dot_count += 1;
991                if dot_count == 2 && i > 10 {
992                    bytes[i - 5] ^= 0x01; // tiny flip
993                    break;
994                }
995            }
996        }
997
998        let mut store = MemoryStore::default();
999        let err = verify_proof(
1000            &mut store,
1001            &jws,
1002            "https://ex.com/a",
1003            "GET",
1004            None,
1005            VerifyOptions::default(),
1006        )
1007        .await
1008        .unwrap_err();
1009        matches!(err, DpopError::InvalidSignature);
1010    }
1011
1012    #[tokio::test]
1013    async fn method_mismatch_rejected() {
1014        let (sk, x, y) = gen_es256_key();
1015        let now = OffsetDateTime::now_utc().unix_timestamp();
1016        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1017        let p = serde_json::json!({"jti":"jm","iat":now,"htm":"POST","htu":"https://ex.com/a"});
1018        let jws = make_jws(&sk, h, p);
1019
1020        let mut store = MemoryStore::default();
1021        let err = verify_proof(
1022            &mut store,
1023            &jws,
1024            "https://ex.com/a",
1025            "GET",
1026            None,
1027            VerifyOptions::default(),
1028        )
1029        .await
1030        .unwrap_err();
1031        matches!(err, DpopError::HtmMismatch);
1032    }
1033
1034    #[test]
1035    fn normalize_helpers_examples() {
1036        // sanity checks for helpers used by verify_proof
1037        assert_eq!(
1038            normalize_htu("https://EX.com:443/a/./b/../c?x=1#frag").unwrap(),
1039            "https://ex.com/a/c"
1040        );
1041        assert_eq!(normalize_method("get").unwrap(), "GET");
1042        assert!(normalize_method("CUSTOM").is_err());
1043    }
1044
1045    #[tokio::test]
1046    async fn jti_too_long_rejected() {
1047        let (sk, x, y) = gen_es256_key();
1048        let now = OffsetDateTime::now_utc().unix_timestamp();
1049        let too_long = "x".repeat(513);
1050        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1051        let p = serde_json::json!({"jti":too_long,"iat":now,"htm":"GET","htu":"https://ex.com/a"});
1052        let jws = make_jws(&sk, h, p);
1053
1054        let mut store = MemoryStore::default();
1055        let err = verify_proof(
1056            &mut store,
1057            &jws,
1058            "https://ex.com/a",
1059            "GET",
1060            None,
1061            VerifyOptions::default(),
1062        )
1063        .await
1064        .unwrap_err();
1065        matches!(err, DpopError::JtiTooLong);
1066    }
1067    // ----------------------- Nonce: RequireEqual -------------------------------
1068
1069    #[tokio::test]
1070    async fn nonce_require_equal_ok() {
1071        let (sk, x, y) = gen_es256_key();
1072        let now = OffsetDateTime::now_utc().unix_timestamp();
1073        let expected_htu = "https://ex.com/a";
1074        let expected_htm = "GET";
1075
1076        let expected_nonce = "nonce-123";
1077        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1078        let p = serde_json::json!({
1079            "jti":"n-reqeq-ok",
1080            "iat":now,
1081            "htm":expected_htm,
1082            "htu":expected_htu,
1083            "nonce": expected_nonce
1084        });
1085        let jws = make_jws(&sk, h, p);
1086
1087        let mut store = MemoryStore::default();
1088        let opts = VerifyOptions {
1089            max_age_seconds: 300,
1090            future_skew_seconds: 5,
1091            nonce_mode: NonceMode::RequireEqual {
1092                expected_nonce: expected_nonce.to_string(),
1093            },
1094            client_binding: None,
1095        };
1096        assert!(
1097            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1098                .await
1099                .is_ok()
1100        );
1101    }
1102
1103    #[tokio::test]
1104    async fn nonce_require_equal_missing_claim() {
1105        let (sk, x, y) = gen_es256_key();
1106        let now = OffsetDateTime::now_utc().unix_timestamp();
1107        let expected_htu = "https://ex.com/a";
1108        let expected_htm = "GET";
1109
1110        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1111        let p = serde_json::json!({
1112            "jti":"n-reqeq-miss",
1113            "iat":now,
1114            "htm":expected_htm,
1115            "htu":expected_htu
1116        });
1117        let jws = make_jws(&sk, h, p);
1118
1119        let mut store = MemoryStore::default();
1120        let opts = VerifyOptions {
1121            max_age_seconds: 300,
1122            future_skew_seconds: 5,
1123            nonce_mode: NonceMode::RequireEqual {
1124                expected_nonce: "x".into(),
1125            },
1126            client_binding: None,
1127        };
1128        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1129            .await
1130            .unwrap_err();
1131        matches!(err, DpopError::MissingNonce);
1132    }
1133
1134    #[tokio::test]
1135    async fn nonce_require_equal_mismatch_yields_usedpopnonce() {
1136        let (sk, x, y) = gen_es256_key();
1137        let now = OffsetDateTime::now_utc().unix_timestamp();
1138        let expected_htu = "https://ex.com/a";
1139        let expected_htm = "GET";
1140
1141        let claim_nonce = "client-value";
1142        let expected_nonce = "server-expected";
1143        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1144        let p = serde_json::json!({
1145            "jti":"n-reqeq-mis",
1146            "iat":now,
1147            "htm":expected_htm,
1148            "htu":expected_htu,
1149            "nonce": claim_nonce
1150        });
1151        let jws = make_jws(&sk, h, p);
1152
1153        let mut store = MemoryStore::default();
1154        let opts = VerifyOptions {
1155            max_age_seconds: 300,
1156            future_skew_seconds: 5,
1157            nonce_mode: NonceMode::RequireEqual {
1158                expected_nonce: expected_nonce.into(),
1159            },
1160            client_binding: None,
1161        };
1162        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1163            .await
1164            .unwrap_err();
1165        // Server should respond with UseDpopNonce carrying a fresh/expected nonce
1166        if let DpopError::UseDpopNonce { nonce } = err {
1167            assert_eq!(nonce, expected_nonce);
1168        } else {
1169            panic!("expected UseDpopNonce, got {err:?}");
1170        }
1171    }
1172
1173    // -------------------------- Nonce: HMAC ------------------------------------
1174
1175    #[tokio::test]
1176    async fn nonce_hmac_ok_bound_all() {
1177        let (sk, x, y) = gen_es256_key();
1178        let now = OffsetDateTime::now_utc().unix_timestamp();
1179        let expected_htu = "https://ex.com/a";
1180        let expected_htm = "GET";
1181
1182        // Compute jkt from header jwk x/y to match verifier's jkt
1183        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1184
1185        let secret = SecretBox::from(b"supersecret".to_vec());
1186        let ctx = crate::nonce::NonceCtx {
1187            htu: Some(expected_htu),
1188            htm: Some(expected_htm),
1189            jkt: Some(&jkt),
1190            client: None,
1191        };
1192        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1193
1194        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1195        let p = serde_json::json!({
1196            "jti":"n-hmac-ok",
1197            "iat":now,
1198            "htm":expected_htm,
1199            "htu":expected_htu,
1200            "nonce": nonce
1201        });
1202        let jws = make_jws(&sk, h, p);
1203
1204        let mut store = MemoryStore::default();
1205        let opts = VerifyOptions {
1206            max_age_seconds: 300,
1207            future_skew_seconds: 5,
1208            nonce_mode: NonceMode::Hmac {
1209                secret: secret.clone(),
1210                max_age_seconds: 300,
1211                bind_htu_htm: true,
1212                bind_jkt: true,
1213                bind_client: false,
1214            },
1215            client_binding: None,
1216        };
1217        assert!(
1218            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1219                .await
1220                .is_ok()
1221        );
1222    }
1223
1224    #[tokio::test]
1225    async fn nonce_hmac_missing_claim_prompts_use_dpop_nonce() {
1226        let (sk, x, y) = gen_es256_key();
1227        let now = OffsetDateTime::now_utc().unix_timestamp();
1228        let expected_htu = "https://ex.com/a";
1229        let expected_htm = "GET";
1230
1231        let secret = SecretBox::from(b"supersecret".to_vec());
1232
1233        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1234        let p = serde_json::json!({
1235            "jti":"n-hmac-miss",
1236            "iat":now,
1237            "htm":expected_htm,
1238            "htu":expected_htu
1239        });
1240        let jws = make_jws(&sk, h, p);
1241
1242        let mut store = MemoryStore::default();
1243        let opts = VerifyOptions {
1244            max_age_seconds: 300,
1245            future_skew_seconds: 5,
1246            nonce_mode: NonceMode::Hmac {
1247                secret: secret.clone(),
1248                max_age_seconds: 300,
1249                bind_htu_htm: true,
1250                bind_jkt: true,
1251                bind_client: false,
1252            },
1253            client_binding: None,
1254        };
1255        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1256            .await
1257            .unwrap_err();
1258        matches!(err, DpopError::UseDpopNonce { .. });
1259    }
1260
1261    #[tokio::test]
1262    async fn nonce_hmac_wrong_htu_prompts_use_dpop_nonce() {
1263        let (sk, x, y) = gen_es256_key();
1264        let now = OffsetDateTime::now_utc().unix_timestamp();
1265        let expected_htm = "GET";
1266        let expected_htu = "https://ex.com/correct";
1267
1268        // Bind nonce to a different HTU to force mismatch
1269        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1270        let secret = SecretBox::from(b"k".to_vec());
1271        let ctx_wrong = crate::nonce::NonceCtx {
1272            htu: Some("https://ex.com/wrong"),
1273            htm: Some(expected_htm),
1274            jkt: Some(&jkt),
1275            client: None,
1276        };
1277        let nonce = issue_nonce(&secret, now, &ctx_wrong).expect("issue_nonce");
1278
1279        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1280        let p = serde_json::json!({
1281            "jti":"n-hmac-htu-mis",
1282            "iat":now,
1283            "htm":expected_htm,
1284            "htu":expected_htu,
1285            "nonce": nonce
1286        });
1287        let jws = make_jws(&sk, h, p);
1288
1289        let mut store = MemoryStore::default();
1290        let opts = VerifyOptions {
1291            max_age_seconds: 300,
1292            future_skew_seconds: 5,
1293            nonce_mode: NonceMode::Hmac {
1294                secret: secret.clone(),
1295                max_age_seconds: 300,
1296                bind_htu_htm: true,
1297                bind_jkt: true,
1298                bind_client: false,
1299            },
1300            client_binding: None,
1301        };
1302        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1303            .await
1304            .unwrap_err();
1305        matches!(err, DpopError::UseDpopNonce { .. });
1306    }
1307
1308    #[tokio::test]
1309    async fn nonce_hmac_wrong_jkt_prompts_use_dpop_nonce() {
1310        // Create two keys; mint nonce with jkt from key A, but sign proof with key B
1311        let (_sk_a, x_a, y_a) = gen_es256_key();
1312        let (sk_b, x_b, y_b) = gen_es256_key();
1313        let now = OffsetDateTime::now_utc().unix_timestamp();
1314        let expected_htu = "https://ex.com/a";
1315        let expected_htm = "GET";
1316
1317        let jkt_a = thumbprint_ec_p256(&x_a, &y_a).unwrap();
1318        let secret = SecretBox::from(b"secret-2".to_vec());
1319        let ctx = crate::nonce::NonceCtx {
1320            htu: Some(expected_htu),
1321            htm: Some(expected_htm),
1322            jkt: Some(&jkt_a), // bind nonce to A's jkt
1323            client: None,
1324        };
1325        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1326
1327        // Build proof with key B (=> jkt != jkt_a)
1328        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x_b,"y":y_b}});
1329        let p = serde_json::json!({
1330            "jti":"n-hmac-jkt-mis",
1331            "iat":now,
1332            "htm":expected_htm,
1333            "htu":expected_htu,
1334            "nonce": nonce
1335        });
1336        let jws = make_jws(&sk_b, h, p);
1337
1338        let mut store = MemoryStore::default();
1339        let opts = VerifyOptions {
1340            max_age_seconds: 300,
1341            future_skew_seconds: 5,
1342            nonce_mode: NonceMode::Hmac {
1343                secret: secret.clone(),
1344                max_age_seconds: 300,
1345                bind_htu_htm: true,
1346                bind_jkt: true,
1347                bind_client: false,
1348            },
1349            client_binding: None,
1350        };
1351        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1352            .await
1353            .unwrap_err();
1354        matches!(err, DpopError::UseDpopNonce { .. });
1355    }
1356
1357    #[tokio::test]
1358    async fn nonce_hmac_stale_prompts_use_dpop_nonce() {
1359        let (sk, x, y) = gen_es256_key();
1360        let now = OffsetDateTime::now_utc().unix_timestamp();
1361        let expected_htu = "https://ex.com/a";
1362        let expected_htm = "GET";
1363
1364        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1365        let secret = SecretBox::from(b"secret-3".to_vec());
1366        // Issue with ts older than max_age
1367        let issued_ts = now - 400;
1368        let nonce = issue_nonce(
1369            &secret,
1370            issued_ts,
1371            &crate::nonce::NonceCtx {
1372                htu: Some(expected_htu),
1373                htm: Some(expected_htm),
1374                jkt: Some(&jkt),
1375                client: None,
1376            },
1377        )
1378        .expect("issue_nonce");
1379
1380        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1381        let p = serde_json::json!({
1382            "jti":"n-hmac-stale",
1383            "iat":now,
1384            "htm":expected_htm,
1385            "htu":expected_htu,
1386            "nonce": nonce
1387        });
1388        let jws = make_jws(&sk, h, p);
1389
1390        let mut store = MemoryStore::default();
1391        let opts = VerifyOptions {
1392            max_age_seconds: 300,
1393            future_skew_seconds: 5,
1394            nonce_mode: NonceMode::Hmac {
1395                secret: secret.clone(),
1396                max_age_seconds: 300,
1397                bind_htu_htm: true,
1398                bind_jkt: true,
1399                bind_client: false,
1400            },
1401            client_binding: None,
1402        };
1403        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1404            .await
1405            .unwrap_err();
1406        matches!(err, DpopError::UseDpopNonce { .. });
1407    }
1408
1409    #[tokio::test]
1410    async fn nonce_hmac_future_skew_prompts_use_dpop_nonce() {
1411        let (sk, x, y) = gen_es256_key();
1412        let now = OffsetDateTime::now_utc().unix_timestamp();
1413        let expected_htu = "https://ex.com/a";
1414        let expected_htm = "GET";
1415
1416        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1417        let secret = SecretBox::from(b"secret-4".to_vec());
1418        // Issue with ts in the future beyond 5s tolerance
1419        let issued_ts = now + 10;
1420        let nonce = issue_nonce(
1421            &secret,
1422            issued_ts,
1423            &crate::nonce::NonceCtx {
1424                htu: Some(expected_htu),
1425                htm: Some(expected_htm),
1426                jkt: Some(&jkt),
1427                client: None,
1428            },
1429        )
1430        .expect("issue_nonce");
1431
1432        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1433        let p = serde_json::json!({
1434            "jti":"n-hmac-future",
1435            "iat":now,
1436            "htm":expected_htm,
1437            "htu":expected_htu,
1438            "nonce": nonce
1439        });
1440        let jws = make_jws(&sk, h, p);
1441
1442        let mut store = MemoryStore::default();
1443        let opts = VerifyOptions {
1444            max_age_seconds: 300,
1445            future_skew_seconds: 5,
1446            nonce_mode: NonceMode::Hmac {
1447                secret: secret.clone(),
1448                max_age_seconds: 300,
1449                bind_htu_htm: true,
1450                bind_jkt: true,
1451                bind_client: false,
1452            },
1453            client_binding: None,
1454        };
1455        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1456            .await
1457            .unwrap_err();
1458        matches!(err, DpopError::UseDpopNonce { .. });
1459    }
1460
1461    #[tokio::test]
1462    async fn nonce_hmac_client_binding_ok() {
1463        let (sk, x, y) = gen_es256_key();
1464        let now = OffsetDateTime::now_utc().unix_timestamp();
1465        let expected_htu = "https://ex.com/a";
1466        let expected_htm = "GET";
1467        let client_id = "client-123";
1468
1469        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1470        let secret = SecretBox::from(b"secret-client".to_vec());
1471        let ctx = crate::nonce::NonceCtx {
1472            htu: Some(expected_htu),
1473            htm: Some(expected_htm),
1474            jkt: Some(&jkt),
1475            client: Some(client_id),
1476        };
1477        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1478
1479        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1480        let p = serde_json::json!({
1481            "jti":"n-hmac-client-ok",
1482            "iat":now,
1483            "htm":expected_htm,
1484            "htu":expected_htu,
1485            "nonce": nonce
1486        });
1487        let jws = make_jws(&sk, h, p);
1488
1489        let mut store = MemoryStore::default();
1490        let opts = VerifyOptions {
1491            max_age_seconds: 300,
1492            future_skew_seconds: 5,
1493            nonce_mode: NonceMode::Hmac {
1494                secret: secret.clone(),
1495                max_age_seconds: 300,
1496                bind_htu_htm: true,
1497                bind_jkt: true,
1498                bind_client: true,
1499            },
1500            client_binding: Some(ClientBinding::new(client_id)),
1501        };
1502        assert!(
1503            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1504                .await
1505                .is_ok()
1506        );
1507    }
1508
1509    #[tokio::test]
1510    async fn nonce_hmac_client_binding_mismatch_prompts_use_dpop_nonce() {
1511        let (sk, x, y) = gen_es256_key();
1512        let now = OffsetDateTime::now_utc().unix_timestamp();
1513        let expected_htu = "https://ex.com/a";
1514        let expected_htm = "GET";
1515        let issue_client_id = "client-issuer";
1516        let verify_client_id = "client-verifier";
1517
1518        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1519        let secret = SecretBox::from(b"secret-client-mismatch".to_vec());
1520        let ctx = crate::nonce::NonceCtx {
1521            htu: Some(expected_htu),
1522            htm: Some(expected_htm),
1523            jkt: Some(&jkt),
1524            client: Some(issue_client_id),
1525        };
1526        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1527
1528        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1529        let p = serde_json::json!({
1530            "jti":"n-hmac-client-mismatch",
1531            "iat":now,
1532            "htm":expected_htm,
1533            "htu":expected_htu,
1534            "nonce": nonce
1535        });
1536        let jws = make_jws(&sk, h, p);
1537
1538        let mut store = MemoryStore::default();
1539        let opts = VerifyOptions {
1540            max_age_seconds: 300,
1541            future_skew_seconds: 5,
1542            nonce_mode: NonceMode::Hmac {
1543                secret: secret.clone(),
1544                max_age_seconds: 300,
1545                bind_htu_htm: true,
1546                bind_jkt: true,
1547                bind_client: true,
1548            },
1549            client_binding: Some(ClientBinding::new(verify_client_id)),
1550        };
1551        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1552            .await
1553            .unwrap_err();
1554        if let DpopError::UseDpopNonce { nonce: new_nonce } = err {
1555            // Response nonce should be bound to the verifier's client binding
1556            let retry_ctx = crate::nonce::NonceCtx {
1557                htu: Some(expected_htu),
1558                htm: Some(expected_htm),
1559                jkt: Some(&jkt),
1560                client: Some(verify_client_id),
1561            };
1562            assert!(
1563                crate::nonce::verify_nonce(&secret, &new_nonce, now, 300, &retry_ctx).is_ok(),
1564                "returned nonce should bind to verifier client id"
1565            );
1566        } else {
1567            panic!("expected UseDpopNonce, got {err:?}");
1568        }
1569    }
1570
1571    #[cfg(feature = "eddsa")]
1572    mod eddsa_tests {
1573        use super::*;
1574        use ed25519_dalek::Signer;
1575        use ed25519_dalek::{Signature as EdSig, SigningKey as EdSk, VerifyingKey as EdVk};
1576        use rand_core::OsRng;
1577
1578        fn gen_ed25519() -> (EdSk, String) {
1579            let sk = EdSk::generate(&mut OsRng);
1580            let vk = EdVk::from(&sk);
1581            let x_b64 = B64.encode(vk.as_bytes()); // 32-byte public key
1582            (sk, x_b64)
1583        }
1584
1585        fn make_jws_ed(sk: &EdSk, header: serde_json::Value, claims: serde_json::Value) -> String {
1586            let h = serde_json::to_vec(&header).unwrap();
1587            let p = serde_json::to_vec(&claims).unwrap();
1588            let h_b64 = B64.encode(h);
1589            let p_b64 = B64.encode(p);
1590            let signing_input = format!("{h_b64}.{p_b64}");
1591            let sig: EdSig = sk.sign(signing_input.as_bytes());
1592            let s_b64 = B64.encode(sig.to_bytes());
1593            format!("{h_b64}.{p_b64}.{s_b64}")
1594        }
1595
1596        #[tokio::test]
1597        async fn verify_valid_eddsa_proof() {
1598            let (sk, x) = gen_ed25519();
1599            let now = OffsetDateTime::now_utc().unix_timestamp();
1600            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"OKP","crv":"Ed25519","x":x}});
1601            let p =
1602                serde_json::json!({"jti":"ed-ok","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1603            let jws = make_jws_ed(&sk, h, p);
1604
1605            let mut store = super::MemoryStore::default();
1606            assert!(verify_proof(
1607                &mut store,
1608                &jws,
1609                "https://ex.com/a",
1610                "GET",
1611                None,
1612                VerifyOptions::default(),
1613            )
1614            .await
1615            .is_ok());
1616        }
1617
1618        #[tokio::test]
1619        async fn eddsa_wrong_jwk_type_rejected() {
1620            let (sk, x) = gen_ed25519();
1621            let now = OffsetDateTime::now_utc().unix_timestamp();
1622            // bad: kty/crv don't match EdDSA expectations
1623            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"EC","crv":"P-256","x":x,"y":x}});
1624            let p = serde_json::json!({"jti":"ed-badjwk","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1625            let jws = make_jws_ed(&sk, h, p);
1626
1627            let mut store = super::MemoryStore::default();
1628            let err = verify_proof(
1629                &mut store,
1630                &jws,
1631                "https://ex.com/a",
1632                "GET",
1633                None,
1634                VerifyOptions::default(),
1635            )
1636            .await
1637            .unwrap_err();
1638            matches!(err, DpopError::BadJwk(_));
1639        }
1640
1641        #[tokio::test]
1642        async fn eddsa_signature_tamper_detected() {
1643            let (sk, x) = gen_ed25519();
1644            let now = OffsetDateTime::now_utc().unix_timestamp();
1645            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"OKP","crv":"Ed25519","x":x}});
1646            let p = serde_json::json!({"jti":"ed-tamper","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1647            let mut jws = make_jws_ed(&sk, h, p);
1648            // flip a byte in the header part (remain base64url-ish length)
1649            unsafe {
1650                let bytes = jws.as_bytes_mut();
1651                for i in 10..(bytes.len().min(40)) {
1652                    bytes[i] ^= 1;
1653                    break;
1654                }
1655            }
1656            let mut store = super::MemoryStore::default();
1657            let err = verify_proof(
1658                &mut store,
1659                &jws,
1660                "https://ex.com/a",
1661                "GET",
1662                None,
1663                VerifyOptions::default(),
1664            )
1665            .await
1666            .unwrap_err();
1667            matches!(err, DpopError::InvalidSignature);
1668        }
1669    }
1670}