1use std::fmt;
50
51use base64::Engine;
52use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
53use p256::ecdsa::signature::{Signer, Verifier};
54use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
55use serde::{Deserialize, Serialize};
56use sha2::{Digest, Sha256};
57
58pub const MAX_AGE: u64 = 300;
60
61pub const MAX_SKEW: u64 = 10;
63
64const TYP: &str = "dpop+jwt";
66
67pub const ALG: &str = "ES256";
69
70#[derive(Clone)]
72pub struct Key(SigningKey);
73
74impl fmt::Debug for Key {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 write!(f, "Key({})", self.thumbprint())
78 }
79}
80
81impl Key {
82 pub fn generate() -> Self {
84 loop {
85 let mut bytes = [0u8; 32];
86 getrandom::fill(&mut bytes).expect("the OS gives random bytes");
88 if let Ok(key) = SigningKey::from_slice(&bytes) {
90 return Key(key);
91 }
92 }
93 }
94
95 pub fn from_secret(secret: &str) -> Result<Self, DpopError> {
97 let bytes = B64
98 .decode(secret)
99 .map_err(|_| DpopError::new("the key is not base64url"))?;
100 SigningKey::from_slice(&bytes)
101 .map(Key)
102 .map_err(|_| DpopError::new("the key is not a P-256 key"))
103 }
104
105 pub fn to_secret(&self) -> String {
107 B64.encode(self.0.to_bytes())
108 }
109
110 pub fn jwk(&self) -> Jwk {
112 let point = self.0.verifying_key().to_sec1_point(false);
113 let coordinate = |c: Option<&_>| B64.encode(c.map_or(&[][..], |c: &p256::FieldBytes| c));
114 Jwk {
115 kty: "EC".into(),
116 crv: "P-256".into(),
117 x: coordinate(point.x()),
118 y: coordinate(point.y()),
119 }
120 }
121
122 pub fn thumbprint(&self) -> String {
124 self.jwk().thumbprint()
125 }
126
127 pub fn proof(&self, method: &str, url: &str, access_token: Option<&str>, now: u64) -> String {
130 let header = Header {
131 typ: TYP.into(),
132 alg: ALG.into(),
133 jwk: self.jwk(),
134 };
135 let mut jti = [0u8; 16];
136 getrandom::fill(&mut jti).expect("the OS gives random bytes");
137 let claims = Claims {
138 jti: B64.encode(jti),
139 htm: method.into(),
140 htu: url.into(),
141 iat: now,
142 ath: access_token.map(token_hash),
143 };
144 let signing_input = format!("{}.{}", json_b64(&header), json_b64(&claims));
145 format!("{signing_input}.{}", self.sign(signing_input.as_bytes()))
146 }
147
148 pub(crate) fn sign(&self, input: &[u8]) -> String {
150 let signature: Signature = self.0.sign(input);
151 B64.encode(signature.to_bytes())
152 }
153}
154
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
157pub struct Jwk {
158 pub kty: String,
159 pub crv: String,
160 pub x: String,
161 pub y: String,
162}
163
164impl Jwk {
165 pub fn thumbprint(&self) -> String {
168 let canonical = format!(
169 r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#,
170 self.crv, self.kty, self.x, self.y
171 );
172 B64.encode(Sha256::digest(canonical.as_bytes()))
173 }
174
175 pub(crate) fn verifying_key(&self) -> Result<VerifyingKey, DpopError> {
176 if self.kty != "EC" || self.crv != "P-256" {
177 return Err(DpopError::new("the jwk is not a P-256 key"));
178 }
179 let x = B64.decode(&self.x).ok();
180 let y = B64.decode(&self.y).ok();
181 let (Some(x), Some(y)) = (x, y) else {
182 return Err(DpopError::new("the jwk coordinates are not base64url"));
183 };
184 if x.len() != 32 || y.len() != 32 {
185 return Err(DpopError::new("the jwk coordinates are not 32 bytes"));
186 }
187 let mut sec1 = vec![4u8];
188 sec1.extend(x);
189 sec1.extend(y);
190 VerifyingKey::from_sec1_bytes(&sec1)
191 .map_err(|_| DpopError::new("the jwk is not a point on P-256"))
192 }
193}
194
195#[derive(Clone, Debug, PartialEq, Eq)]
197pub struct Proof {
198 pub jkt: String,
200 pub jti: String,
202 pub iat: u64,
204}
205
206#[derive(Clone, Debug, PartialEq, Eq)]
208pub struct DpopError(String);
209
210impl DpopError {
211 fn new(message: &str) -> Self {
212 DpopError(message.to_owned())
213 }
214}
215
216impl fmt::Display for DpopError {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 f.write_str(&self.0)
219 }
220}
221
222impl std::error::Error for DpopError {}
223
224pub fn verify(
227 proof: &str,
228 method: &str,
229 url: &str,
230 access_token: Option<&str>,
231 now: u64,
232) -> Result<Proof, DpopError> {
233 let mut parts = proof.split('.');
234 let (Some(header), Some(claims), Some(signature), None) =
235 (parts.next(), parts.next(), parts.next(), parts.next())
236 else {
237 return Err(DpopError::new("the proof is not a compact JWT"));
238 };
239 let header: serde_json::Value = json_part(header)?;
240 if header.pointer("/jwk/d").is_some() {
242 return Err(DpopError::new("the proof jwk holds a private key"));
243 }
244 let header: Header = serde_json::from_value(header)
245 .map_err(|_| DpopError::new("a proof part is not valid JSON"))?;
246 if header.typ != TYP {
247 return Err(DpopError::new("the proof typ is not dpop+jwt"));
248 }
249 if header.alg != ALG {
250 return Err(DpopError::new("the proof alg is not ES256"));
251 }
252 let key = header.jwk.verifying_key()?;
253 let signature = B64
254 .decode(signature)
255 .ok()
256 .and_then(|bytes| Signature::from_slice(&bytes).ok())
257 .ok_or_else(|| DpopError::new("the proof signature is malformed"))?;
258 let signing_input = &proof[..proof.rfind('.').unwrap_or(0)];
259 key.verify(signing_input.as_bytes(), &signature)
260 .map_err(|_| DpopError::new("the proof signature is not valid"))?;
261
262 let claims: Claims = json_part(claims)?;
263 if claims.htm != method {
264 return Err(DpopError::new("the proof is for another method"));
265 }
266 if claims.htu != url {
267 return Err(DpopError::new("the proof is for another URL"));
268 }
269 if now.saturating_sub(claims.iat) > MAX_AGE {
270 return Err(DpopError::new("the proof is too old"));
271 }
272 if claims.iat > now + MAX_SKEW {
273 return Err(DpopError::new("the proof is from the future"));
274 }
275 if claims.jti.is_empty() || claims.jti.len() > 64 {
276 return Err(DpopError::new("the proof jti is empty or too long"));
277 }
278 if let Some(token) = access_token
279 && claims.ath.as_deref() != Some(&token_hash(token))
280 {
281 return Err(DpopError::new("the proof is for another access token"));
282 }
283 Ok(Proof {
284 jkt: header.jwk.thumbprint(),
285 jti: claims.jti,
286 iat: claims.iat,
287 })
288}
289
290pub fn token_hash(token: &str) -> String {
292 B64.encode(Sha256::digest(token.as_bytes()))
293}
294
295#[derive(Serialize, Deserialize)]
298pub(crate) struct Header {
299 pub(crate) typ: String,
300 pub(crate) alg: String,
301 pub(crate) jwk: Jwk,
302}
303
304#[derive(Serialize, Deserialize)]
305struct Claims {
306 jti: String,
307 htm: String,
308 htu: String,
309 iat: u64,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 ath: Option<String>,
312}
313
314pub(crate) fn json_b64<T: Serialize>(value: &T) -> String {
315 B64.encode(serde_json::to_vec(value).expect("the proof parts serialize"))
316}
317
318fn json_part<T: for<'de> Deserialize<'de>>(part: &str) -> Result<T, DpopError> {
319 let bytes = B64
320 .decode(part)
321 .map_err(|_| DpopError::new("a proof part is not base64url"))?;
322 serde_json::from_slice(&bytes).map_err(|_| DpopError::new("a proof part is not valid JSON"))
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 const URL: &str = "https://riff.example.com/v1/who";
330
331 #[test]
332 fn a_key_survives_the_keyring_format() {
333 let key = Key::generate();
334 let again = Key::from_secret(&key.to_secret()).unwrap();
335 assert_eq!(again.thumbprint(), key.thumbprint());
336 assert_ne!(Key::generate().thumbprint(), key.thumbprint());
337 assert!(Key::from_secret("nope!").is_err());
338 }
339
340 #[test]
341 fn debug_hides_the_private_key() {
342 let key = Key::generate();
343 assert!(!format!("{key:?}").contains(&key.to_secret()));
344 }
345
346 #[test]
347 fn thumbprint_matches_rfc_7638_members() {
348 let jwk = Jwk {
349 kty: "EC".into(),
350 crv: "P-256".into(),
351 x: "a".into(),
352 y: "b".into(),
353 };
354 let expected = B64.encode(Sha256::digest(
355 br#"{"crv":"P-256","kty":"EC","x":"a","y":"b"}"#,
356 ));
357 assert_eq!(jwk.thumbprint(), expected);
358 }
359
360 #[test]
361 fn a_good_proof_names_its_key() {
362 let key = Key::generate();
363 let proof = key.proof("POST", URL, None, 500);
364 let checked = verify(&proof, "POST", URL, None, 500).unwrap();
365 assert_eq!(checked.jkt, key.thumbprint());
366 assert_eq!(checked.iat, 500);
367 assert!(!checked.jti.is_empty());
368 let other = verify(&key.proof("POST", URL, None, 500), "POST", URL, None, 500).unwrap();
369 assert_ne!(other.jti, checked.jti);
370 }
371
372 #[test]
373 fn the_method_must_match() {
374 let proof = Key::generate().proof("POST", URL, None, 500);
375 assert!(verify(&proof, "GET", URL, None, 500).is_err());
376 }
377
378 #[test]
379 fn old_and_future_proofs_are_refused() {
380 let proof = Key::generate().proof("POST", URL, None, 1_000);
381 assert!(verify(&proof, "POST", URL, None, 1_000 + MAX_AGE).is_ok());
382 assert!(verify(&proof, "POST", URL, None, 1_000 + MAX_AGE + 1).is_err());
383 assert!(verify(&proof, "POST", URL, None, 1_000 - MAX_SKEW).is_ok());
384 assert!(verify(&proof, "POST", URL, None, 1_000 - MAX_SKEW - 1).is_err());
385 }
386
387 #[test]
388 fn a_token_needs_its_hash_in_the_proof() {
389 let key = Key::generate();
390 let without = key.proof("POST", URL, None, 500);
391 assert!(verify(&without, "POST", URL, Some("t"), 500).is_err());
392 let with = key.proof("POST", URL, Some("t"), 500);
393 assert!(verify(&with, "POST", URL, Some("t"), 500).is_ok());
394 }
395
396 #[test]
397 fn a_changed_proof_is_refused() {
398 let key = Key::generate();
399 let proof = key.proof("POST", URL, None, 500);
400 let (input, _) = proof.rsplit_once('.').unwrap();
401 let forged: Signature = Key::generate().0.sign(input.as_bytes());
403 let forged = format!("{input}.{}", B64.encode(forged.to_bytes()));
404 assert_eq!(
405 verify(&forged, "POST", URL, None, 500)
406 .unwrap_err()
407 .to_string(),
408 "the proof signature is not valid"
409 );
410 let other = key.proof("GET", URL, None, 500);
412 let mut parts: Vec<&str> = proof.split('.').collect();
413 parts[1] = other.split('.').nth(1).unwrap();
414 assert!(verify(&parts.join("."), "GET", URL, None, 500).is_err());
415 }
416
417 #[test]
418 fn malformed_proofs_are_refused() {
419 for bad in ["", "a.b", "a.b.c.d", "!!.!!.!!"] {
420 assert!(verify(bad, "POST", URL, None, 500).is_err(), "{bad}");
421 }
422 }
423
424 #[test]
425 fn a_private_jwk_is_refused() {
426 let key = Key::generate();
427 let mut header = serde_json::to_value(Header {
428 typ: TYP.into(),
429 alg: ALG.into(),
430 jwk: key.jwk(),
431 })
432 .unwrap();
433 header["jwk"]["d"] = "private".into();
434 let claims = key.proof("POST", URL, None, 500);
435 let claims = claims.split('.').nth(1).unwrap();
436 let input = format!("{}.{claims}", json_b64(&header));
437 let signature: Signature = key.0.sign(input.as_bytes());
438 let proof = format!("{input}.{}", B64.encode(signature.to_bytes()));
439 assert_eq!(
440 verify(&proof, "POST", URL, None, 500)
441 .unwrap_err()
442 .to_string(),
443 "the proof jwk holds a private key"
444 );
445 }
446
447 #[test]
448 fn a_huge_iat_is_refused_without_a_panic() {
449 let key = Key::generate();
450 let proof = key.proof("POST", URL, None, u64::MAX);
451 assert_eq!(
452 verify(&proof, "POST", URL, None, 500)
453 .unwrap_err()
454 .to_string(),
455 "the proof is from the future"
456 );
457 }
458
459 #[test]
460 fn other_algorithms_are_refused() {
461 let key = Key::generate();
462 let header = Header {
463 typ: TYP.into(),
464 alg: "none".into(),
465 jwk: key.jwk(),
466 };
467 let proof = key.proof("POST", URL, None, 500);
468 let rest = proof.split_once('.').unwrap().1;
469 let none = format!("{}.{rest}", json_b64(&header));
470 assert_eq!(
471 verify(&none, "POST", URL, None, 500)
472 .unwrap_err()
473 .to_string(),
474 "the proof alg is not ES256"
475 );
476 }
477}