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: std::sync::Arc<[u8]>, // server secret
51        max_age_secs: i64,            // window (e.g., 300)
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_secs: i64,
61    pub future_skew_secs: 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_secs: 300,
69            future_skew_secs: 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
151    pub fn with_max_age(mut self, max_age_secs: i64) -> Self {
152        self.options.max_age_secs = max_age_secs;
153        self
154    }
155
156    /// Set the future skew tolerance
157    pub fn with_future_skew(mut self, future_skew_secs: i64) -> Self {
158        self.options.future_skew_secs = future_skew_secs;
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_secs {
394            return Err(DpopError::FutureSkew);
395        }
396        if current_time - iat > self.options.max_age_secs {
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_secs,
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_secs,
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 std::sync::Arc;
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 p_future =
894            serde_json::json!({"jti":"jf","iat":now + 6,"htm":"GET","htu":"https://ex.com/a"});
895        let jws_future = make_jws(&sk, h.clone(), p_future);
896        let mut store1 = MemoryStore::default();
897        let opts = VerifyOptions {
898            max_age_secs: 300,
899            future_skew_secs: 5,
900            nonce_mode: NonceMode::Disabled,
901            client_binding: None,
902        };
903        let err = verify_proof(
904            &mut store1,
905            &jws_future,
906            "https://ex.com/a",
907            "GET",
908            None,
909            opts,
910        )
911        .await
912        .unwrap_err();
913        matches!(err, DpopError::FutureSkew);
914
915        // Stale just over limit
916        let p_stale =
917            serde_json::json!({"jti":"js","iat":now - 301,"htm":"GET","htu":"https://ex.com/a"});
918        let jws_stale = make_jws(&sk, h.clone(), p_stale);
919        let mut store2 = MemoryStore::default();
920        let opts = VerifyOptions {
921            max_age_secs: 300,
922            future_skew_secs: 5,
923            nonce_mode: NonceMode::Disabled,
924            client_binding: None,
925        };
926        let err = verify_proof(
927            &mut store2,
928            &jws_stale,
929            "https://ex.com/a",
930            "GET",
931            None,
932            opts,
933        )
934        .await
935        .unwrap_err();
936        matches!(err, DpopError::Stale);
937    }
938
939    #[tokio::test]
940    async fn replay_same_jti_is_rejected() {
941        let (sk, x, y) = gen_es256_key();
942        let now = OffsetDateTime::now_utc().unix_timestamp();
943        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
944        let p = serde_json::json!({"jti":"jr","iat":now,"htm":"GET","htu":"https://ex.com/a"});
945        let jws = make_jws(&sk, h, p);
946
947        let mut store = MemoryStore::default();
948        let ok1 = verify_proof(
949            &mut store,
950            &jws,
951            "https://ex.com/a",
952            "GET",
953            None,
954            VerifyOptions::default(),
955        )
956        .await;
957        assert!(ok1.is_ok());
958        let err = verify_proof(
959            &mut store,
960            &jws,
961            "https://ex.com/a",
962            "GET",
963            None,
964            VerifyOptions::default(),
965        )
966        .await
967        .unwrap_err();
968        matches!(err, DpopError::Replay);
969    }
970
971    #[tokio::test]
972    async fn signature_tamper_detected() {
973        let (sk, x, y) = gen_es256_key();
974        let now = OffsetDateTime::now_utc().unix_timestamp();
975        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
976        let p = serde_json::json!({"jti":"jt","iat":now,"htm":"GET","htu":"https://ex.com/a"});
977        let mut jws = make_jws(&sk, h, p);
978
979        // Flip one byte in the payload section (keep base64url valid length)
980        let bytes = unsafe { jws.as_bytes_mut() }; // alternative: rebuild string
981                                                   // Find the second '.' and flip a safe ASCII char before it
982        let mut dot_count = 0usize;
983        for i in 0..bytes.len() {
984            if bytes[i] == b'.' {
985                dot_count += 1;
986                if dot_count == 2 && i > 10 {
987                    bytes[i - 5] ^= 0x01; // tiny flip
988                    break;
989                }
990            }
991        }
992
993        let mut store = MemoryStore::default();
994        let err = verify_proof(
995            &mut store,
996            &jws,
997            "https://ex.com/a",
998            "GET",
999            None,
1000            VerifyOptions::default(),
1001        )
1002        .await
1003        .unwrap_err();
1004        matches!(err, DpopError::InvalidSignature);
1005    }
1006
1007    #[tokio::test]
1008    async fn method_mismatch_rejected() {
1009        let (sk, x, y) = gen_es256_key();
1010        let now = OffsetDateTime::now_utc().unix_timestamp();
1011        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1012        let p = serde_json::json!({"jti":"jm","iat":now,"htm":"POST","htu":"https://ex.com/a"});
1013        let jws = make_jws(&sk, h, p);
1014
1015        let mut store = MemoryStore::default();
1016        let err = verify_proof(
1017            &mut store,
1018            &jws,
1019            "https://ex.com/a",
1020            "GET",
1021            None,
1022            VerifyOptions::default(),
1023        )
1024        .await
1025        .unwrap_err();
1026        matches!(err, DpopError::HtmMismatch);
1027    }
1028
1029    #[test]
1030    fn normalize_helpers_examples() {
1031        // sanity checks for helpers used by verify_proof
1032        assert_eq!(
1033            normalize_htu("https://EX.com:443/a/./b/../c?x=1#frag").unwrap(),
1034            "https://ex.com/a/c"
1035        );
1036        assert_eq!(normalize_method("get").unwrap(), "GET");
1037        assert!(normalize_method("CUSTOM").is_err());
1038    }
1039
1040    #[tokio::test]
1041    async fn jti_too_long_rejected() {
1042        let (sk, x, y) = gen_es256_key();
1043        let now = OffsetDateTime::now_utc().unix_timestamp();
1044        let too_long = "x".repeat(513);
1045        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1046        let p = serde_json::json!({"jti":too_long,"iat":now,"htm":"GET","htu":"https://ex.com/a"});
1047        let jws = make_jws(&sk, h, p);
1048
1049        let mut store = MemoryStore::default();
1050        let err = verify_proof(
1051            &mut store,
1052            &jws,
1053            "https://ex.com/a",
1054            "GET",
1055            None,
1056            VerifyOptions::default(),
1057        )
1058        .await
1059        .unwrap_err();
1060        matches!(err, DpopError::JtiTooLong);
1061    }
1062    // ----------------------- Nonce: RequireEqual -------------------------------
1063
1064    #[tokio::test]
1065    async fn nonce_require_equal_ok() {
1066        let (sk, x, y) = gen_es256_key();
1067        let now = OffsetDateTime::now_utc().unix_timestamp();
1068        let expected_htu = "https://ex.com/a";
1069        let expected_htm = "GET";
1070
1071        let expected_nonce = "nonce-123";
1072        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1073        let p = serde_json::json!({
1074            "jti":"n-reqeq-ok",
1075            "iat":now,
1076            "htm":expected_htm,
1077            "htu":expected_htu,
1078            "nonce": expected_nonce
1079        });
1080        let jws = make_jws(&sk, h, p);
1081
1082        let mut store = MemoryStore::default();
1083        let opts = VerifyOptions {
1084            max_age_secs: 300,
1085            future_skew_secs: 5,
1086            nonce_mode: NonceMode::RequireEqual {
1087                expected_nonce: expected_nonce.to_string(),
1088            },
1089            client_binding: None,
1090        };
1091        assert!(
1092            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1093                .await
1094                .is_ok()
1095        );
1096    }
1097
1098    #[tokio::test]
1099    async fn nonce_require_equal_missing_claim() {
1100        let (sk, x, y) = gen_es256_key();
1101        let now = OffsetDateTime::now_utc().unix_timestamp();
1102        let expected_htu = "https://ex.com/a";
1103        let expected_htm = "GET";
1104
1105        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1106        let p = serde_json::json!({
1107            "jti":"n-reqeq-miss",
1108            "iat":now,
1109            "htm":expected_htm,
1110            "htu":expected_htu
1111        });
1112        let jws = make_jws(&sk, h, p);
1113
1114        let mut store = MemoryStore::default();
1115        let opts = VerifyOptions {
1116            max_age_secs: 300,
1117            future_skew_secs: 5,
1118            nonce_mode: NonceMode::RequireEqual {
1119                expected_nonce: "x".into(),
1120            },
1121            client_binding: None,
1122        };
1123        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1124            .await
1125            .unwrap_err();
1126        matches!(err, DpopError::MissingNonce);
1127    }
1128
1129    #[tokio::test]
1130    async fn nonce_require_equal_mismatch_yields_usedpopnonce() {
1131        let (sk, x, y) = gen_es256_key();
1132        let now = OffsetDateTime::now_utc().unix_timestamp();
1133        let expected_htu = "https://ex.com/a";
1134        let expected_htm = "GET";
1135
1136        let claim_nonce = "client-value";
1137        let expected_nonce = "server-expected";
1138        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1139        let p = serde_json::json!({
1140            "jti":"n-reqeq-mis",
1141            "iat":now,
1142            "htm":expected_htm,
1143            "htu":expected_htu,
1144            "nonce": claim_nonce
1145        });
1146        let jws = make_jws(&sk, h, p);
1147
1148        let mut store = MemoryStore::default();
1149        let opts = VerifyOptions {
1150            max_age_secs: 300,
1151            future_skew_secs: 5,
1152            nonce_mode: NonceMode::RequireEqual {
1153                expected_nonce: expected_nonce.into(),
1154            },
1155            client_binding: None,
1156        };
1157        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1158            .await
1159            .unwrap_err();
1160        // Server should respond with UseDpopNonce carrying a fresh/expected nonce
1161        if let DpopError::UseDpopNonce { nonce } = err {
1162            assert_eq!(nonce, expected_nonce);
1163        } else {
1164            panic!("expected UseDpopNonce, got {err:?}");
1165        }
1166    }
1167
1168    // -------------------------- Nonce: HMAC ------------------------------------
1169
1170    #[tokio::test]
1171    async fn nonce_hmac_ok_bound_all() {
1172        let (sk, x, y) = gen_es256_key();
1173        let now = OffsetDateTime::now_utc().unix_timestamp();
1174        let expected_htu = "https://ex.com/a";
1175        let expected_htm = "GET";
1176
1177        // Compute jkt from header jwk x/y to match verifier's jkt
1178        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1179
1180        let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
1181        let ctx = crate::nonce::NonceCtx {
1182            htu: Some(expected_htu),
1183            htm: Some(expected_htm),
1184            jkt: Some(&jkt),
1185            client: None,
1186        };
1187        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1188
1189        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1190        let p = serde_json::json!({
1191            "jti":"n-hmac-ok",
1192            "iat":now,
1193            "htm":expected_htm,
1194            "htu":expected_htu,
1195            "nonce": nonce
1196        });
1197        let jws = make_jws(&sk, h, p);
1198
1199        let mut store = MemoryStore::default();
1200        let opts = VerifyOptions {
1201            max_age_secs: 300,
1202            future_skew_secs: 5,
1203            nonce_mode: NonceMode::Hmac {
1204                secret: secret.clone(),
1205                max_age_secs: 300,
1206                bind_htu_htm: true,
1207                bind_jkt: true,
1208                bind_client: false,
1209            },
1210            client_binding: None,
1211        };
1212        assert!(
1213            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1214                .await
1215                .is_ok()
1216        );
1217    }
1218
1219    #[tokio::test]
1220    async fn nonce_hmac_missing_claim_prompts_use_dpop_nonce() {
1221        let (sk, x, y) = gen_es256_key();
1222        let now = OffsetDateTime::now_utc().unix_timestamp();
1223        let expected_htu = "https://ex.com/a";
1224        let expected_htm = "GET";
1225
1226        let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
1227
1228        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1229        let p = serde_json::json!({
1230            "jti":"n-hmac-miss",
1231            "iat":now,
1232            "htm":expected_htm,
1233            "htu":expected_htu
1234        });
1235        let jws = make_jws(&sk, h, p);
1236
1237        let mut store = MemoryStore::default();
1238        let opts = VerifyOptions {
1239            max_age_secs: 300,
1240            future_skew_secs: 5,
1241            nonce_mode: NonceMode::Hmac {
1242                secret: secret.clone(),
1243                max_age_secs: 300,
1244                bind_htu_htm: true,
1245                bind_jkt: true,
1246                bind_client: false,
1247            },
1248            client_binding: None,
1249        };
1250        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1251            .await
1252            .unwrap_err();
1253        matches!(err, DpopError::UseDpopNonce { .. });
1254    }
1255
1256    #[tokio::test]
1257    async fn nonce_hmac_wrong_htu_prompts_use_dpop_nonce() {
1258        let (sk, x, y) = gen_es256_key();
1259        let now = OffsetDateTime::now_utc().unix_timestamp();
1260        let expected_htm = "GET";
1261        let expected_htu = "https://ex.com/correct";
1262
1263        // Bind nonce to a different HTU to force mismatch
1264        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1265        let secret: Arc<[u8]> = Arc::from(&b"k"[..]);
1266        let ctx_wrong = crate::nonce::NonceCtx {
1267            htu: Some("https://ex.com/wrong"),
1268            htm: Some(expected_htm),
1269            jkt: Some(&jkt),
1270            client: None,
1271        };
1272        let nonce = issue_nonce(&secret, now, &ctx_wrong).expect("issue_nonce");
1273
1274        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1275        let p = serde_json::json!({
1276            "jti":"n-hmac-htu-mis",
1277            "iat":now,
1278            "htm":expected_htm,
1279            "htu":expected_htu,
1280            "nonce": nonce
1281        });
1282        let jws = make_jws(&sk, h, p);
1283
1284        let mut store = MemoryStore::default();
1285        let opts = VerifyOptions {
1286            max_age_secs: 300,
1287            future_skew_secs: 5,
1288            nonce_mode: NonceMode::Hmac {
1289                secret: secret.clone(),
1290                max_age_secs: 300,
1291                bind_htu_htm: true,
1292                bind_jkt: true,
1293                bind_client: false,
1294            },
1295            client_binding: None,
1296        };
1297        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1298            .await
1299            .unwrap_err();
1300        matches!(err, DpopError::UseDpopNonce { .. });
1301    }
1302
1303    #[tokio::test]
1304    async fn nonce_hmac_wrong_jkt_prompts_use_dpop_nonce() {
1305        // Create two keys; mint nonce with jkt from key A, but sign proof with key B
1306        let (_sk_a, x_a, y_a) = gen_es256_key();
1307        let (sk_b, x_b, y_b) = gen_es256_key();
1308        let now = OffsetDateTime::now_utc().unix_timestamp();
1309        let expected_htu = "https://ex.com/a";
1310        let expected_htm = "GET";
1311
1312        let jkt_a = thumbprint_ec_p256(&x_a, &y_a).unwrap();
1313        let secret: Arc<[u8]> = Arc::from(&b"secret-2"[..]);
1314        let ctx = crate::nonce::NonceCtx {
1315            htu: Some(expected_htu),
1316            htm: Some(expected_htm),
1317            jkt: Some(&jkt_a), // bind nonce to A's jkt
1318            client: None,
1319        };
1320        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1321
1322        // Build proof with key B (=> jkt != jkt_a)
1323        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x_b,"y":y_b}});
1324        let p = serde_json::json!({
1325            "jti":"n-hmac-jkt-mis",
1326            "iat":now,
1327            "htm":expected_htm,
1328            "htu":expected_htu,
1329            "nonce": nonce
1330        });
1331        let jws = make_jws(&sk_b, h, p);
1332
1333        let mut store = MemoryStore::default();
1334        let opts = VerifyOptions {
1335            max_age_secs: 300,
1336            future_skew_secs: 5,
1337            nonce_mode: NonceMode::Hmac {
1338                secret: secret.clone(),
1339                max_age_secs: 300,
1340                bind_htu_htm: true,
1341                bind_jkt: true,
1342                bind_client: false,
1343            },
1344            client_binding: None,
1345        };
1346        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1347            .await
1348            .unwrap_err();
1349        matches!(err, DpopError::UseDpopNonce { .. });
1350    }
1351
1352    #[tokio::test]
1353    async fn nonce_hmac_stale_prompts_use_dpop_nonce() {
1354        let (sk, x, y) = gen_es256_key();
1355        let now = OffsetDateTime::now_utc().unix_timestamp();
1356        let expected_htu = "https://ex.com/a";
1357        let expected_htm = "GET";
1358
1359        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1360        let secret: Arc<[u8]> = Arc::from(&b"secret-3"[..]);
1361        // Issue with ts older than max_age
1362        let issued_ts = now - 400;
1363        let nonce = issue_nonce(
1364            &secret,
1365            issued_ts,
1366            &crate::nonce::NonceCtx {
1367                htu: Some(expected_htu),
1368                htm: Some(expected_htm),
1369                jkt: Some(&jkt),
1370                client: None,
1371            },
1372        )
1373        .expect("issue_nonce");
1374
1375        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1376        let p = serde_json::json!({
1377            "jti":"n-hmac-stale",
1378            "iat":now,
1379            "htm":expected_htm,
1380            "htu":expected_htu,
1381            "nonce": nonce
1382        });
1383        let jws = make_jws(&sk, h, p);
1384
1385        let mut store = MemoryStore::default();
1386        let opts = VerifyOptions {
1387            max_age_secs: 300,
1388            future_skew_secs: 5,
1389            nonce_mode: NonceMode::Hmac {
1390                secret: secret.clone(),
1391                max_age_secs: 300,
1392                bind_htu_htm: true,
1393                bind_jkt: true,
1394                bind_client: false,
1395            },
1396            client_binding: None,
1397        };
1398        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1399            .await
1400            .unwrap_err();
1401        matches!(err, DpopError::UseDpopNonce { .. });
1402    }
1403
1404    #[tokio::test]
1405    async fn nonce_hmac_future_skew_prompts_use_dpop_nonce() {
1406        let (sk, x, y) = gen_es256_key();
1407        let now = OffsetDateTime::now_utc().unix_timestamp();
1408        let expected_htu = "https://ex.com/a";
1409        let expected_htm = "GET";
1410
1411        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1412        let secret: Arc<[u8]> = Arc::from(&b"secret-4"[..]);
1413        // Issue with ts in the future beyond 5s tolerance
1414        let issued_ts = now + 10;
1415        let nonce = issue_nonce(
1416            &secret,
1417            issued_ts,
1418            &crate::nonce::NonceCtx {
1419                htu: Some(expected_htu),
1420                htm: Some(expected_htm),
1421                jkt: Some(&jkt),
1422                client: None,
1423            },
1424        )
1425        .expect("issue_nonce");
1426
1427        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1428        let p = serde_json::json!({
1429            "jti":"n-hmac-future",
1430            "iat":now,
1431            "htm":expected_htm,
1432            "htu":expected_htu,
1433            "nonce": nonce
1434        });
1435        let jws = make_jws(&sk, h, p);
1436
1437        let mut store = MemoryStore::default();
1438        let opts = VerifyOptions {
1439            max_age_secs: 300,
1440            future_skew_secs: 5,
1441            nonce_mode: NonceMode::Hmac {
1442                secret: secret.clone(),
1443                max_age_secs: 300,
1444                bind_htu_htm: true,
1445                bind_jkt: true,
1446                bind_client: false,
1447            },
1448            client_binding: None,
1449        };
1450        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1451            .await
1452            .unwrap_err();
1453        matches!(err, DpopError::UseDpopNonce { .. });
1454    }
1455
1456    #[tokio::test]
1457    async fn nonce_hmac_client_binding_ok() {
1458        let (sk, x, y) = gen_es256_key();
1459        let now = OffsetDateTime::now_utc().unix_timestamp();
1460        let expected_htu = "https://ex.com/a";
1461        let expected_htm = "GET";
1462        let client_id = "client-123";
1463
1464        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1465        let secret: Arc<[u8]> = Arc::from(&b"secret-client"[..]);
1466        let ctx = crate::nonce::NonceCtx {
1467            htu: Some(expected_htu),
1468            htm: Some(expected_htm),
1469            jkt: Some(&jkt),
1470            client: Some(client_id),
1471        };
1472        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1473
1474        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1475        let p = serde_json::json!({
1476            "jti":"n-hmac-client-ok",
1477            "iat":now,
1478            "htm":expected_htm,
1479            "htu":expected_htu,
1480            "nonce": nonce
1481        });
1482        let jws = make_jws(&sk, h, p);
1483
1484        let mut store = MemoryStore::default();
1485        let opts = VerifyOptions {
1486            max_age_secs: 300,
1487            future_skew_secs: 5,
1488            nonce_mode: NonceMode::Hmac {
1489                secret: secret.clone(),
1490                max_age_secs: 300,
1491                bind_htu_htm: true,
1492                bind_jkt: true,
1493                bind_client: true,
1494            },
1495            client_binding: Some(ClientBinding::new(client_id)),
1496        };
1497        assert!(
1498            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1499                .await
1500                .is_ok()
1501        );
1502    }
1503
1504    #[tokio::test]
1505    async fn nonce_hmac_client_binding_mismatch_prompts_use_dpop_nonce() {
1506        let (sk, x, y) = gen_es256_key();
1507        let now = OffsetDateTime::now_utc().unix_timestamp();
1508        let expected_htu = "https://ex.com/a";
1509        let expected_htm = "GET";
1510        let issue_client_id = "client-issuer";
1511        let verify_client_id = "client-verifier";
1512
1513        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1514        let secret: Arc<[u8]> = Arc::from(&b"secret-client-mismatch"[..]);
1515        let ctx = crate::nonce::NonceCtx {
1516            htu: Some(expected_htu),
1517            htm: Some(expected_htm),
1518            jkt: Some(&jkt),
1519            client: Some(issue_client_id),
1520        };
1521        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1522
1523        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1524        let p = serde_json::json!({
1525            "jti":"n-hmac-client-mismatch",
1526            "iat":now,
1527            "htm":expected_htm,
1528            "htu":expected_htu,
1529            "nonce": nonce
1530        });
1531        let jws = make_jws(&sk, h, p);
1532
1533        let mut store = MemoryStore::default();
1534        let opts = VerifyOptions {
1535            max_age_secs: 300,
1536            future_skew_secs: 5,
1537            nonce_mode: NonceMode::Hmac {
1538                secret: secret.clone(),
1539                max_age_secs: 300,
1540                bind_htu_htm: true,
1541                bind_jkt: true,
1542                bind_client: true,
1543            },
1544            client_binding: Some(ClientBinding::new(verify_client_id)),
1545        };
1546        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1547            .await
1548            .unwrap_err();
1549        if let DpopError::UseDpopNonce { nonce: new_nonce } = err {
1550            // Response nonce should be bound to the verifier's client binding
1551            let retry_ctx = crate::nonce::NonceCtx {
1552                htu: Some(expected_htu),
1553                htm: Some(expected_htm),
1554                jkt: Some(&jkt),
1555                client: Some(verify_client_id),
1556            };
1557            assert!(
1558                crate::nonce::verify_nonce(&secret, &new_nonce, now, 300, &retry_ctx).is_ok(),
1559                "returned nonce should bind to verifier client id"
1560            );
1561        } else {
1562            panic!("expected UseDpopNonce, got {err:?}");
1563        }
1564    }
1565
1566    #[cfg(feature = "eddsa")]
1567    mod eddsa_tests {
1568        use super::*;
1569        use ed25519_dalek::Signer;
1570        use ed25519_dalek::{Signature as EdSig, SigningKey as EdSk, VerifyingKey as EdVk};
1571        use rand_core::OsRng;
1572
1573        fn gen_ed25519() -> (EdSk, String) {
1574            let sk = EdSk::generate(&mut OsRng);
1575            let vk = EdVk::from(&sk);
1576            let x_b64 = B64.encode(vk.as_bytes()); // 32-byte public key
1577            (sk, x_b64)
1578        }
1579
1580        fn make_jws_ed(sk: &EdSk, header: serde_json::Value, claims: serde_json::Value) -> String {
1581            let h = serde_json::to_vec(&header).unwrap();
1582            let p = serde_json::to_vec(&claims).unwrap();
1583            let h_b64 = B64.encode(h);
1584            let p_b64 = B64.encode(p);
1585            let signing_input = format!("{h_b64}.{p_b64}");
1586            let sig: EdSig = sk.sign(signing_input.as_bytes());
1587            let s_b64 = B64.encode(sig.to_bytes());
1588            format!("{h_b64}.{p_b64}.{s_b64}")
1589        }
1590
1591        #[tokio::test]
1592        async fn verify_valid_eddsa_proof() {
1593            let (sk, x) = gen_ed25519();
1594            let now = OffsetDateTime::now_utc().unix_timestamp();
1595            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"OKP","crv":"Ed25519","x":x}});
1596            let p =
1597                serde_json::json!({"jti":"ed-ok","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1598            let jws = make_jws_ed(&sk, h, p);
1599
1600            let mut store = super::MemoryStore::default();
1601            assert!(verify_proof(
1602                &mut store,
1603                &jws,
1604                "https://ex.com/a",
1605                "GET",
1606                None,
1607                VerifyOptions::default(),
1608            )
1609            .await
1610            .is_ok());
1611        }
1612
1613        #[tokio::test]
1614        async fn eddsa_wrong_jwk_type_rejected() {
1615            let (sk, x) = gen_ed25519();
1616            let now = OffsetDateTime::now_utc().unix_timestamp();
1617            // bad: kty/crv don't match EdDSA expectations
1618            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"EC","crv":"P-256","x":x,"y":x}});
1619            let p = serde_json::json!({"jti":"ed-badjwk","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1620            let jws = make_jws_ed(&sk, h, p);
1621
1622            let mut store = super::MemoryStore::default();
1623            let err = verify_proof(
1624                &mut store,
1625                &jws,
1626                "https://ex.com/a",
1627                "GET",
1628                None,
1629                VerifyOptions::default(),
1630            )
1631            .await
1632            .unwrap_err();
1633            matches!(err, DpopError::BadJwk(_));
1634        }
1635
1636        #[tokio::test]
1637        async fn eddsa_signature_tamper_detected() {
1638            let (sk, x) = gen_ed25519();
1639            let now = OffsetDateTime::now_utc().unix_timestamp();
1640            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"OKP","crv":"Ed25519","x":x}});
1641            let p = serde_json::json!({"jti":"ed-tamper","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1642            let mut jws = make_jws_ed(&sk, h, p);
1643            // flip a byte in the header part (remain base64url-ish length)
1644            unsafe {
1645                let bytes = jws.as_bytes_mut();
1646                for i in 10..(bytes.len().min(40)) {
1647                    bytes[i] ^= 1;
1648                    break;
1649                }
1650            }
1651            let mut store = super::MemoryStore::default();
1652            let err = verify_proof(
1653                &mut store,
1654                &jws,
1655                "https://ex.com/a",
1656                "GET",
1657                None,
1658                VerifyOptions::default(),
1659            )
1660            .await
1661            .unwrap_err();
1662            matches!(err, DpopError::InvalidSignature);
1663        }
1664    }
1665}