1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum DpopAlgorithm {
28 #[serde(rename = "ES256")]
31 ES256,
32}
33
34impl DpopAlgorithm {
35 #[must_use]
37 pub fn as_str(self) -> &'static str {
38 "ES256"
39 }
40
41 #[must_use]
43 pub fn recommended_key_size(self) -> u32 {
44 256 }
46
47 #[must_use]
49 pub fn is_production_ready(self) -> bool {
50 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#[derive(Debug, Clone)]
66pub struct DpopKeyPair {
67 pub id: String,
69
70 pub private_key: DpopPrivateKey,
72
73 pub public_key: DpopPublicKey,
75
76 pub thumbprint: String,
78
79 pub algorithm: DpopAlgorithm,
81
82 pub created_at: SystemTime,
84
85 pub expires_at: Option<SystemTime>,
87
88 pub metadata: DpopKeyMetadata,
90}
91
92impl DpopKeyPair {
93 #[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 #[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 #[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 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 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 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 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#[derive(Debug, Clone)]
187pub enum DpopPrivateKey {
188 EcdsaP256 {
190 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#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum DpopPublicKey {
215 EcdsaP256 {
217 x: [u8; 32],
219 y: [u8; 32],
221 },
222}
223
224#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226pub struct DpopKeyMetadata {
227 pub description: Option<String>,
229
230 pub client_id: Option<String>,
232
233 pub session_id: Option<String>,
235
236 pub usage_count: u64,
238
239 pub last_used: Option<SystemTime>,
241
242 pub rotation_generation: u32,
244
245 pub custom: HashMap<String, serde_json::Value>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct DpopHeader {
252 #[serde(rename = "typ")]
254 pub typ: String,
255
256 #[serde(rename = "alg")]
258 pub algorithm: DpopAlgorithm,
259
260 #[serde(rename = "jwk")]
262 pub jwk: DpopJwk,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct DpopPayload {
268 #[serde(rename = "jti")]
270 pub jti: String,
271
272 #[serde(rename = "htm")]
274 pub htm: String,
275
276 #[serde(rename = "htu")]
278 pub htu: String,
279
280 #[serde(rename = "iat")]
282 pub iat: i64,
283
284 #[serde(rename = "ath", skip_serializing_if = "Option::is_none")]
286 pub ath: Option<String>,
287
288 #[serde(rename = "nonce", skip_serializing_if = "Option::is_none")]
290 pub nonce: Option<String>,
291}
292
293#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
298#[serde(tag = "kty")]
299pub enum DpopJwk {
300 #[serde(rename = "EC")]
302 Ec {
303 #[serde(rename = "use")]
308 use_: String,
309
310 crv: String,
312
313 x: String,
315
316 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#[derive(Debug, Clone)]
383pub struct DpopProof {
384 pub header: DpopHeader,
386
387 pub payload: DpopPayload,
389
390 pub signature: String,
392
393 jwt_string: Option<String>,
395}
396
397impl DpopProof {
398 #[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 #[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 pub fn to_jwt_string(&self) -> String {
431 if let Some(ref cached) = self.jwt_string {
432 return cached.clone();
433 }
434
435 match self.create_jwt_string() {
438 Ok(jwt) => jwt,
439 Err(e) => {
440 tracing::error!("Failed to create JWT string: {}, using fallback", e);
442 self.create_minimal_jwt_fallback()
443 }
444 }
445 }
446
447 fn create_jwt_string(&self) -> Result<String, Box<dyn std::error::Error>> {
452 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 let encoded_header = URL_SAFE_NO_PAD.encode(header_json);
461 let encoded_payload = URL_SAFE_NO_PAD.encode(payload_json);
462
463 Ok(format!(
465 "{}.{}.{}",
466 encoded_header, encoded_payload, self.signature
467 ))
468 }
469
470 fn create_minimal_jwt_fallback(&self) -> String {
472 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 pub fn from_jwt_string(jwt: &str) -> super::Result<Self> {
490 use jsonwebtoken::{Algorithm, Validation, decode, decode_header};
491
492 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 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 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 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 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 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 let mut validation = Validation::new(jwt_header.alg);
564 validation.validate_exp = false; validation.set_required_spec_claims(&["iat"]); 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 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 Ok(Self::new_with_jwt(
587 header,
588 payload,
589 signature,
590 jwt.to_string(),
591 ))
592 }
593
594 pub fn thumbprint(&self) -> super::Result<String> {
596 compute_jwk_thumbprint(&self.header.jwk)
597 }
598
599 pub fn validate_structure(&self) -> super::Result<()> {
601 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 if self.payload.jti.is_empty() {
612 return Err(super::DpopError::InvalidProofStructure {
613 reason: "Missing jti claim".to_string(),
614 });
615 }
616
617 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 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 #[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 pub fn builder() -> crate::helpers::DpopProofParamsBuilder {
656 crate::helpers::DpopProofParams::builder()
657 }
658}
659
660pub type TicketId = String;
662
663pub fn generate_ticket_id() -> TicketId {
665 Uuid::new_v4().to_string()
666}
667
668pub fn compute_jwk_thumbprint(jwk: &DpopJwk) -> super::Result<String> {
675 use sha2::{Digest, Sha256};
676
677 let canonical_json = match jwk {
680 DpopJwk::Ec { crv, x, y, .. } => {
681 let crv_escaped = crv.replace('\\', "\\\\").replace('"', "\\\"");
683 let x_escaped = x.replace('\\', "\\\\").replace('"', "\\\"");
684 let y_escaped = y.replace('\\', "\\\\").replace('"', "\\\"");
685
686 format!(
688 r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#,
689 crv_escaped, x_escaped, y_escaped
690 )
691 }
692 };
693
694 let mut hasher = Sha256::new();
696 hasher.update(canonical_json.as_bytes());
697 let hash = hasher.finalize();
698
699 Ok(URL_SAFE_NO_PAD.encode(hash))
701}
702
703fn 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
711fn is_valid_http_uri(uri: &str) -> bool {
713 uri.starts_with("https://") || uri.starts_with("http://")
714}
715
716fn 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 DecodingKey::from_ec_components(x, y)
732 .map_err(|e| format!("Failed to create EC decoding key: {}", e).into())
733 }
734 }
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct StorageStats {
740 pub total_nonces: u64,
742 pub active_nonces: u64,
744 pub expired_nonces: u64,
746 pub cleanup_runs: u64,
748 pub average_nonce_age: Duration,
750 pub storage_size_bytes: u64,
752 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
770pub trait NonceStorage: Send + Sync + std::fmt::Debug {
775 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 fn is_nonce_used(
810 &self,
811 nonce: &str,
812 client_id: &str,
813 ) -> impl Future<Output = super::Result<bool>> + Send;
814
815 fn cleanup_expired(&self) -> impl Future<Output = super::Result<u64>> + Send;
820
821 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}