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, 310, "ES256", None, None);
195 let result = verifier.verify_proof(&proof_301s_future, "GET", url, None);
196 assert!(
197 result.is_err(),
198 "310s in future should exceed clock skew tolerance"
199 );
200
201 let (proof_301s_past, _) = create_dpop_proof("GET", url, -310, "ES256", None, None);
202 let result = verifier.verify_proof(&proof_301s_past, "GET", url, None);
203 assert!(
204 result.is_err(),
205 "310s 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}