this repo has no description
1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use chrono::Utc;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7use crate::OAuthError;
8use tranquil_types::{DPoPProofId, JwkThumbprint};
9
10const DPOP_NONCE_VALIDITY_SECS: i64 = 300;
11const DPOP_MAX_AGE_SECS: i64 = 300;
12
13#[derive(Debug, Clone)]
14pub struct DPoPVerifyResult {
15 pub jkt: JwkThumbprint,
16 pub jti: DPoPProofId,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DPoPProofHeader {
21 pub typ: String,
22 pub alg: String,
23 pub jwk: DPoPJwk,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct DPoPJwk {
28 pub kty: String,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub crv: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub x: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub y: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct DPoPProofPayload {
39 pub jti: String,
40 pub htm: String,
41 pub htu: String,
42 pub iat: i64,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub ath: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub nonce: Option<String>,
47}
48
49pub struct DPoPVerifier {
50 secret: Vec<u8>,
51}
52
53impl DPoPVerifier {
54 pub fn new(secret: &[u8]) -> Self {
55 Self {
56 secret: secret.to_vec(),
57 }
58 }
59
60 pub fn generate_nonce(&self) -> String {
61 let timestamp = Utc::now().timestamp();
62 let timestamp_bytes = timestamp.to_be_bytes();
63 let mut hasher = Sha256::new();
64 hasher.update(&self.secret);
65 hasher.update(timestamp_bytes);
66 let hash = hasher.finalize();
67 let mut nonce_data = Vec::with_capacity(8 + 16);
68 nonce_data.extend_from_slice(×tamp_bytes);
69 nonce_data.extend_from_slice(&hash[..16]);
70 URL_SAFE_NO_PAD.encode(&nonce_data)
71 }
72
73 pub fn validate_nonce(&self, nonce: &str) -> Result<(), OAuthError> {
74 let nonce_bytes = URL_SAFE_NO_PAD
75 .decode(nonce)
76 .map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce encoding".to_string()))?;
77 if nonce_bytes.len() < 24 {
78 return Err(OAuthError::InvalidDpopProof(
79 "Invalid nonce length".to_string(),
80 ));
81 }
82 let timestamp_bytes: [u8; 8] = nonce_bytes[..8]
83 .try_into()
84 .map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce".to_string()))?;
85 let timestamp = i64::from_be_bytes(timestamp_bytes);
86 let now = Utc::now().timestamp();
87 if now - timestamp > DPOP_NONCE_VALIDITY_SECS {
88 return Err(OAuthError::UseDpopNonce(self.generate_nonce()));
89 }
90 let mut hasher = Sha256::new();
91 hasher.update(&self.secret);
92 hasher.update(timestamp_bytes);
93 let expected_hash = hasher.finalize();
94 if nonce_bytes[8..24] != expected_hash[..16] {
95 return Err(OAuthError::InvalidDpopProof(
96 "Invalid nonce signature".to_string(),
97 ));
98 }
99 Ok(())
100 }
101
102 pub fn verify_proof(
103 &self,
104 dpop_header: &str,
105 http_method: &str,
106 http_uri: &str,
107 access_token_hash: Option<&str>,
108 ) -> Result<DPoPVerifyResult, OAuthError> {
109 let parts: Vec<&str> = dpop_header.split('.').collect();
110 if parts.len() != 3 {
111 return Err(OAuthError::InvalidDpopProof(
112 "Invalid DPoP proof format".to_string(),
113 ));
114 }
115 let header_json = URL_SAFE_NO_PAD
116 .decode(parts[0])
117 .map_err(|_| OAuthError::InvalidDpopProof("Invalid header encoding".to_string()))?;
118 let payload_json = URL_SAFE_NO_PAD
119 .decode(parts[1])
120 .map_err(|_| OAuthError::InvalidDpopProof("Invalid payload encoding".to_string()))?;
121 let header: DPoPProofHeader = serde_json::from_slice(&header_json)
122 .map_err(|_| OAuthError::InvalidDpopProof("Invalid header JSON".to_string()))?;
123 let payload: DPoPProofPayload = serde_json::from_slice(&payload_json)
124 .map_err(|_| OAuthError::InvalidDpopProof("Invalid payload JSON".to_string()))?;
125 if header.typ != "dpop+jwt" {
126 return Err(OAuthError::InvalidDpopProof(
127 "Invalid typ claim".to_string(),
128 ));
129 }
130 if !matches!(header.alg.as_str(), "ES256" | "ES384" | "ES512" | "EdDSA") {
131 return Err(OAuthError::InvalidDpopProof(
132 "Unsupported algorithm".to_string(),
133 ));
134 }
135 if payload.htm.to_uppercase() != http_method.to_uppercase() {
136 return Err(OAuthError::InvalidDpopProof(
137 "HTTP method mismatch".to_string(),
138 ));
139 }
140 let proof_uri = payload.htu.split('?').next().unwrap_or(&payload.htu);
141 let request_uri = http_uri.split('?').next().unwrap_or(http_uri);
142 if proof_uri != request_uri {
143 return Err(OAuthError::InvalidDpopProof(
144 "HTTP URI mismatch".to_string(),
145 ));
146 }
147 let now = Utc::now().timestamp();
148 if (now - payload.iat).abs() > DPOP_MAX_AGE_SECS {
149 return Err(OAuthError::InvalidDpopProof(
150 "Proof too old or from the future".to_string(),
151 ));
152 }
153 if let Some(nonce) = &payload.nonce {
154 self.validate_nonce(nonce)?;
155 }
156 if let Some(expected_ath) = access_token_hash {
157 match &payload.ath {
158 Some(ath) if ath == expected_ath => {}
159 Some(_) => {
160 return Err(OAuthError::InvalidDpopProof(
161 "Access token hash mismatch".to_string(),
162 ));
163 }
164 None => {
165 return Err(OAuthError::InvalidDpopProof(
166 "Missing access token hash".to_string(),
167 ));
168 }
169 }
170 }
171 let signature_bytes = URL_SAFE_NO_PAD
172 .decode(parts[2])
173 .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature encoding".to_string()))?;
174 let signing_input = format!("{}.{}", parts[0], parts[1]);
175 verify_dpop_signature(
176 &header.alg,
177 &header.jwk,
178 signing_input.as_bytes(),
179 &signature_bytes,
180 )?;
181 let jkt = compute_jwk_thumbprint(&header.jwk)?;
182 Ok(DPoPVerifyResult {
183 jkt: jkt.into(),
184 jti: payload.jti.clone().into(),
185 })
186 }
187}
188
189fn verify_dpop_signature(
190 alg: &str,
191 jwk: &DPoPJwk,
192 message: &[u8],
193 signature: &[u8],
194) -> Result<(), OAuthError> {
195 match alg {
196 "ES256" => verify_es256(jwk, message, signature),
197 "ES384" => verify_es384(jwk, message, signature),
198 "EdDSA" => verify_eddsa(jwk, message, signature),
199 _ => Err(OAuthError::InvalidDpopProof(format!(
200 "Unsupported algorithm: {}",
201 alg
202 ))),
203 }
204}
205
206fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
207 use p256::ecdsa::signature::Verifier;
208 use p256::ecdsa::{Signature, VerifyingKey};
209 use p256::elliptic_curve::sec1::FromEncodedPoint;
210 use p256::{AffinePoint, EncodedPoint};
211 let crv = jwk
212 .crv
213 .as_ref()
214 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for ES256".to_string()))?;
215 if crv != "P-256" {
216 return Err(OAuthError::InvalidDpopProof(format!(
217 "Invalid curve for ES256: {}",
218 crv
219 )));
220 }
221 let x_decoded = URL_SAFE_NO_PAD
222 .decode(
223 jwk.x
224 .as_ref()
225 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
226 )
227 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
228 let y_decoded = URL_SAFE_NO_PAD
229 .decode(
230 jwk.y
231 .as_ref()
232 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
233 )
234 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
235 let mut x_bytes = [0u8; 32];
236 let mut y_bytes = [0u8; 32];
237 if x_decoded.len() > 32 || y_decoded.len() > 32 {
238 return Err(OAuthError::InvalidDpopProof(
239 "EC coordinate too long".to_string(),
240 ));
241 }
242 x_bytes[32 - x_decoded.len()..].copy_from_slice(&x_decoded);
243 y_bytes[32 - y_decoded.len()..].copy_from_slice(&y_decoded);
244 let point = EncodedPoint::from_affine_coordinates((&x_bytes).into(), (&y_bytes).into(), false);
245 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into();
246 let affine =
247 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
248 let verifying_key = VerifyingKey::from_affine(affine)
249 .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?;
250 let sig = Signature::from_slice(signature)
251 .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature format".to_string()))?;
252 verifying_key
253 .verify(message, &sig)
254 .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
255}
256
257fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
258 use p384::ecdsa::signature::Verifier;
259 use p384::ecdsa::{Signature, VerifyingKey};
260 use p384::elliptic_curve::sec1::FromEncodedPoint;
261 use p384::{AffinePoint, EncodedPoint};
262 let crv = jwk
263 .crv
264 .as_ref()
265 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for ES384".to_string()))?;
266 if crv != "P-384" {
267 return Err(OAuthError::InvalidDpopProof(format!(
268 "Invalid curve for ES384: {}",
269 crv
270 )));
271 }
272 let x_decoded = URL_SAFE_NO_PAD
273 .decode(
274 jwk.x
275 .as_ref()
276 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
277 )
278 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
279 let y_decoded = URL_SAFE_NO_PAD
280 .decode(
281 jwk.y
282 .as_ref()
283 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
284 )
285 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
286 let mut x_bytes = [0u8; 48];
287 let mut y_bytes = [0u8; 48];
288 if x_decoded.len() > 48 || y_decoded.len() > 48 {
289 return Err(OAuthError::InvalidDpopProof(
290 "EC coordinate too long".to_string(),
291 ));
292 }
293 x_bytes[48 - x_decoded.len()..].copy_from_slice(&x_decoded);
294 y_bytes[48 - y_decoded.len()..].copy_from_slice(&y_decoded);
295 let point = EncodedPoint::from_affine_coordinates((&x_bytes).into(), (&y_bytes).into(), false);
296 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into();
297 let affine =
298 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
299 let verifying_key = VerifyingKey::from_affine(affine)
300 .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?;
301 let sig = Signature::from_slice(signature)
302 .map_err(|_| OAuthError::InvalidDpopProof("Invalid signature format".to_string()))?;
303 verifying_key
304 .verify(message, &sig)
305 .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
306}
307
308fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
309 use ed25519_dalek::{Signature, VerifyingKey};
310 let crv = jwk
311 .crv
312 .as_ref()
313 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string()))?;
314 if crv != "Ed25519" {
315 return Err(OAuthError::InvalidDpopProof(format!(
316 "Invalid curve for EdDSA: {}",
317 crv
318 )));
319 }
320 let x_bytes = URL_SAFE_NO_PAD
321 .decode(
322 jwk.x
323 .as_ref()
324 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
325 )
326 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
327 let key_bytes: [u8; 32] = x_bytes
328 .try_into()
329 .map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string()))?;
330 let verifying_key = VerifyingKey::from_bytes(&key_bytes)
331 .map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key".to_string()))?;
332 let sig_bytes: [u8; 64] = signature.try_into().map_err(|_| {
333 OAuthError::InvalidDpopProof("Invalid Ed25519 signature length".to_string())
334 })?;
335 let sig = Signature::from_bytes(&sig_bytes);
336 verifying_key
337 .verify_strict(message, &sig)
338 .map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
339}
340
341pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
342 let canonical = match jwk.kty.as_str() {
343 "EC" => {
344 let crv = jwk
345 .crv
346 .as_ref()
347 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv".to_string()))?;
348 let x = jwk
349 .x
350 .as_ref()
351 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x".to_string()))?;
352 let y = jwk
353 .y
354 .as_ref()
355 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y".to_string()))?;
356 format!(r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#, crv, x, y)
357 }
358 "OKP" => {
359 let crv = jwk
360 .crv
361 .as_ref()
362 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv".to_string()))?;
363 let x = jwk
364 .x
365 .as_ref()
366 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x".to_string()))?;
367 format!(r#"{{"crv":"{}","kty":"OKP","x":"{}"}}"#, crv, x)
368 }
369 _ => {
370 return Err(OAuthError::InvalidDpopProof(
371 "Unsupported key type".to_string(),
372 ));
373 }
374 };
375 let mut hasher = Sha256::new();
376 hasher.update(canonical.as_bytes());
377 let hash = hasher.finalize();
378 Ok(URL_SAFE_NO_PAD.encode(hash))
379}
380
381pub fn compute_access_token_hash(access_token: &str) -> String {
382 let mut hasher = Sha256::new();
383 hasher.update(access_token.as_bytes());
384 let hash = hasher.finalize();
385 URL_SAFE_NO_PAD.encode(hash)
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_nonce_generation_and_validation() {
394 let secret = b"test-secret-key-32-bytes-long!!!";
395 let verifier = DPoPVerifier::new(secret);
396 let nonce = verifier.generate_nonce();
397 assert!(verifier.validate_nonce(&nonce).is_ok());
398 }
399
400 #[test]
401 fn test_jwk_thumbprint_ec() {
402 let jwk = DPoPJwk {
403 kty: "EC".to_string(),
404 crv: Some("P-256".to_string()),
405 x: Some("test_x".to_string()),
406 y: Some("test_y".to_string()),
407 };
408 let thumbprint = compute_jwk_thumbprint(&jwk).unwrap();
409 assert!(!thumbprint.is_empty());
410 }
411
412 #[test]
413 fn test_es256_short_coordinate_no_panic() {
414 let short_31_bytes = vec![0x42u8; 31];
415 let short_30_bytes = vec![0x42u8; 30];
416 let x_b64 = URL_SAFE_NO_PAD.encode(&short_31_bytes);
417 let y_b64 = URL_SAFE_NO_PAD.encode(&short_30_bytes);
418 let jwk = DPoPJwk {
419 kty: "EC".to_string(),
420 crv: Some("P-256".to_string()),
421 x: Some(x_b64),
422 y: Some(y_b64),
423 };
424 let result = verify_es256(&jwk, b"test", &[0u8; 64]);
425 assert!(result.is_err(), "Invalid coordinates should return error, not panic");
426 }
427
428 #[test]
429 fn test_es256_valid_key_with_trimmed_coordinates() {
430 use p256::ecdsa::{SigningKey, signature::Signer};
431 use p256::elliptic_curve::rand_core::OsRng;
432
433 let signing_key = SigningKey::random(&mut OsRng);
434 let verifying_key = signing_key.verifying_key();
435 let point = verifying_key.to_encoded_point(false);
436 let x_bytes = point.x().unwrap();
437 let y_bytes = point.y().unwrap();
438 let x_trimmed: Vec<u8> = x_bytes.iter().copied().skip_while(|&b| b == 0).collect();
439 let y_trimmed: Vec<u8> = y_bytes.iter().copied().skip_while(|&b| b == 0).collect();
440 let x_b64 = URL_SAFE_NO_PAD.encode(&x_trimmed);
441 let y_b64 = URL_SAFE_NO_PAD.encode(&y_trimmed);
442 let jwk = DPoPJwk {
443 kty: "EC".to_string(),
444 crv: Some("P-256".to_string()),
445 x: Some(x_b64),
446 y: Some(y_b64),
447 };
448 let message = b"test message for signature verification";
449 let signature: p256::ecdsa::Signature = signing_key.sign(message);
450 let result = verify_es256(&jwk, message, signature.to_bytes().as_slice());
451 assert!(
452 result.is_ok(),
453 "Should verify signature with trimmed coordinates (x={}, y={}): {:?}",
454 x_trimmed.len(),
455 y_trimmed.len(),
456 result
457 );
458 }
459}