Skip to main content

riff_core/
dpop.rs

1//! Device keys and DPoP proofs (RFC 9449, R18).
2//!
3//! # Design
4//!
5//! Each device has one P-256 key. The private part stays on the device,
6//! in the OS keyring. Each request carries a *proof*: a short JWT that
7//! the key signs (ES256). The proof names the method and the URL of the
8//! request, and the hash of the access token. So a copied token or a
9//! copied proof is of no use without the key.
10//!
11//! ```text
12//!  Authorization: DPoP <access token>
13//!  DPoP: <header>.<payload>.<signature>
14//!        header  {"typ":"dpop+jwt","alg":"ES256","jwk":{public key}}
15//!        payload {"jti","htm","htu","iat","ath"}
16//! ```
17//!
18//! The server binds each sign-in to the thumbprint of the key (RFC
19//! 7638). It accepts a token only with a proof from that key.
20//!
21//! # Rules for a valid proof
22//!
23//! - `typ` is `dpop+jwt`, `alg` is `ES256`, and `jwk` is a public P-256
24//!   key with no private part.
25//! - The signature is good for that key.
26//! - `htm` is the method and `htu` is the URL of the request, with no
27//!   query and no fragment.
28//! - `iat` is at most [`MAX_AGE`] seconds old, and at most
29//!   [`MAX_SKEW`] seconds in the future (R86).
30//! - `jti` is not empty. The server refuses a `jti` that it saw before.
31//! - With an access token, `ath` is its hash.
32//!
33//! # Example
34//!
35//! ```
36//! use riff_core::dpop::{Key, verify};
37//!
38//! let key = Key::generate();
39//! let url = "https://riff.example.com/v1/who";
40//! let proof = key.proof("POST", url, Some("t-1"), 1_000);
41//! let checked = verify(&proof, "POST", url, Some("t-1"), 1_000).unwrap();
42//! assert_eq!(checked.jkt, key.thumbprint());
43//!
44//! // The proof does not work for another token or another URL.
45//! assert!(verify(&proof, "POST", url, Some("t-2"), 1_000).is_err());
46//! assert!(verify(&proof, "POST", "https://riff.example.com/v1/post", Some("t-1"), 1_000).is_err());
47//! ```
48
49use 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
58/// A proof may be this many seconds old (R86).
59pub const MAX_AGE: u64 = 300;
60
61/// A proof may be this many seconds in the future, for clock skew (R86).
62pub const MAX_SKEW: u64 = 10;
63
64/// The `typ` of a proof.
65const TYP: &str = "dpop+jwt";
66
67/// The only signing algorithm.
68pub const ALG: &str = "ES256";
69
70/// The private key of a device.
71#[derive(Clone)]
72pub struct Key(SigningKey);
73
74impl fmt::Debug for Key {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        // Never print the private key.
77        write!(f, "Key({})", self.thumbprint())
78    }
79}
80
81impl Key {
82    /// Makes a new key from OS random bytes.
83    pub fn generate() -> Self {
84        loop {
85            let mut bytes = [0u8; 32];
86            // The OS random source fails only when the OS is broken.
87            getrandom::fill(&mut bytes).expect("the OS gives random bytes");
88            // Nearly each 32 bytes make a valid scalar; try again if not.
89            if let Ok(key) = SigningKey::from_slice(&bytes) {
90                return Key(key);
91            }
92        }
93    }
94
95    /// Reads a key from [`Key::to_secret`].
96    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    /// The private key as base64url. Keep it only in the OS keyring.
106    pub fn to_secret(&self) -> String {
107        B64.encode(self.0.to_bytes())
108    }
109
110    /// The public key as a JWK.
111    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    /// The JWK thumbprint of the public key (RFC 7638).
123    pub fn thumbprint(&self) -> String {
124        self.jwk().thumbprint()
125    }
126
127    /// A proof for one request. `url` has no query. `now` is in seconds
128    /// since the Unix epoch.
129    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    /// The ES256 signature of `input`, as base64url.
149    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/// A public P-256 key as a JWK.
156#[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    /// The JWK thumbprint (RFC 7638): base64url of the SHA-256 hash of
166    /// the required members, in order, with no spaces.
167    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/// What a valid proof tells the server.
196#[derive(Clone, Debug, PartialEq, Eq)]
197pub struct Proof {
198    /// The thumbprint of the key that signed the proof.
199    pub jkt: String,
200    /// The unique ID of the proof.
201    pub jti: String,
202    /// When the proof was made, in seconds since the Unix epoch.
203    pub iat: u64,
204}
205
206/// Why a proof is not valid.
207#[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
224/// Checks a proof for one request. See the module docs for the rules.
225/// The caller checks that the `jti` is new.
226pub 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    // RFC 9449 section 4.3 (R113).
241    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
290/// The `ath` of an access token: base64url of its SHA-256 hash.
291pub fn token_hash(token: &str) -> String {
292    B64.encode(Sha256::digest(token.as_bytes()))
293}
294
295/// The header of a proof and of a message signature: one layout for
296/// both (01M49W17GGV1K5FZEKJRPV0GNA).
297#[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        // The signature of another key over the same input.
402        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        // Claims from another proof under the first signature.
411        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}