this repo has no description
1use base64::Engine as _; 2use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3use chrono::Utc; 4use p256::ecdsa::{SigningKey, signature::Signer}; 5use serde_json::json; 6 7use tranquil_pds::oauth::dpop::{ 8 DPoPJwk, DPoPVerifier, compute_access_token_hash, compute_jwk_thumbprint, 9}; 10 11fn create_dpop_proof( 12 method: &str, 13 htu: &str, 14 iat_offset_secs: i64, 15 alg: &str, 16 nonce: Option<&str>, 17 ath: Option<&str>, 18) -> (String, p256::ecdsa::VerifyingKey) { 19 let signing_key = SigningKey::random(&mut rand::thread_rng()); 20 let verifying_key = *signing_key.verifying_key(); 21 let point = verifying_key.to_encoded_point(false); 22 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 23 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 24 25 let header = json!({ 26 "typ": "dpop+jwt", 27 "alg": alg, 28 "jwk": { 29 "kty": "EC", 30 "crv": "P-256", 31 "x": x, 32 "y": y 33 } 34 }); 35 36 let iat = Utc::now().timestamp() + iat_offset_secs; 37 let jti = uuid::Uuid::new_v4().to_string(); 38 39 let mut payload = json!({ 40 "jti": jti, 41 "htm": method, 42 "htu": htu, 43 "iat": iat 44 }); 45 46 if let Some(n) = nonce { 47 payload["nonce"] = json!(n); 48 } 49 if let Some(a) = ath { 50 payload["ath"] = json!(a); 51 } 52 53 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); 54 let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); 55 let signing_input = format!("{}.{}", header_b64, payload_b64); 56 57 let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 58 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 59 60 let proof = format!("{}.{}.{}", header_b64, payload_b64, sig_b64); 61 (proof, verifying_key) 62} 63 64fn create_dpop_proof_with_invalid_sig(method: &str, htu: &str, alg: &str) -> String { 65 let signing_key = SigningKey::random(&mut rand::thread_rng()); 66 let verifying_key = *signing_key.verifying_key(); 67 let point = verifying_key.to_encoded_point(false); 68 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 69 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 70 71 let header = json!({ 72 "typ": "dpop+jwt", 73 "alg": alg, 74 "jwk": { 75 "kty": "EC", 76 "crv": "P-256", 77 "x": x, 78 "y": y 79 } 80 }); 81 82 let iat = Utc::now().timestamp(); 83 let jti = uuid::Uuid::new_v4().to_string(); 84 85 let payload = json!({ 86 "jti": jti, 87 "htm": method, 88 "htu": htu, 89 "iat": iat 90 }); 91 92 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); 93 let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); 94 95 let fake_sig = URL_SAFE_NO_PAD.encode(vec![0u8; 64]); 96 97 format!("{}.{}.{}", header_b64, payload_b64, fake_sig) 98} 99 100#[test] 101fn test_dpop_htu_query_params_stripped() { 102 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 103 let url_with_query = "https://pds.example/xrpc/com.atproto.server.getSession?foo=bar"; 104 let url_without_query = "https://pds.example/xrpc/com.atproto.server.getSession"; 105 106 let (proof, _) = create_dpop_proof("GET", url_with_query, 0, "ES256", None, None); 107 let result = verifier.verify_proof(&proof, "GET", url_without_query, None); 108 assert!( 109 result.is_ok(), 110 "Query params in htu should be stripped for comparison" 111 ); 112} 113 114#[test] 115fn test_dpop_htu_fragment_behavior() { 116 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 117 let url_with_fragment = "https://pds.example/xrpc/foo#fragment"; 118 let url_without_fragment = "https://pds.example/xrpc/foo"; 119 120 let (proof, _) = create_dpop_proof("GET", url_with_fragment, 0, "ES256", None, None); 121 let result = verifier.verify_proof(&proof, "GET", url_without_fragment, None); 122 123 assert!( 124 result.is_err(), 125 "Fragment in htu should cause mismatch (currently NOT stripped)" 126 ); 127} 128 129#[test] 130fn test_dpop_es512_algorithm_rejected() { 131 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 132 let url = "https://pds.example/xrpc/foo"; 133 134 let signing_key = SigningKey::random(&mut rand::thread_rng()); 135 let verifying_key = *signing_key.verifying_key(); 136 let point = verifying_key.to_encoded_point(false); 137 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 138 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 139 140 let header = json!({ 141 "typ": "dpop+jwt", 142 "alg": "ES512", 143 "jwk": { 144 "kty": "EC", 145 "crv": "P-256", 146 "x": x, 147 "y": y 148 } 149 }); 150 151 let payload = json!({ 152 "jti": uuid::Uuid::new_v4().to_string(), 153 "htm": "GET", 154 "htu": url, 155 "iat": Utc::now().timestamp() 156 }); 157 158 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); 159 let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); 160 let signing_input = format!("{}.{}", header_b64, payload_b64); 161 let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 162 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 163 let proof = format!("{}.{}.{}", header_b64, payload_b64, sig_b64); 164 165 let result = verifier.verify_proof(&proof, "GET", url, None); 166 assert!(result.is_err(), "ES512 should be rejected as unsupported"); 167} 168 169#[test] 170fn test_dpop_iat_clock_skew_within_bounds() { 171 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 172 let url = "https://pds.example/xrpc/foo"; 173 174 let (proof_299s_future, _) = create_dpop_proof("GET", url, 299, "ES256", None, None); 175 let result = verifier.verify_proof(&proof_299s_future, "GET", url, None); 176 assert!( 177 result.is_ok(), 178 "299s in future should be within clock skew tolerance" 179 ); 180 181 let (proof_299s_past, _) = create_dpop_proof("GET", url, -299, "ES256", None, None); 182 let result = verifier.verify_proof(&proof_299s_past, "GET", url, None); 183 assert!( 184 result.is_ok(), 185 "299s in past should be within clock skew tolerance" 186 ); 187} 188 189#[test] 190fn test_dpop_iat_clock_skew_beyond_bounds() { 191 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 192 let url = "https://pds.example/xrpc/foo"; 193 194 let (proof_301s_future, _) = create_dpop_proof("GET", url, 301, "ES256", None, None); 195 let result = verifier.verify_proof(&proof_301s_future, "GET", url, None); 196 assert!( 197 result.is_err(), 198 "301s in future should exceed clock skew tolerance" 199 ); 200 201 let (proof_301s_past, _) = create_dpop_proof("GET", url, -301, "ES256", None, None); 202 let result = verifier.verify_proof(&proof_301s_past, "GET", url, None); 203 assert!( 204 result.is_err(), 205 "301s in past should exceed clock skew tolerance" 206 ); 207} 208 209#[test] 210fn test_dpop_http_method_case_insensitive() { 211 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 212 let url = "https://pds.example/xrpc/foo"; 213 214 let (proof_lowercase, _) = create_dpop_proof("get", url, 0, "ES256", None, None); 215 let result = verifier.verify_proof(&proof_lowercase, "GET", url, None); 216 assert!( 217 result.is_ok(), 218 "HTTP method comparison should be case-insensitive" 219 ); 220 221 let (proof_mixed, _) = create_dpop_proof("GeT", url, 0, "ES256", None, None); 222 let result = verifier.verify_proof(&proof_mixed, "GET", url, None); 223 assert!( 224 result.is_ok(), 225 "HTTP method comparison should be case-insensitive" 226 ); 227} 228 229#[test] 230fn test_dpop_http_method_mismatch() { 231 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 232 let url = "https://pds.example/xrpc/foo"; 233 234 let (proof_post, _) = create_dpop_proof("POST", url, 0, "ES256", None, None); 235 let result = verifier.verify_proof(&proof_post, "GET", url, None); 236 assert!(result.is_err(), "HTTP method mismatch should fail"); 237} 238 239#[test] 240fn test_dpop_invalid_signature() { 241 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 242 let url = "https://pds.example/xrpc/foo"; 243 244 let proof = create_dpop_proof_with_invalid_sig("GET", url, "ES256"); 245 let result = verifier.verify_proof(&proof, "GET", url, None); 246 assert!(result.is_err(), "Invalid signature should be rejected"); 247} 248 249#[test] 250fn test_dpop_malformed_base64() { 251 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 252 let result = verifier.verify_proof("not.valid.base64!!!", "GET", "https://example.com", None); 253 assert!(result.is_err(), "Malformed base64 should be rejected"); 254} 255 256#[test] 257fn test_dpop_missing_parts() { 258 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 259 260 let result = verifier.verify_proof("onlyonepart", "GET", "https://example.com", None); 261 assert!( 262 result.is_err(), 263 "DPoP with missing parts should be rejected" 264 ); 265 266 let result = verifier.verify_proof("two.parts", "GET", "https://example.com", None); 267 assert!( 268 result.is_err(), 269 "DPoP with only two parts should be rejected" 270 ); 271} 272 273#[test] 274fn test_dpop_invalid_typ() { 275 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 276 let url = "https://pds.example/xrpc/foo"; 277 278 let signing_key = SigningKey::random(&mut rand::thread_rng()); 279 let verifying_key = *signing_key.verifying_key(); 280 let point = verifying_key.to_encoded_point(false); 281 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 282 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 283 284 let header = json!({ 285 "typ": "jwt", 286 "alg": "ES256", 287 "jwk": { 288 "kty": "EC", 289 "crv": "P-256", 290 "x": x, 291 "y": y 292 } 293 }); 294 295 let payload = json!({ 296 "jti": uuid::Uuid::new_v4().to_string(), 297 "htm": "GET", 298 "htu": url, 299 "iat": Utc::now().timestamp() 300 }); 301 302 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); 303 let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); 304 let signing_input = format!("{}.{}", header_b64, payload_b64); 305 let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 306 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 307 let proof = format!("{}.{}.{}", header_b64, payload_b64, sig_b64); 308 309 let result = verifier.verify_proof(&proof, "GET", url, None); 310 assert!(result.is_err(), "Invalid typ claim should be rejected"); 311} 312 313#[test] 314fn test_dpop_unsupported_algorithm() { 315 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 316 let url = "https://pds.example/xrpc/foo"; 317 318 let signing_key = SigningKey::random(&mut rand::thread_rng()); 319 let verifying_key = *signing_key.verifying_key(); 320 let point = verifying_key.to_encoded_point(false); 321 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 322 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 323 324 let header = json!({ 325 "typ": "dpop+jwt", 326 "alg": "RS256", 327 "jwk": { 328 "kty": "EC", 329 "crv": "P-256", 330 "x": x, 331 "y": y 332 } 333 }); 334 335 let payload = json!({ 336 "jti": uuid::Uuid::new_v4().to_string(), 337 "htm": "GET", 338 "htu": url, 339 "iat": Utc::now().timestamp() 340 }); 341 342 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); 343 let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); 344 let signing_input = format!("{}.{}", header_b64, payload_b64); 345 let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 346 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 347 let proof = format!("{}.{}.{}", header_b64, payload_b64, sig_b64); 348 349 let result = verifier.verify_proof(&proof, "GET", url, None); 350 assert!(result.is_err(), "Unsupported algorithm should be rejected"); 351} 352 353#[test] 354fn test_dpop_access_token_hash() { 355 let token = "test-access-token"; 356 let hash = compute_access_token_hash(token); 357 assert!(!hash.is_empty()); 358 359 let hash2 = compute_access_token_hash(token); 360 assert_eq!(hash, hash2, "Same token should produce same hash"); 361 362 let hash3 = compute_access_token_hash("different-token"); 363 assert_ne!(hash, hash3, "Different token should produce different hash"); 364} 365 366#[test] 367fn test_dpop_nonce_generation_and_validation() { 368 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 369 let nonce = verifier.generate_nonce(); 370 assert!(!nonce.is_empty()); 371 372 let result = verifier.validate_nonce(&nonce); 373 assert!(result.is_ok(), "Freshly generated nonce should be valid"); 374} 375 376#[test] 377fn test_dpop_nonce_invalid_encoding() { 378 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 379 let result = verifier.validate_nonce("not-valid-base64!!!"); 380 assert!(result.is_err(), "Invalid base64 nonce should be rejected"); 381} 382 383#[test] 384fn test_dpop_nonce_too_short() { 385 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 386 let short_nonce = URL_SAFE_NO_PAD.encode(vec![0u8; 10]); 387 let result = verifier.validate_nonce(&short_nonce); 388 assert!(result.is_err(), "Too short nonce should be rejected"); 389} 390 391#[test] 392fn test_dpop_nonce_tampered_signature() { 393 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 394 let nonce = verifier.generate_nonce(); 395 396 let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap(); 397 let mut tampered = nonce_bytes.clone(); 398 tampered[10] ^= 0xFF; 399 let tampered_nonce = URL_SAFE_NO_PAD.encode(&tampered); 400 401 let result = verifier.validate_nonce(&tampered_nonce); 402 assert!(result.is_err(), "Tampered nonce should be rejected"); 403} 404 405#[test] 406fn test_jwk_thumbprint_ec() { 407 let jwk = DPoPJwk { 408 kty: "EC".to_string(), 409 crv: Some("P-256".to_string()), 410 x: Some("test_x".to_string()), 411 y: Some("test_y".to_string()), 412 }; 413 let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); 414 assert!(!thumbprint.is_empty()); 415 416 let thumbprint2 = compute_jwk_thumbprint(&jwk).unwrap(); 417 assert_eq!( 418 thumbprint, thumbprint2, 419 "Same JWK should produce same thumbprint" 420 ); 421} 422 423#[test] 424fn test_jwk_thumbprint_okp() { 425 let jwk = DPoPJwk { 426 kty: "OKP".to_string(), 427 crv: Some("Ed25519".to_string()), 428 x: Some("test_x".to_string()), 429 y: None, 430 }; 431 let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); 432 assert!(!thumbprint.is_empty()); 433} 434 435#[test] 436fn test_jwk_thumbprint_unsupported_kty() { 437 let jwk = DPoPJwk { 438 kty: "RSA".to_string(), 439 crv: None, 440 x: None, 441 y: None, 442 }; 443 let result = compute_jwk_thumbprint(&jwk); 444 assert!(result.is_err(), "Unsupported key type should error"); 445} 446 447#[test] 448fn test_jwk_thumbprint_missing_fields() { 449 let jwk = DPoPJwk { 450 kty: "EC".to_string(), 451 crv: None, 452 x: None, 453 y: None, 454 }; 455 let result = compute_jwk_thumbprint(&jwk); 456 assert!(result.is_err(), "Missing crv should error"); 457} 458 459#[test] 460fn test_dpop_uri_normalization_preserves_port() { 461 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 462 let url_with_port = "https://pds.example:8080/xrpc/foo"; 463 464 let (proof, _) = create_dpop_proof("GET", url_with_port, 0, "ES256", None, None); 465 let result = verifier.verify_proof(&proof, "GET", url_with_port, None); 466 assert!(result.is_ok(), "URL with port should work"); 467 468 let url_without_port = "https://pds.example/xrpc/foo"; 469 let result = verifier.verify_proof(&proof, "GET", url_without_port, None); 470 assert!(result.is_err(), "Different port should fail"); 471} 472 473#[test] 474fn test_dpop_uri_normalization_preserves_path() { 475 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 476 let url = "https://pds.example/xrpc/com.atproto.server.getSession"; 477 478 let (proof, _) = create_dpop_proof("GET", url, 0, "ES256", None, None); 479 480 let different_path = "https://pds.example/xrpc/com.atproto.server.refreshSession"; 481 let result = verifier.verify_proof(&proof, "GET", different_path, None); 482 assert!(result.is_err(), "Different path should fail"); 483} 484 485#[test] 486fn test_dpop_htu_must_be_full_url_not_path() { 487 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 488 let full_url = "https://pds.example/xrpc/com.atproto.server.getSession"; 489 let path_only = "/xrpc/com.atproto.server.getSession"; 490 491 let (proof_with_path, _) = create_dpop_proof("GET", path_only, 0, "ES256", None, None); 492 let result = verifier.verify_proof(&proof_with_path, "GET", full_url, None); 493 assert!( 494 result.is_err(), 495 "htu with path-only should not match full URL" 496 ); 497 498 let (proof_with_full, _) = create_dpop_proof("GET", full_url, 0, "ES256", None, None); 499 let result = verifier.verify_proof(&proof_with_full, "GET", full_url, None); 500 assert!(result.is_ok(), "htu with full URL should match"); 501} 502 503#[test] 504fn test_dpop_htu_scheme_must_match() { 505 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 506 let https_url = "https://pds.example/xrpc/foo"; 507 let http_url = "http://pds.example/xrpc/foo"; 508 509 let (proof, _) = create_dpop_proof("GET", http_url, 0, "ES256", None, None); 510 let result = verifier.verify_proof(&proof, "GET", https_url, None); 511 assert!(result.is_err(), "HTTP vs HTTPS scheme mismatch should fail"); 512} 513 514#[test] 515fn test_dpop_htu_host_must_match() { 516 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 517 let url1 = "https://pds1.example/xrpc/foo"; 518 let url2 = "https://pds2.example/xrpc/foo"; 519 520 let (proof, _) = create_dpop_proof("GET", url1, 0, "ES256", None, None); 521 let result = verifier.verify_proof(&proof, "GET", url2, None); 522 assert!(result.is_err(), "Different host should fail"); 523} 524 525#[test] 526fn test_dpop_server_must_check_full_url_not_path() { 527 let verifier = DPoPVerifier::new(b"test-secret-32-bytes-long!!!!!!!"); 528 let full_url = "https://pds.example/xrpc/com.atproto.server.getSession"; 529 let path_only = "/xrpc/com.atproto.server.getSession"; 530 531 let (proof, _) = create_dpop_proof("GET", full_url, 0, "ES256", None, None); 532 let result = verifier.verify_proof(&proof, "GET", path_only, None); 533 assert!( 534 result.is_err(), 535 "Server checking path-only against full URL htu should fail" 536 ); 537}