this repo has no description
1#![allow(unused_imports)] 2mod common; 3mod helpers; 4use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5use tranquil_pds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint}; 6use chrono::Utc; 7use common::{base_url, client}; 8use helpers::verify_new_account; 9use reqwest::{StatusCode, redirect}; 10use serde_json::{Value, json}; 11use sha2::{Digest, Sha256}; 12use wiremock::matchers::{method, path}; 13use wiremock::{Mock, MockServer, ResponseTemplate}; 14 15fn no_redirect_client() -> reqwest::Client { 16 reqwest::Client::builder().redirect(redirect::Policy::none()).build().unwrap() 17} 18 19fn generate_pkce() -> (String, String) { 20 let verifier_bytes: [u8; 32] = rand::random(); 21 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 22 let mut hasher = Sha256::new(); 23 hasher.update(code_verifier.as_bytes()); 24 let code_challenge = URL_SAFE_NO_PAD.encode(&hasher.finalize()); 25 (code_verifier, code_challenge) 26} 27 28async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { 29 let mock_server = MockServer::start().await; 30 let metadata = json!({ 31 "client_id": mock_server.uri(), 32 "client_name": "Security Test Client", 33 "redirect_uris": [redirect_uri], 34 "grant_types": ["authorization_code", "refresh_token"], 35 "response_types": ["code"], 36 "token_endpoint_auth_method": "none", 37 "dpop_bound_access_tokens": false 38 }); 39 Mock::given(method("GET")).and(path("/")) 40 .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 41 .mount(&mock_server).await; 42 mock_server 43} 44 45async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) { 46 let ts = Utc::now().timestamp_millis(); 47 let handle = format!("sec-test-{}", ts); 48 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 49 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" })) 50 .send().await.unwrap(); 51 let account: Value = create_res.json().await.unwrap(); 52 let did = account["did"].as_str().unwrap(); 53 verify_new_account(http_client, did).await; 54 let redirect_uri = "https://example.com/sec-callback"; 55 let mock_client = setup_mock_client_metadata(redirect_uri).await; 56 let client_id = mock_client.uri(); 57 let (code_verifier, code_challenge) = generate_pkce(); 58 let par_body: Value = http_client.post(format!("{}/oauth/par", url)) 59 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), 60 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")]) 61 .send().await.unwrap().json().await.unwrap(); 62 let request_uri = par_body["request_uri"].as_str().unwrap(); 63 let auth_client = no_redirect_client(); 64 let auth_res = auth_client.post(format!("{}/oauth/authorize", url)) 65 .form(&[("request_uri", request_uri), ("username", &handle), ("password", "security-test-password"), ("remember_device", "false")]) 66 .send().await.unwrap(); 67 let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 68 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 69 let token_body: Value = http_client.post(format!("{}/oauth/token", url)) 70 .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri), 71 ("code_verifier", &code_verifier), ("client_id", &client_id)]) 72 .send().await.unwrap().json().await.unwrap(); 73 (token_body["access_token"].as_str().unwrap().to_string(), 74 token_body["refresh_token"].as_str().unwrap().to_string(), client_id) 75} 76 77#[tokio::test] 78async fn test_token_tampering_attacks() { 79 let url = base_url().await; 80 let http_client = client(); 81 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; 82 let parts: Vec<&str> = access_token.split('.').collect(); 83 assert_eq!(parts.len(), 3); 84 let forged_sig = URL_SAFE_NO_PAD.encode(&[0u8; 32]); 85 let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_sig); 86 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 87 .bearer_auth(&forged_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Forged signature should be rejected"); 88 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 89 let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap(); 90 payload["sub"] = json!("did:plc:attacker"); 91 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 92 let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]); 93 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 94 .bearer_auth(&modified_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected"); 95 let none_header = json!({ "alg": "none", "typ": "at+jwt" }); 96 let none_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:attacker", "aud": "https://test.pds", 97 "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "fake", "scope": "atproto" }); 98 let none_token = format!("{}.{}.", URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()), 99 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap())); 100 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 101 .bearer_auth(&none_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "alg=none should be rejected"); 102 let rs256_header = json!({ "alg": "RS256", "typ": "at+jwt" }); 103 let rs256_token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()), 104 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 64])); 105 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 106 .bearer_auth(&rs256_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Algorithm substitution should be rejected"); 107 let expired_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:test", "aud": "https://test.pds", 108 "iat": Utc::now().timestamp() - 7200, "exp": Utc::now().timestamp() - 3600, "jti": "expired" }); 109 let expired_token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()), 110 URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 32])); 111 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 112 .bearer_auth(&expired_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected"); 113} 114 115#[tokio::test] 116async fn test_pkce_security() { 117 let url = base_url().await; 118 let http_client = client(); 119 let redirect_uri = "https://example.com/pkce-callback"; 120 let mock_client = setup_mock_client_metadata(redirect_uri).await; 121 let client_id = mock_client.uri(); 122 let res = http_client.post(format!("{}/oauth/par", url)) 123 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), 124 ("code_challenge", "plain-text-challenge"), ("code_challenge_method", "plain")]) 125 .send().await.unwrap(); 126 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "PKCE plain method should be rejected"); 127 let body: Value = res.json().await.unwrap(); 128 assert!(body["error_description"].as_str().unwrap().to_lowercase().contains("s256")); 129 let res = http_client.post(format!("{}/oauth/par", url)) 130 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri)]) 131 .send().await.unwrap(); 132 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected"); 133 let ts = Utc::now().timestamp_millis(); 134 let handle = format!("pkce-attack-{}", ts); 135 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 136 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" })) 137 .send().await.unwrap(); 138 let account: Value = create_res.json().await.unwrap(); 139 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 140 let (_, code_challenge) = generate_pkce(); 141 let (attacker_verifier, _) = generate_pkce(); 142 let par_body: Value = http_client.post(format!("{}/oauth/par", url)) 143 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), 144 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")]) 145 .send().await.unwrap().json().await.unwrap(); 146 let request_uri = par_body["request_uri"].as_str().unwrap(); 147 let auth_client = no_redirect_client(); 148 let auth_res = auth_client.post(format!("{}/oauth/authorize", url)) 149 .form(&[("request_uri", request_uri), ("username", &handle), ("password", "pkce-password"), ("remember_device", "false")]) 150 .send().await.unwrap(); 151 let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 152 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 153 let token_res = http_client.post(format!("{}/oauth/token", url)) 154 .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri), 155 ("code_verifier", &attacker_verifier), ("client_id", &client_id)]) 156 .send().await.unwrap(); 157 assert_eq!(token_res.status(), StatusCode::BAD_REQUEST, "Wrong PKCE verifier should be rejected"); 158} 159 160#[tokio::test] 161async fn test_replay_attacks() { 162 let url = base_url().await; 163 let http_client = client(); 164 let ts = Utc::now().timestamp_millis(); 165 let handle = format!("replay-{}", ts); 166 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 167 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" })) 168 .send().await.unwrap(); 169 let account: Value = create_res.json().await.unwrap(); 170 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 171 let redirect_uri = "https://example.com/replay-callback"; 172 let mock_client = setup_mock_client_metadata(redirect_uri).await; 173 let client_id = mock_client.uri(); 174 let (code_verifier, code_challenge) = generate_pkce(); 175 let par_body: Value = http_client.post(format!("{}/oauth/par", url)) 176 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), 177 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")]) 178 .send().await.unwrap().json().await.unwrap(); 179 let request_uri = par_body["request_uri"].as_str().unwrap(); 180 let auth_client = no_redirect_client(); 181 let auth_res = auth_client.post(format!("{}/oauth/authorize", url)) 182 .form(&[("request_uri", request_uri), ("username", &handle), ("password", "replay-password"), ("remember_device", "false")]) 183 .send().await.unwrap(); 184 let location = auth_res.headers().get("location").unwrap().to_str().unwrap(); 185 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap().to_string(); 186 let first = http_client.post(format!("{}/oauth/token", url)) 187 .form(&[("grant_type", "authorization_code"), ("code", &code), ("redirect_uri", redirect_uri), 188 ("code_verifier", &code_verifier), ("client_id", &client_id)]) 189 .send().await.unwrap(); 190 assert_eq!(first.status(), StatusCode::OK, "First use should succeed"); 191 let first_body: Value = first.json().await.unwrap(); 192 let replay = http_client.post(format!("{}/oauth/token", url)) 193 .form(&[("grant_type", "authorization_code"), ("code", &code), ("redirect_uri", redirect_uri), 194 ("code_verifier", &code_verifier), ("client_id", &client_id)]) 195 .send().await.unwrap(); 196 assert_eq!(replay.status(), StatusCode::BAD_REQUEST, "Auth code replay should fail"); 197 let stolen_rt = first_body["refresh_token"].as_str().unwrap().to_string(); 198 let first_refresh: Value = http_client.post(format!("{}/oauth/token", url)) 199 .form(&[("grant_type", "refresh_token"), ("refresh_token", &stolen_rt), ("client_id", &client_id)]) 200 .send().await.unwrap().json().await.unwrap(); 201 assert!(first_refresh["access_token"].is_string(), "First refresh should succeed"); 202 let new_rt = first_refresh["refresh_token"].as_str().unwrap(); 203 let rt_replay = http_client.post(format!("{}/oauth/token", url)) 204 .form(&[("grant_type", "refresh_token"), ("refresh_token", &stolen_rt), ("client_id", &client_id)]) 205 .send().await.unwrap(); 206 assert_eq!(rt_replay.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail"); 207 let body: Value = rt_replay.json().await.unwrap(); 208 assert!(body["error_description"].as_str().unwrap().to_lowercase().contains("reuse")); 209 let family_revoked = http_client.post(format!("{}/oauth/token", url)) 210 .form(&[("grant_type", "refresh_token"), ("refresh_token", new_rt), ("client_id", &client_id)]) 211 .send().await.unwrap(); 212 assert_eq!(family_revoked.status(), StatusCode::BAD_REQUEST, "Token family should be revoked"); 213} 214 215#[tokio::test] 216async fn test_oauth_security_boundaries() { 217 let url = base_url().await; 218 let http_client = client(); 219 let registered_redirect = "https://legitimate-app.com/callback"; 220 let mock_client = setup_mock_client_metadata(registered_redirect).await; 221 let client_id = mock_client.uri(); 222 let (_, code_challenge) = generate_pkce(); 223 let res = http_client.post(format!("{}/oauth/par", url)) 224 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", "https://attacker.com/steal"), 225 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")]) 226 .send().await.unwrap(); 227 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected"); 228 let ts = Utc::now().timestamp_millis(); 229 let handle = format!("deact-{}", ts); 230 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 231 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "deact-password" })) 232 .send().await.unwrap(); 233 let account: Value = create_res.json().await.unwrap(); 234 let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 235 http_client.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url)) 236 .bearer_auth(&access_jwt).json(&json!({})).send().await.unwrap(); 237 let deact_par: Value = http_client.post(format!("{}/oauth/par", url)) 238 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", registered_redirect), 239 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")]) 240 .send().await.unwrap().json().await.unwrap(); 241 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 242 .header("Accept", "application/json") 243 .form(&[("request_uri", deact_par["request_uri"].as_str().unwrap()), ("username", &handle), ("password", "deact-password"), ("remember_device", "false")]) 244 .send().await.unwrap(); 245 assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked"); 246 let redirect_uri_a = "https://app-a.com/callback"; 247 let mock_a = setup_mock_client_metadata(redirect_uri_a).await; 248 let client_id_a = mock_a.uri(); 249 let mock_b = setup_mock_client_metadata("https://app-b.com/callback").await; 250 let client_id_b = mock_b.uri(); 251 let ts2 = Utc::now().timestamp_millis(); 252 let handle2 = format!("cross-{}", ts2); 253 let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 254 .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" })) 255 .send().await.unwrap(); 256 let account2: Value = create_res2.json().await.unwrap(); 257 verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; 258 let (code_verifier2, code_challenge2) = generate_pkce(); 259 let par_a: Value = http_client.post(format!("{}/oauth/par", url)) 260 .form(&[("response_type", "code"), ("client_id", &client_id_a), ("redirect_uri", redirect_uri_a), 261 ("code_challenge", &code_challenge2), ("code_challenge_method", "S256")]) 262 .send().await.unwrap().json().await.unwrap(); 263 let auth_client = no_redirect_client(); 264 let auth_a = auth_client.post(format!("{}/oauth/authorize", url)) 265 .form(&[("request_uri", par_a["request_uri"].as_str().unwrap()), ("username", &handle2), ("password", "cross-password"), ("remember_device", "false")]) 266 .send().await.unwrap(); 267 let loc_a = auth_a.headers().get("location").unwrap().to_str().unwrap(); 268 let code_a = loc_a.split("code=").nth(1).unwrap().split('&').next().unwrap(); 269 let cross_client = http_client.post(format!("{}/oauth/token", url)) 270 .form(&[("grant_type", "authorization_code"), ("code", code_a), ("redirect_uri", redirect_uri_a), 271 ("code_verifier", &code_verifier2), ("client_id", &client_id_b)]) 272 .send().await.unwrap(); 273 assert_eq!(cross_client.status(), StatusCode::BAD_REQUEST, "Cross-client code exchange must be rejected"); 274} 275 276#[tokio::test] 277async fn test_malformed_tokens_and_headers() { 278 let url = base_url().await; 279 let http_client = client(); 280 let malformed = vec!["", "not-a-token", "one.two", "one.two.three.four", "....", "eyJhbGciOiJIUzI1NiJ9", 281 "eyJhbGciOiJIUzI1NiJ9.", "eyJhbGciOiJIUzI1NiJ9..", ".eyJzdWIiOiJ0ZXN0In0.", "!!invalid!!.eyJ9.sig"]; 282 for token in &malformed { 283 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 284 .bearer_auth(token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED); 285 } 286 let wrong_types = vec!["JWT", "jwt", "at+JWT", ""]; 287 for typ in wrong_types { 288 let header = json!({ "alg": "HS256", "typ": typ }); 289 let payload = json!({ "iss": "x", "sub": "did:plc:x", "aud": "x", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "x" }); 290 let token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), 291 URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 32])); 292 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 293 .bearer_auth(&token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "typ='{}' should be rejected", typ); 294 } 295 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await; 296 let invalid_formats = vec![format!("Basic {}", access_token), format!("Digest {}", access_token), 297 access_token.clone(), format!("Bearer{}", access_token)]; 298 for auth in &invalid_formats { 299 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 300 .header("Authorization", auth).send().await.unwrap().status(), StatusCode::UNAUTHORIZED); 301 } 302 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 303 .send().await.unwrap().status(), StatusCode::UNAUTHORIZED); 304 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url)) 305 .header("Authorization", "").send().await.unwrap().status(), StatusCode::UNAUTHORIZED); 306 let grants = vec!["client_credentials", "password", "implicit", "", "AUTHORIZATION_CODE"]; 307 for grant in grants { 308 assert_eq!(http_client.post(format!("{}/oauth/token", url)) 309 .form(&[("grant_type", grant), ("client_id", "https://example.com")]) 310 .send().await.unwrap().status(), StatusCode::BAD_REQUEST, "Grant '{}' should be rejected", grant); 311 } 312} 313 314#[tokio::test] 315async fn test_token_revocation() { 316 let url = base_url().await; 317 let http_client = client(); 318 let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await; 319 assert_eq!(http_client.post(format!("{}/oauth/revoke", url)) 320 .form(&[("token", &refresh_token)]).send().await.unwrap().status(), StatusCode::OK); 321 let introspect: Value = http_client.post(format!("{}/oauth/introspect", url)) 322 .form(&[("token", &access_token)]).send().await.unwrap().json().await.unwrap(); 323 assert_eq!(introspect["active"], false, "Revoked token should be inactive"); 324} 325 326fn create_dpop_proof(method: &str, uri: &str, _nonce: Option<&str>, ath: Option<&str>, iat_offset: i64) -> String { 327 use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 328 use p256::elliptic_curve::sec1::ToEncodedPoint; 329 let signing_key = SigningKey::random(&mut rand::thread_rng()); 330 let point = signing_key.verifying_key().to_encoded_point(false); 331 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 332 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 333 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); 334 let mut payload = json!({ "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 335 "htm": method, "htu": uri, "iat": Utc::now().timestamp() + iat_offset }); 336 if let Some(a) = ath { payload["ath"] = json!(a); } 337 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 338 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 339 let signing_input = format!("{}.{}", header_b64, payload_b64); 340 let signature: Signature = signing_key.sign(signing_input.as_bytes()); 341 format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes())) 342} 343 344#[test] 345fn test_dpop_nonce_security() { 346 let secret1 = b"test-dpop-secret-32-bytes-long!!"; 347 let secret2 = b"different-secret-32-bytes-long!!"; 348 let v1 = DPoPVerifier::new(secret1); 349 let v2 = DPoPVerifier::new(secret2); 350 let nonce = v1.generate_nonce(); 351 assert!(!nonce.is_empty()); 352 assert!(v1.validate_nonce(&nonce).is_ok(), "Valid nonce should pass"); 353 assert!(v2.validate_nonce(&nonce).is_err(), "Nonce from different secret should fail"); 354 let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap(); 355 let mut tampered = nonce_bytes.clone(); 356 if !tampered.is_empty() { tampered[0] ^= 0xFF; } 357 assert!(v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered)).is_err(), "Tampered nonce should fail"); 358 assert!(v1.validate_nonce("invalid").is_err()); 359 assert!(v1.validate_nonce("").is_err()); 360 assert!(v1.validate_nonce("!!!not-base64!!!").is_err()); 361} 362 363#[test] 364fn test_dpop_proof_validation() { 365 let secret = b"test-dpop-secret-32-bytes-long!!"; 366 let verifier = DPoPVerifier::new(secret); 367 assert!(verifier.verify_proof("not.enough", "POST", "https://example.com", None).is_err()); 368 assert!(verifier.verify_proof("invalid", "POST", "https://example.com", None).is_err()); 369 let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0); 370 assert!(verifier.verify_proof(&proof, "GET", "https://example.com/token", None).is_err(), "Method mismatch"); 371 assert!(verifier.verify_proof(&proof, "POST", "https://other.com/token", None).is_err(), "URI mismatch"); 372 assert!(verifier.verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None).is_ok(), "Query params should be ignored"); 373 let old_proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600); 374 assert!(verifier.verify_proof(&old_proof, "POST", "https://example.com/token", None).is_err(), "iat too old"); 375 let future_proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600); 376 assert!(verifier.verify_proof(&future_proof, "POST", "https://example.com/token", None).is_err(), "iat in future"); 377 let ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, Some("wrong"), 0); 378 assert!(verifier.verify_proof(&ath_proof, "GET", "https://example.com/resource", Some("correct")).is_err(), "ath mismatch"); 379 let no_ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0); 380 assert!(verifier.verify_proof(&no_ath_proof, "GET", "https://example.com/resource", Some("expected")).is_err(), "Missing ath"); 381} 382 383#[test] 384fn test_dpop_proof_signature_attacks() { 385 use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 386 use p256::elliptic_curve::sec1::ToEncodedPoint; 387 let secret = b"test-dpop-secret-32-bytes-long!!"; 388 let verifier = DPoPVerifier::new(secret); 389 let signing_key = SigningKey::random(&mut rand::thread_rng()); 390 let attacker_key = SigningKey::random(&mut rand::thread_rng()); 391 let attacker_point = attacker_key.verifying_key().to_encoded_point(false); 392 let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap()); 393 let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap()); 394 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); 395 let payload = json!({ "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 396 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() }); 397 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 398 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 399 let signing_input = format!("{}.{}", header_b64, payload_b64); 400 let signature: Signature = signing_key.sign(signing_input.as_bytes()); 401 let mismatched = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes())); 402 assert!(verifier.verify_proof(&mismatched, "POST", "https://example.com/token", None).is_err(), "Mismatched key should fail"); 403 let point = signing_key.verifying_key().to_encoded_point(false); 404 let good_header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", 405 "x": URL_SAFE_NO_PAD.encode(point.x().unwrap()), "y": URL_SAFE_NO_PAD.encode(point.y().unwrap()) } }); 406 let good_header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&good_header).unwrap()); 407 let good_input = format!("{}.{}", good_header_b64, payload_b64); 408 let good_sig: Signature = signing_key.sign(good_input.as_bytes()); 409 let mut sig_bytes = good_sig.to_bytes().to_vec(); 410 sig_bytes[0] ^= 0xFF; 411 let tampered = format!("{}.{}", good_input, URL_SAFE_NO_PAD.encode(&sig_bytes)); 412 assert!(verifier.verify_proof(&tampered, "POST", "https://example.com/token", None).is_err(), "Tampered sig should fail"); 413} 414 415#[test] 416fn test_jwk_thumbprint() { 417 let jwk = DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), 418 x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()), 419 y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()) }; 420 let tp1 = compute_jwk_thumbprint(&jwk).unwrap(); 421 let tp2 = compute_jwk_thumbprint(&jwk).unwrap(); 422 assert_eq!(tp1, tp2, "Thumbprint should be deterministic"); 423 assert!(!tp1.is_empty()); 424 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("secp256k1".to_string()), 425 x: Some("x".to_string()), y: Some("y".to_string()) }).is_ok()); 426 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "OKP".to_string(), crv: Some("Ed25519".to_string()), 427 x: Some("x".to_string()), y: None }).is_ok()); 428 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: None, x: Some("x".to_string()), y: Some("y".to_string()) }).is_err()); 429 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), x: None, y: Some("y".to_string()) }).is_err()); 430 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), x: Some("x".to_string()), y: None }).is_err()); 431 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "RSA".to_string(), crv: None, x: None, y: None }).is_err()); 432} 433 434#[test] 435fn test_dpop_clock_skew() { 436 use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 437 use p256::elliptic_curve::sec1::ToEncodedPoint; 438 let secret = b"test-dpop-secret-32-bytes-long!!"; 439 let verifier = DPoPVerifier::new(secret); 440 let test_cases = vec![(-600, true), (-301, true), (-299, false), (0, false), (299, false), (301, true), (600, true)]; 441 for (offset, should_fail) in test_cases { 442 let signing_key = SigningKey::random(&mut rand::thread_rng()); 443 let point = signing_key.verifying_key().to_encoded_point(false); 444 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 445 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 446 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); 447 let payload = json!({ "jti": format!("clock-{}-{}", offset, Utc::now().timestamp_nanos_opt().unwrap_or(0)), 448 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() + offset }); 449 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 450 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 451 let signing_input = format!("{}.{}", header_b64, payload_b64); 452 let signature: Signature = signing_key.sign(signing_input.as_bytes()); 453 let proof = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes())); 454 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None); 455 if should_fail { assert!(result.is_err(), "offset {} should fail", offset); } 456 else { assert!(result.is_ok(), "offset {} should pass", offset); } 457 } 458} 459 460#[test] 461fn test_dpop_http_method_case() { 462 use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 463 use p256::elliptic_curve::sec1::ToEncodedPoint; 464 let secret = b"test-dpop-secret-32-bytes-long!!"; 465 let verifier = DPoPVerifier::new(secret); 466 let signing_key = SigningKey::random(&mut rand::thread_rng()); 467 let point = signing_key.verifying_key().to_encoded_point(false); 468 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 469 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 470 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } }); 471 let payload = json!({ "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)), 472 "htm": "post", "htu": "https://example.com/token", "iat": Utc::now().timestamp() }); 473 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 474 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 475 let signing_input = format!("{}.{}", header_b64, payload_b64); 476 let signature: Signature = signing_key.sign(signing_input.as_bytes()); 477 let proof = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes())); 478 assert!(verifier.verify_proof(&proof, "POST", "https://example.com/token", None).is_ok(), "HTTP method should be case-insensitive"); 479}