Skip to main content

turbomcp_dpop/
types.rs

1//! Core DPoP types and data structures
2//!
3//! This module implements the fundamental types for RFC 9449 DPoP (Demonstration
4//! of Proof-of-Possession) including algorithms, key pairs, proofs, and related metadata.
5
6use std::collections::HashMap;
7use std::fmt;
8use std::future::Future;
9use std::time::{Duration, SystemTime};
10
11use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
12use serde::{Deserialize, Deserializer, Serialize};
13use uuid::Uuid;
14use zeroize::Zeroize;
15
16/// DPoP cryptographic algorithm as defined in RFC 9449
17///
18/// This implementation uses only ES256 (ECDSA P-256) for maximum security.
19/// RSA algorithms (RS256, PS256) have been removed due to timing attack
20/// vulnerabilities in the rsa crate (RUSTSEC-2023-0071).
21///
22/// ES256 is the recommended algorithm in RFC 9449 and provides:
23/// - Superior security against timing attacks
24/// - Faster performance than RSA
25/// - Smaller key and signature sizes
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum DpopAlgorithm {
28    /// Elliptic Curve Digital Signature Algorithm with P-256 curve and SHA-256 (RFC 7518)
29    /// This is the only supported algorithm for DPoP in TurboMCP v3.0+
30    #[serde(rename = "ES256")]
31    ES256,
32}
33
34impl DpopAlgorithm {
35    /// Get the algorithm name as specified in RFC 7518
36    #[must_use]
37    pub fn as_str(self) -> &'static str {
38        "ES256"
39    }
40
41    /// Get recommended key size for the algorithm
42    #[must_use]
43    pub fn recommended_key_size(self) -> u32 {
44        256 // P-256 curve
45    }
46
47    /// Check if algorithm is suitable for production use
48    #[must_use]
49    pub fn is_production_ready(self) -> bool {
50        // ES256 is production-ready and the recommended algorithm
51        true
52    }
53}
54
55impl fmt::Display for DpopAlgorithm {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "{}", self.as_str())
58    }
59}
60
61/// DPoP key pair with metadata
62///
63/// Contains the cryptographic key material and associated metadata for DPoP operations.
64/// The private key is zeroized on drop to prevent memory disclosure attacks.
65#[derive(Debug, Clone)]
66pub struct DpopKeyPair {
67    /// Unique identifier for this key pair
68    pub id: String,
69
70    /// Private key material (will be zeroized on drop)
71    pub private_key: DpopPrivateKey,
72
73    /// Public key material
74    pub public_key: DpopPublicKey,
75
76    /// JWK thumbprint for binding (RFC 7638)
77    pub thumbprint: String,
78
79    /// Cryptographic algorithm
80    pub algorithm: DpopAlgorithm,
81
82    /// Key creation timestamp
83    pub created_at: SystemTime,
84
85    /// Key expiration (None = never expires)
86    pub expires_at: Option<SystemTime>,
87
88    /// Key usage metadata
89    pub metadata: DpopKeyMetadata,
90}
91
92impl DpopKeyPair {
93    /// Check if the key pair has expired
94    #[must_use]
95    pub fn is_expired(&self) -> bool {
96        self.expires_at
97            .map(|expires| SystemTime::now() > expires)
98            .unwrap_or(false)
99    }
100
101    /// Check if the key pair will expire within the given duration
102    #[must_use]
103    pub fn expires_within(&self, duration: Duration) -> bool {
104        self.expires_at
105            .map(|expires| expires <= SystemTime::now() + duration)
106            .unwrap_or(false)
107    }
108
109    /// Get the age of this key pair
110    #[must_use]
111    pub fn age(&self) -> Duration {
112        SystemTime::now()
113            .duration_since(self.created_at)
114            .unwrap_or(Duration::ZERO)
115    }
116
117    /// Generate a new P-256 (ES256) key pair
118    ///
119    /// Convenience method for generating EC P-256 keys commonly used with DPoP.
120    /// For production use with key rotation and management, use `DpopKeyManager`.
121    ///
122    /// # Errors
123    /// Returns error if key generation fails
124    pub fn generate_p256() -> Result<Self, crate::errors::DpopError> {
125        use p256::ecdsa::{SigningKey, VerifyingKey};
126        use p256::elliptic_curve::Generate as _;
127        use sha2::{Digest, Sha256};
128
129        let signing_key = SigningKey::generate();
130        let verifying_key = VerifyingKey::from(&signing_key);
131
132        // Get private key bytes
133        let private_bytes = signing_key.to_bytes();
134        let mut key_bytes = [0u8; 32];
135        key_bytes.copy_from_slice(private_bytes.as_ref());
136
137        // Extract x and y coordinates from the public key
138        let public_point = verifying_key.to_sec1_point(false);
139        let x_bytes =
140            public_point
141                .x()
142                .ok_or_else(|| crate::errors::DpopError::CryptographicError {
143                    reason: "Failed to extract x coordinate from P-256 public key".to_string(),
144                })?;
145        let y_bytes =
146            public_point
147                .y()
148                .ok_or_else(|| crate::errors::DpopError::CryptographicError {
149                    reason: "Failed to extract y coordinate from P-256 public key".to_string(),
150                })?;
151
152        let mut x = [0u8; 32];
153        let mut y = [0u8; 32];
154        x.copy_from_slice(x_bytes);
155        y.copy_from_slice(y_bytes);
156
157        // Calculate JWK thumbprint per RFC 7638
158        let jwk_json = serde_json::json!({
159            "crv": "P-256",
160            "kty": "EC",
161            "x": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(x),
162            "y": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(y),
163        });
164        let jwk_canonical = jwk_json.to_string();
165        let mut hasher = Sha256::new();
166        hasher.update(jwk_canonical.as_bytes());
167        let thumbprint = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
168
169        Ok(Self {
170            id: uuid::Uuid::new_v4().to_string(),
171            private_key: DpopPrivateKey::EcdsaP256 { key_bytes },
172            public_key: DpopPublicKey::EcdsaP256 { x, y },
173            thumbprint,
174            algorithm: DpopAlgorithm::ES256,
175            created_at: SystemTime::now(),
176            expires_at: None,
177            metadata: DpopKeyMetadata::default(),
178        })
179    }
180}
181
182/// Private key material for DPoP operations
183///
184/// This implementation only supports ECDSA P-256 keys for maximum security.
185/// RSA support has been removed due to timing attack vulnerabilities (RUSTSEC-2023-0071).
186#[derive(Debug, Clone)]
187pub enum DpopPrivateKey {
188    /// ECDSA P-256 private key
189    EcdsaP256 {
190        /// P-256 private key in SEC1 format
191        key_bytes: [u8; 32],
192    },
193}
194
195impl Zeroize for DpopPrivateKey {
196    fn zeroize(&mut self) {
197        match self {
198            Self::EcdsaP256 { key_bytes } => key_bytes.zeroize(),
199        }
200    }
201}
202
203impl Drop for DpopPrivateKey {
204    fn drop(&mut self) {
205        self.zeroize();
206    }
207}
208
209/// Public key material for DPoP operations
210///
211/// This implementation only supports ECDSA P-256 keys for maximum security.
212/// RSA support has been removed due to timing attack vulnerabilities (RUSTSEC-2023-0071).
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum DpopPublicKey {
215    /// ECDSA P-256 public key
216    EcdsaP256 {
217        /// X coordinate of the public key point
218        x: [u8; 32],
219        /// Y coordinate of the public key point
220        y: [u8; 32],
221    },
222}
223
224/// Key usage metadata for auditing and management
225#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226pub struct DpopKeyMetadata {
227    /// Human-readable key description
228    pub description: Option<String>,
229
230    /// Client identifier this key belongs to
231    pub client_id: Option<String>,
232
233    /// Session identifier (if session-bound)
234    pub session_id: Option<String>,
235
236    /// Number of times this key has been used
237    pub usage_count: u64,
238
239    /// Last time this key was used for proof generation
240    pub last_used: Option<SystemTime>,
241
242    /// Key rotation generation (0 = original, 1+ = rotated)
243    pub rotation_generation: u32,
244
245    /// Custom metadata for applications
246    pub custom: HashMap<String, serde_json::Value>,
247}
248
249/// DPoP JWT header as defined in RFC 9449
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct DpopHeader {
252    /// JWT type - always "dpop+jwt" for DPoP
253    #[serde(rename = "typ")]
254    pub typ: String,
255
256    /// Cryptographic algorithm used for signing
257    #[serde(rename = "alg")]
258    pub algorithm: DpopAlgorithm,
259
260    /// JSON Web Key (JWK) representing the public key
261    #[serde(rename = "jwk")]
262    pub jwk: DpopJwk,
263}
264
265/// DPoP JWT payload as defined in RFC 9449
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct DpopPayload {
268    /// JWT ID - unique nonce for replay prevention
269    #[serde(rename = "jti")]
270    pub jti: String,
271
272    /// HTTP method being bound to this proof
273    #[serde(rename = "htm")]
274    pub htm: String,
275
276    /// HTTP URI being bound to this proof (without query/fragment)
277    #[serde(rename = "htu")]
278    pub htu: String,
279
280    /// Issued at timestamp (Unix timestamp)
281    #[serde(rename = "iat")]
282    pub iat: i64,
283
284    /// Access token hash (when binding to an access token)
285    #[serde(rename = "ath", skip_serializing_if = "Option::is_none")]
286    pub ath: Option<String>,
287
288    /// Confirmation nonce from authorization server
289    #[serde(rename = "nonce", skip_serializing_if = "Option::is_none")]
290    pub nonce: Option<String>,
291}
292
293/// JSON Web Key representation for DPoP public keys
294///
295/// This implementation only supports ECDSA P-256 keys for maximum security.
296/// RSA support has been removed due to timing attack vulnerabilities (RUSTSEC-2023-0071).
297#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
298#[serde(tag = "kty")]
299pub enum DpopJwk {
300    /// Elliptic Curve public key in JWK format
301    #[serde(rename = "EC")]
302    Ec {
303        /// Key usage. Optional in JWK; when present for DPoP it must be "sig".
304        ///
305        /// Missing `use` values deserialize as "sig" to preserve the public
306        /// API shape while accepting interoperable public JWK headers.
307        #[serde(rename = "use")]
308        use_: String,
309
310        /// Elliptic curve name - always "P-256" for ES256
311        crv: String,
312
313        /// X coordinate (base64url-encoded)
314        x: String,
315
316        /// Y coordinate (base64url-encoded)
317        y: String,
318    },
319}
320
321impl<'de> Deserialize<'de> for DpopJwk {
322    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
323    where
324        D: Deserializer<'de>,
325    {
326        let object = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
327
328        if object.contains_key("d") {
329            return Err(serde::de::Error::custom(
330                "DPoP JWK header must not contain private key material (`d`)",
331            ));
332        }
333
334        match object.get("kty").and_then(serde_json::Value::as_str) {
335            Some("EC") => {
336                let use_ = match object.get("use") {
337                    None => "sig".to_string(),
338                    Some(serde_json::Value::String(value)) if value == "sig" => value.clone(),
339                    Some(serde_json::Value::String(_)) => {
340                        return Err(serde::de::Error::custom(
341                            "DPoP EC JWK `use`, when present, must be `sig`",
342                        ));
343                    }
344                    Some(_) => {
345                        return Err(serde::de::Error::custom(
346                            "DPoP EC JWK `use` must be a string when present",
347                        ));
348                    }
349                };
350
351                let crv = required_jwk_string::<D::Error>(&object, "crv")?;
352                let x = required_jwk_string::<D::Error>(&object, "x")?;
353                let y = required_jwk_string::<D::Error>(&object, "y")?;
354
355                Ok(Self::Ec { use_, crv, x, y })
356            }
357            Some(other) => Err(serde::de::Error::custom(format!(
358                "unsupported DPoP JWK key type `{other}`"
359            ))),
360            None => Err(serde::de::Error::missing_field("kty")),
361        }
362    }
363}
364
365fn required_jwk_string<E>(
366    object: &serde_json::Map<String, serde_json::Value>,
367    field: &'static str,
368) -> std::result::Result<String, E>
369where
370    E: serde::de::Error,
371{
372    match object.get(field) {
373        Some(serde_json::Value::String(value)) => Ok(value.clone()),
374        Some(_) => Err(serde::de::Error::custom(format!(
375            "DPoP EC JWK `{field}` must be a string"
376        ))),
377        None => Err(serde::de::Error::missing_field(field)),
378    }
379}
380
381/// Complete DPoP proof JWT
382#[derive(Debug, Clone)]
383pub struct DpopProof {
384    /// JWT header
385    pub header: DpopHeader,
386
387    /// JWT payload
388    pub payload: DpopPayload,
389
390    /// JWT signature (base64url-encoded)
391    pub signature: String,
392
393    /// The complete JWT string representation
394    jwt_string: Option<String>,
395}
396
397impl DpopProof {
398    /// Create a new DPoP proof
399    #[must_use]
400    pub fn new(header: DpopHeader, payload: DpopPayload, signature: String) -> Self {
401        Self {
402            header,
403            payload,
404            signature,
405            jwt_string: None,
406        }
407    }
408
409    /// Create a new DPoP proof with pre-computed JWT string for performance
410    #[must_use]
411    pub fn new_with_jwt(
412        header: DpopHeader,
413        payload: DpopPayload,
414        signature: String,
415        jwt_string: String,
416    ) -> Self {
417        Self {
418            header,
419            payload,
420            signature,
421            jwt_string: Some(jwt_string),
422        }
423    }
424
425    /// Get the JWT string representation for HTTP headers
426    ///
427    /// Returns a complete RFC 7515 compliant JWT in the format: `header.payload.signature`
428    /// where each component is base64url-encoded JSON. Uses proven JWT formatting
429    /// compatible with the jsonwebtoken crate standards.
430    pub fn to_jwt_string(&self) -> String {
431        if let Some(ref cached) = self.jwt_string {
432            return cached.clone();
433        }
434
435        // RFC 7515 compliant JWT construction: header.payload.signature
436        // Manual construction is appropriate here since we're assembling pre-signed tokens
437        match self.create_jwt_string() {
438            Ok(jwt) => jwt,
439            Err(e) => {
440                // Log error but provide a functional fallback
441                tracing::error!("Failed to create JWT string: {}, using fallback", e);
442                self.create_minimal_jwt_fallback()
443            }
444        }
445    }
446
447    /// Create RFC 7515 compliant JWT string
448    ///
449    /// This follows the exact same format as the jsonwebtoken crate but is optimized
450    /// for our use case of assembling already-signed DPoP tokens.
451    fn create_jwt_string(&self) -> Result<String, Box<dyn std::error::Error>> {
452        // Serialize header and payload to canonical JSON (matches jsonwebtoken crate behavior)
453        let header_json = serde_json::to_string(&self.header)
454            .map_err(|e| format!("Failed to serialize header: {e}"))?;
455
456        let payload_json = serde_json::to_string(&self.payload)
457            .map_err(|e| format!("Failed to serialize payload: {e}"))?;
458
459        // Base64url encode both components (RFC 7515 Section 2)
460        let encoded_header = URL_SAFE_NO_PAD.encode(header_json);
461        let encoded_payload = URL_SAFE_NO_PAD.encode(payload_json);
462
463        // Construct complete JWT: header.payload.signature (RFC 7515 Section 7.1)
464        Ok(format!(
465            "{}.{}.{}",
466            encoded_header, encoded_payload, self.signature
467        ))
468    }
469
470    /// Create minimal valid JWT as fallback (should never be needed in production)
471    fn create_minimal_jwt_fallback(&self) -> String {
472        // Create a minimal but valid DPoP JWT header
473        let minimal_header = format!(r#"{{"typ":"{}","alg":"ES256"}}"#, super::DPOP_JWT_TYPE);
474        let minimal_payload = "{}";
475
476        let encoded_header = URL_SAFE_NO_PAD.encode(minimal_header);
477        let encoded_payload = URL_SAFE_NO_PAD.encode(minimal_payload);
478
479        format!("{}.{}.{}", encoded_header, encoded_payload, self.signature)
480    }
481
482    /// Parse and cryptographically validate DPoP proof from JWT string
483    ///
484    /// This method leverages the well-established jsonwebtoken crate for complete JWT parsing
485    /// and cryptographic signature verification using the embedded JWK. This implementation
486    /// follows RFC 9449 security requirements and validates signatures before processing claims.
487    ///
488    /// Requires the `jwt-validation` feature to be enabled.
489    pub fn from_jwt_string(jwt: &str) -> super::Result<Self> {
490        use jsonwebtoken::{Algorithm, Validation, decode, decode_header};
491
492        // Use jsonwebtoken crate to decode header (no validation yet)
493        let jwt_header =
494            decode_header(jwt).map_err(|e| super::DpopError::InvalidProofStructure {
495                reason: format!("Failed to decode JWT header: {}", e),
496            })?;
497
498        // Validate this is a DPoP JWT
499        if jwt_header.typ.as_deref() != Some(super::DPOP_JWT_TYPE) {
500            return Err(super::DpopError::InvalidProofStructure {
501                reason: format!(
502                    "Invalid JWT type: expected '{}', got '{:?}'",
503                    super::DPOP_JWT_TYPE,
504                    jwt_header.typ
505                ),
506            });
507        }
508
509        // Convert jsonwebtoken::Header to our DpopHeader
510        // Only ES256 is supported in TurboMCP v3.0+
511        let algorithm = match jwt_header.alg {
512            Algorithm::ES256 => DpopAlgorithm::ES256,
513            other => {
514                return Err(super::DpopError::InvalidProofStructure {
515                    reason: format!(
516                        "Unsupported DPoP algorithm: {:?}. Only ES256 is supported (RSA removed due to RUSTSEC-2023-0071)",
517                        other
518                    ),
519                });
520            }
521        };
522
523        // Extract JWK from header - convert from jsonwebtoken::Jwk to our DpopJwk
524        let jwk_value = jwt_header
525            .jwk
526            .ok_or_else(|| super::DpopError::InvalidProofStructure {
527                reason: "Missing JWK in DPoP proof header".to_string(),
528            })?;
529
530        // Convert jsonwebtoken::Jwk to serde_json::Value first, then to our DpopJwk
531        let jwk_json = serde_json::to_value(&jwk_value).map_err(|e| {
532            super::DpopError::InvalidProofStructure {
533                reason: format!("Failed to serialize JWK: {}", e),
534            }
535        })?;
536
537        let jwk: DpopJwk = serde_json::from_value(jwk_json).map_err(|e| {
538            super::DpopError::InvalidProofStructure {
539                reason: format!("Invalid JWK in header: {}", e),
540            }
541        })?;
542
543        let header = DpopHeader {
544            typ: super::DPOP_JWT_TYPE.to_string(),
545            algorithm,
546            jwk,
547        };
548
549        // CRITICAL SECURITY: Create proper DecodingKey from embedded JWK for signature validation
550        let decoding_key = create_decoding_key_from_jwk(&header.jwk).map_err(|e| {
551            super::DpopError::CryptographicError {
552                reason: format!("Failed to create decoding key from JWK: {}", e),
553            }
554        })?;
555
556        // Configure validation for DPoP JWTs (RFC 9449):
557        // - DPoP proofs use `iat` for freshness, not `exp` — disable exp validation
558        // - DPoP proofs have no `aud` claim — disable audience validation
559        // - `iat` is required per RFC 9449 §4.2
560        // Leaving these as the Validation::new() defaults would reject every valid
561        // DPoP proof because jsonwebtoken treats a missing `exp` as an error by default,
562        // and it would require an `aud` claim that DPoP proofs never carry.
563        let mut validation = Validation::new(jwt_header.alg);
564        validation.validate_exp = false; // DPoP uses iat, not exp
565        validation.set_required_spec_claims(&["iat"]); // Require iat per RFC 9449 §4.2
566
567        // Decode and validate JWT signature using the embedded public key
568        let token_data = decode::<DpopPayload>(jwt, &decoding_key, &validation).map_err(|e| {
569            super::DpopError::ProofValidationFailed {
570                reason: format!("JWT signature validation failed: {}", e),
571            }
572        })?;
573
574        let payload = token_data.claims;
575
576        // Extract signature from JWT (jsonwebtoken doesn't expose this directly)
577        let parts: Vec<&str> = jwt.split('.').collect();
578        if parts.len() != 3 {
579            return Err(super::DpopError::InvalidProofStructure {
580                reason: format!("Invalid JWT format: expected 3 parts, got {}", parts.len()),
581            });
582        }
583        let signature = parts[2].to_string();
584
585        // Create proof with cached JWT string for performance
586        Ok(Self::new_with_jwt(
587            header,
588            payload,
589            signature,
590            jwt.to_string(),
591        ))
592    }
593
594    /// Get the JWK thumbprint from this proof
595    pub fn thumbprint(&self) -> super::Result<String> {
596        compute_jwk_thumbprint(&self.header.jwk)
597    }
598
599    /// Validate the proof structure (not cryptographic signature)
600    pub fn validate_structure(&self) -> super::Result<()> {
601        // Validate JWT type
602        if self.header.typ != super::DPOP_JWT_TYPE {
603            return Err(super::DpopError::InvalidProofStructure {
604                reason: format!("Invalid JWT type: {}", self.header.typ),
605            });
606        }
607
608        // RFC 9449 requires `jti` to be a unique string. UUIDs are a good
609        // locally generated default, but valid interoperable proofs may use
610        // any non-empty collision-resistant string.
611        if self.payload.jti.is_empty() {
612            return Err(super::DpopError::InvalidProofStructure {
613                reason: "Missing jti claim".to_string(),
614            });
615        }
616
617        // Validate HTTP method
618        if !is_valid_http_method(&self.payload.htm) {
619            return Err(super::DpopError::InvalidProofStructure {
620                reason: format!("Invalid HTTP method: {}", self.payload.htm),
621            });
622        }
623
624        // Validate HTTP URI
625        if !is_valid_http_uri(&self.payload.htu) {
626            return Err(super::DpopError::InvalidProofStructure {
627                reason: format!("Invalid HTTP URI: {}", self.payload.htu),
628            });
629        }
630
631        Ok(())
632    }
633
634    /// Check if proof has expired based on timestamp
635    #[must_use]
636    pub fn is_expired(&self, max_age: Duration) -> bool {
637        let issued_at = SystemTime::UNIX_EPOCH + Duration::from_secs(self.payload.iat as u64);
638        SystemTime::now() > issued_at + max_age
639    }
640
641    /// Create a builder for DPoP proof generation
642    ///
643    /// Returns a builder that provides a fluent, type-safe API for creating DPoP proofs.
644    /// The builder uses compile-time checks to ensure required parameters are provided.
645    ///
646    /// # Example
647    /// ```ignore
648    /// let proof = DpopProof::builder()
649    ///     .http_method("GET")
650    ///     .http_uri("https://api.example.com/resource")
651    ///     .access_token("token_value")
652    ///     .build_with_key(&key_pair)
653    ///     .await?;
654    /// ```
655    pub fn builder() -> crate::helpers::DpopProofParamsBuilder {
656        crate::helpers::DpopProofParams::builder()
657    }
658}
659
660/// Unique identifier for registered intents
661pub type TicketId = String;
662
663/// Generate a new ticket ID
664pub fn generate_ticket_id() -> TicketId {
665    Uuid::new_v4().to_string()
666}
667
668/// Compute JWK thumbprint as defined in RFC 7638
669///
670/// RFC 7638 requires lexicographic ordering of JSON keys for canonical representation.
671/// This function manually constructs the canonical JSON to ensure proper ordering.
672///
673/// Only supports ES256 (ECDSA P-256) as of TurboMCP v3.0+
674pub fn compute_jwk_thumbprint(jwk: &DpopJwk) -> super::Result<String> {
675    use sha2::{Digest, Sha256};
676
677    // RFC 7638 requires lexicographic ordering: crv, kty, x, y (for EC keys)
678    // We manually construct the JSON to guarantee this ordering
679    let canonical_json = match jwk {
680        DpopJwk::Ec { crv, x, y, .. } => {
681            // Escape JSON string values (base64url strings don't contain special chars, but ensure safety)
682            let crv_escaped = crv.replace('\\', "\\\\").replace('"', "\\\"");
683            let x_escaped = x.replace('\\', "\\\\").replace('"', "\\\"");
684            let y_escaped = y.replace('\\', "\\\\").replace('"', "\\\"");
685
686            // Construct canonical JSON with guaranteed lexicographic key ordering
687            format!(
688                r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#,
689                crv_escaped, x_escaped, y_escaped
690            )
691        }
692    };
693
694    // Compute SHA-256 hash
695    let mut hasher = Sha256::new();
696    hasher.update(canonical_json.as_bytes());
697    let hash = hasher.finalize();
698
699    // Return base64url-encoded thumbprint
700    Ok(URL_SAFE_NO_PAD.encode(hash))
701}
702
703/// Validate HTTP method format
704fn is_valid_http_method(method: &str) -> bool {
705    matches!(
706        method.to_uppercase().as_str(),
707        "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"
708    )
709}
710
711/// Validate HTTP URI format (basic validation)
712fn is_valid_http_uri(uri: &str) -> bool {
713    uri.starts_with("https://") || uri.starts_with("http://")
714}
715
716/// Create jsonwebtoken DecodingKey from DPoP JWK
717///
718/// This function converts our DpopJwk to a DecodingKey that can be used
719/// with the jsonwebtoken crate for signature verification. This is critical
720/// for proper DPoP security as per RFC 9449 requirements.
721///
722/// Only supports ES256 (ECDSA P-256) as of TurboMCP v3.0+
723fn create_decoding_key_from_jwk(
724    jwk: &DpopJwk,
725) -> Result<jsonwebtoken::DecodingKey, Box<dyn std::error::Error>> {
726    use jsonwebtoken::DecodingKey;
727
728    match jwk {
729        DpopJwk::Ec { x, y, .. } => {
730            // Use jsonwebtoken's EC components method (expects base64url-encoded strings)
731            DecodingKey::from_ec_components(x, y)
732                .map_err(|e| format!("Failed to create EC decoding key: {}", e).into())
733        }
734    }
735}
736
737/// Statistics about nonce storage usage and performance
738#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct StorageStats {
740    /// Total number of nonces stored
741    pub total_nonces: u64,
742    /// Number of active (non-expired) nonces
743    pub active_nonces: u64,
744    /// Number of expired nonces that have been cleaned up
745    pub expired_nonces: u64,
746    /// Number of cleanup operations performed
747    pub cleanup_runs: u64,
748    /// Average age of stored nonces
749    pub average_nonce_age: Duration,
750    /// Estimated storage size in bytes
751    pub storage_size_bytes: u64,
752    /// Additional backend-specific metrics
753    pub additional_metrics: Vec<(String, String)>,
754}
755
756impl Default for StorageStats {
757    fn default() -> Self {
758        Self {
759            total_nonces: 0,
760            active_nonces: 0,
761            expired_nonces: 0,
762            cleanup_runs: 0,
763            average_nonce_age: Duration::ZERO,
764            storage_size_bytes: 0,
765            additional_metrics: Vec::new(),
766        }
767    }
768}
769
770/// Trait for DPoP nonce storage backends
771///
772/// This trait defines the interface for storing and managing DPoP nonces to prevent replay attacks.
773/// Implementations should ensure thread-safety and efficient concurrent access.
774pub trait NonceStorage: Send + Sync + std::fmt::Debug {
775    /// Store a nonce with associated metadata
776    ///
777    /// # Arguments
778    /// * `nonce` - The unique nonce value from the DPoP proof
779    /// * `jti` - The JWT ID (jti) claim from the DPoP proof
780    /// * `http_method` - The HTTP method for which this nonce is valid
781    /// * `http_uri` - The HTTP URI for which this nonce is valid
782    /// * `client_id` - The client identifier
783    /// * `ttl` - Time-to-live for the nonce (None uses default)
784    ///
785    /// # Returns
786    /// * `Ok(true)` - Nonce was successfully stored (first use)
787    /// * `Ok(false)` - Nonce already exists (replay attack detected)
788    /// * `Err(_)` - Storage operation failed
789    fn store_nonce(
790        &self,
791        nonce: &str,
792        jti: &str,
793        http_method: &str,
794        http_uri: &str,
795        client_id: &str,
796        ttl: Option<Duration>,
797    ) -> impl Future<Output = super::Result<bool>> + Send;
798
799    /// Check if a nonce has been used before
800    ///
801    /// # Arguments
802    /// * `nonce` - The nonce to check
803    /// * `client_id` - The client identifier
804    ///
805    /// # Returns
806    /// * `Ok(true)` - Nonce has been used before
807    /// * `Ok(false)` - Nonce is new
808    /// * `Err(_)` - Storage operation failed
809    fn is_nonce_used(
810        &self,
811        nonce: &str,
812        client_id: &str,
813    ) -> impl Future<Output = super::Result<bool>> + Send;
814
815    /// Clean up expired nonces
816    ///
817    /// # Returns
818    /// Number of expired nonces cleaned up
819    fn cleanup_expired(&self) -> impl Future<Output = super::Result<u64>> + Send;
820
821    /// Get storage usage statistics
822    ///
823    /// # Returns
824    /// Statistics about nonce storage usage and performance
825    fn get_usage_stats(&self) -> impl Future<Output = super::Result<StorageStats>> + Send;
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    #[test]
833    fn test_dpop_algorithm_properties() {
834        assert_eq!(DpopAlgorithm::ES256.as_str(), "ES256");
835        assert_eq!(DpopAlgorithm::ES256.recommended_key_size(), 256);
836        assert!(DpopAlgorithm::ES256.is_production_ready());
837    }
838
839    #[test]
840    fn test_http_method_validation() {
841        assert!(is_valid_http_method("GET"));
842        assert!(is_valid_http_method("post"));
843        assert!(is_valid_http_method("PUT"));
844        assert!(!is_valid_http_method("INVALID"));
845        assert!(!is_valid_http_method(""));
846    }
847
848    #[test]
849    fn test_http_uri_validation() {
850        assert!(is_valid_http_uri("https://api.example.com/token"));
851        assert!(is_valid_http_uri("http://localhost:8080/auth"));
852        assert!(!is_valid_http_uri("ftp://example.com"));
853        assert!(!is_valid_http_uri("invalid-uri"));
854    }
855
856    #[test]
857    fn test_jwk_use_is_optional() {
858        let jwk: DpopJwk = serde_json::from_value(serde_json::json!({
859            "kty": "EC",
860            "crv": "P-256",
861            "x": "abc",
862            "y": "def"
863        }))
864        .unwrap();
865
866        match jwk {
867            DpopJwk::Ec { use_, .. } => assert_eq!(use_, "sig"),
868        }
869    }
870
871    #[test]
872    fn test_jwk_rejects_private_key_material() {
873        let err = serde_json::from_value::<DpopJwk>(serde_json::json!({
874            "kty": "EC",
875            "crv": "P-256",
876            "x": "abc",
877            "y": "def",
878            "d": "private"
879        }))
880        .unwrap_err();
881
882        assert!(err.to_string().contains("private key material"));
883    }
884
885    #[test]
886    fn test_proof_structure_allows_non_uuid_jti() {
887        let proof = DpopProof::new(
888            DpopHeader {
889                typ: crate::DPOP_JWT_TYPE.to_string(),
890                algorithm: DpopAlgorithm::ES256,
891                jwk: DpopJwk::Ec {
892                    use_: "sig".to_string(),
893                    crv: "P-256".to_string(),
894                    x: "abc".to_string(),
895                    y: "def".to_string(),
896                },
897            },
898            DpopPayload {
899                jti: "opaque-nonce-123".to_string(),
900                htm: "POST".to_string(),
901                htu: "https://api.example.com/token".to_string(),
902                iat: 1,
903                ath: None,
904                nonce: None,
905            },
906            "signature".to_string(),
907        );
908
909        proof.validate_structure().unwrap();
910    }
911
912    #[test]
913    fn test_ticket_id_generation() {
914        let id1 = generate_ticket_id();
915        let id2 = generate_ticket_id();
916
917        assert_ne!(id1, id2);
918        assert!(Uuid::parse_str(&id1).is_ok());
919        assert!(Uuid::parse_str(&id2).is_ok());
920    }
921}