Skip to main content

feather_reader/oauth/
jwt.rs

1//! ES256 JWS signing — the one signing primitive the OAuth flow needs.
2//!
3//! Two things are signed with the confidential client's key:
4//!
5//! * the **client assertion** (`private_key_jwt`), proving to the PDS's token
6//!   endpoint that we are the client named by `client_id`, and
7//! * **DPoP proofs**, proving possession of the per-session DPoP key.
8//!
9//! Both are compact JWS: `base64url(header) . base64url(payload) . base64url(sig)`.
10//!
11//! The detail worth stating, because getting it wrong produces a signature that
12//! verifies nowhere: JOSE ECDSA signatures are the **fixed-width `r || s`**
13//! form (64 bytes for P-256), *not* ASN.1 DER. A DER signature is the default
14//! output of many ECDSA APIs and is silently accepted by nothing.
15
16use anyhow::{anyhow, bail, Context as _, Result};
17use base64::engine::general_purpose::URL_SAFE_NO_PAD;
18use base64::Engine;
19use p256::ecdsa::signature::{Signer as _, Verifier as _};
20use p256::ecdsa::{Signature, SigningKey as EcdsaSigningKey, VerifyingKey};
21use serde_json::Value;
22
23use super::keys::SigningKey;
24
25/// The only algorithm this module signs or accepts.
26const ALG: &str = "ES256";
27
28/// The JOSE signing input: `base64url(header) . base64url(payload)`.
29fn signing_input(header: &Value, claims: &Value) -> Result<String> {
30    let header = serde_json::to_vec(header).context("serializing the JWS header")?;
31    let claims = serde_json::to_vec(claims).context("serializing the JWS claims")?;
32    Ok(format!(
33        "{}.{}",
34        URL_SAFE_NO_PAD.encode(header),
35        URL_SAFE_NO_PAD.encode(claims)
36    ))
37}
38
39/// Sign `header`/`claims` as a compact ES256 JWS.
40///
41/// `p256`'s `Signature` is the fixed-width `r || s` form that JOSE requires, so
42/// `to_bytes()` is 64 bytes. Reaching for `to_der()` here would produce a
43/// signature no verifier accepts — see the module docs.
44///
45/// Signing is deterministic (RFC 6979): the nonce `k` is derived from the key
46/// and the message rather than drawn from an RNG, so there is no nonce-reuse
47/// failure mode here of the kind AES-GCM has.
48pub fn sign(key: &SigningKey, header: &Value, claims: &Value) -> Result<String> {
49    // `alg` is owned here, not taken from the caller: RFC 7515 §4.1.1 makes it
50    // REQUIRED, and a header advertising anything other than what actually
51    // signed is an algorithm-confusion bug. Same reasoning as `guarded_post`
52    // owning the Content-Type.
53    let mut header = header.clone();
54    header
55        .as_object_mut()
56        .ok_or_else(|| anyhow!("JWS header must be a JSON object"))?
57        .insert("alg".into(), Value::String(ALG.into()));
58
59    let input = signing_input(&header, claims)?;
60    let signer = EcdsaSigningKey::from(key.secret());
61    let signature: Signature = signer.sign(input.as_bytes());
62    Ok(format!(
63        "{input}.{}",
64        URL_SAFE_NO_PAD.encode(signature.to_bytes())
65    ))
66}
67
68/// Verify a compact ES256 JWS against `key`'s public half.
69///
70/// Used by the tests and available for verifying anything we minted; the PDS
71/// verifies our assertions itself via the published JWKS.
72pub fn verify(key: &SigningKey, jws: &str) -> Result<()> {
73    let parts: Vec<&str> = jws.split('.').collect();
74    if parts.len() != 3 {
75        bail!("malformed JWS: expected 3 segments, got {}", parts.len());
76    }
77    // Check the advertised algorithm before spending a verification. The header
78    // is covered by the signature, so a mismatch would fail anyway — but saying
79    // so explicitly means a caller can never be handed a verified-looking JWS
80    // whose header claims an algorithm we did not check.
81    let header: Value = serde_json::from_slice(
82        &URL_SAFE_NO_PAD
83            .decode(parts[0])
84            .context("header is not valid base64url")?,
85    )
86    .context("JWS header is not valid JSON")?;
87    match header.get("alg").and_then(Value::as_str) {
88        Some(ALG) => {}
89        other => bail!("unsupported JWS alg {other:?}; only {ALG} is accepted"),
90    }
91
92    let raw = URL_SAFE_NO_PAD
93        .decode(parts[2])
94        .context("signature is not valid base64url")?;
95    let signature =
96        Signature::from_slice(&raw).map_err(|err| anyhow!("not a valid P-256 signature: {err}"))?;
97
98    let input = format!("{}.{}", parts[0], parts[1]);
99    let verifier = VerifyingKey::from(key.secret().public_key());
100    verifier
101        .verify(input.as_bytes(), &signature)
102        .map_err(|_| anyhow!("JWS signature does not verify"))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde_json::json;
109
110    const KID: &str = "featherreader-oauth-1";
111
112    fn decode_part(part: &str) -> Value {
113        serde_json::from_slice(&URL_SAFE_NO_PAD.decode(part).unwrap()).unwrap()
114    }
115
116    /// **A valid JWS with an extra segment appended must be refused.**
117    ///
118    /// `verify` requires EXACTLY three segments. Relaxing that to `>= 3` — a
119    /// plausible-looking loosening — left the whole suite green, because the one
120    /// test covering segment counts feeds `"a.b.c.d"`, which dies at base64
121    /// decoding long before the count is consulted. Under the relaxed check a
122    /// genuinely valid token with attacker-appended trailing data verifies.
123    ///
124    /// So this appends to a REAL signed JWS: every segment is well-formed and the
125    /// signature covers the first two, leaving the count as the only thing that
126    /// can reject it.
127    #[test]
128    fn a_valid_jws_with_a_trailing_segment_is_refused() {
129        let key = SigningKey::generate(KID);
130        let jws = sign(&key, &json!({"typ": "JWT"}), &json!({"iss": "x"})).unwrap();
131        verify(&key, &jws).expect("the unmodified JWS must verify");
132
133        let extended = format!("{jws}.AAAA");
134        let err =
135            verify(&key, &extended).expect_err("a JWS with a trailing segment must not verify");
136        assert!(
137            format!("{err:#}").contains("expected 3 segments"),
138            "refused, but not by the segment count — the relaxed check is what this \
139             test exists to catch: {err:#}",
140        );
141    }
142
143    /// **Algorithm confusion: a signature valid over the bytes, whose header
144    /// advertises a different algorithm, must not verify.**
145    ///
146    /// The existing pair of tests swap the header of an already-signed token,
147    /// which changes the signing input — so the ECDSA check fails and the `alg`
148    /// guard is never the reason. Deleting the guard entirely left all 664 tests
149    /// green.
150    ///
151    /// This builds the hostile token properly: the header says `RS256`, and the
152    /// ES256 signature is computed over THAT header, so the signature is
153    /// genuinely valid and only the `alg` check can reject it. `sign` cannot be
154    /// used here — it owns `alg` and overwrites it, which is the right production
155    /// behaviour and exactly why the token has to be assembled by hand.
156    #[test]
157    fn a_signature_valid_under_a_forged_alg_header_is_refused() {
158        let key = SigningKey::generate(KID);
159        let header = json!({"alg": "RS256", "typ": "JWT", "kid": KID});
160        let claims = json!({"iss": "https://x.example"});
161
162        let input = signing_input(&header, &claims).unwrap();
163        let signer = EcdsaSigningKey::from(key.secret());
164        let signature: Signature = signer.sign(input.as_bytes());
165        let forged = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes()));
166
167        // The signature really is valid over these bytes — swap the header to the
168        // honest algorithm and the same construction verifies.
169        let honest_input =
170            signing_input(&json!({"alg": "ES256", "typ": "JWT", "kid": KID}), &claims).unwrap();
171        let honest_sig: Signature = signer.sign(honest_input.as_bytes());
172        verify(
173            &key,
174            &format!(
175                "{honest_input}.{}",
176                URL_SAFE_NO_PAD.encode(honest_sig.to_bytes())
177            ),
178        )
179        .expect("the same construction with an honest alg must verify");
180
181        let err = verify(&key, &forged).expect_err("a forged alg header must not verify");
182        assert!(
183            format!("{err:#}").contains("unsupported JWS alg"),
184            "refused, but not by the alg check — if the signature merely failed, the \
185             guard could be deleted and this test would still pass: {err:#}",
186        );
187    }
188
189    #[test]
190    fn a_signed_jws_has_three_base64url_segments() {
191        let key = SigningKey::generate(KID);
192        let jws = sign(&key, &json!({"alg": "ES256"}), &json!({"iss": "x"})).unwrap();
193        let parts: Vec<&str> = jws.split('.').collect();
194        assert_eq!(parts.len(), 3);
195        for p in &parts {
196            assert!(!p.is_empty());
197            assert!(!p.contains('='), "base64url in JOSE is unpadded: {p}");
198            assert!(!p.contains('+') && !p.contains('/'), "not url-safe: {p}");
199        }
200    }
201
202    #[test]
203    fn the_header_and_payload_round_trip_verbatim() {
204        let key = SigningKey::generate(KID);
205        let header = json!({"alg": "ES256", "typ": "JWT", "kid": KID});
206        let claims = json!({"iss": "https://x.example", "jti": "abc", "iat": 1_700_000_000});
207        let jws = sign(&key, &header, &claims).unwrap();
208        let parts: Vec<&str> = jws.split('.').collect();
209        assert_eq!(decode_part(parts[0]), header);
210        assert_eq!(decode_part(parts[1]), claims);
211    }
212
213    /// **The classic ES256 bug.** JOSE requires the fixed-width `r || s`
214    /// concatenation — exactly 64 bytes for P-256 — while many ECDSA APIs return
215    /// ASN.1 DER by default. For P-256 with random `r`/`s`, DER runs 70-72
216    /// bytes, so the length alone discriminates.
217    ///
218    /// A fresh key per iteration: signing is RFC 6979-deterministic, so reusing
219    /// one key would produce sixteen identical signatures and test nothing.
220    #[test]
221    fn the_signature_is_fixed_width_r_s_not_der() {
222        for _ in 0..16 {
223            let key = SigningKey::generate(KID);
224            let jws = sign(&key, &json!({}), &json!({"n": 1})).unwrap();
225            let sig = URL_SAFE_NO_PAD
226                .decode(jws.split('.').nth(2).unwrap())
227                .unwrap();
228            assert_eq!(sig.len(), 64, "not a fixed-width P-256 JOSE signature");
229        }
230    }
231
232    /// `alg` is REQUIRED by RFC 7515 §4.1.1, and a header claiming anything else
233    /// while carrying an ES256 signature is an algorithm-confusion bug waiting
234    /// to happen. `sign` owns the field rather than trusting the caller — the
235    /// same reasoning as `guarded_post` owning Content-Type.
236    #[test]
237    fn sign_owns_the_alg_header_and_overrides_the_caller() {
238        let key = SigningKey::generate(KID);
239        for given in [json!({}), json!({"alg": "RS256"}), json!({"alg": "none"})] {
240            let jws = sign(&key, &given, &json!({"x": 1})).unwrap();
241            assert_eq!(decode_part(jws.split('.').next().unwrap())["alg"], "ES256");
242            assert!(verify(&key, &jws).is_ok());
243        }
244    }
245
246    #[test]
247    fn sign_preserves_the_callers_other_header_members() {
248        let key = SigningKey::generate(KID);
249        let jws = sign(&key, &json!({"typ": "dpop+jwt", "kid": KID}), &json!({})).unwrap();
250        let header = decode_part(jws.split('.').next().unwrap());
251        assert_eq!(header["typ"], "dpop+jwt");
252        assert_eq!(header["kid"], KID);
253        assert_eq!(header["alg"], "ES256");
254    }
255
256    /// A signature that is valid over the bytes but whose header advertises a
257    /// different algorithm must not verify — otherwise a caller could be talked
258    /// into treating an ES256 signature as an RS256 one.
259    #[test]
260    fn verify_rejects_a_header_advertising_another_algorithm() {
261        let key = SigningKey::generate(KID);
262        let jws = sign(&key, &json!({}), &json!({"x": 1})).unwrap();
263        let seg: Vec<&str> = jws.split('.').collect();
264
265        for forged_header in [json!({"alg": "RS256"}), json!({"alg": "none"}), json!({})] {
266            let swapped = format!(
267                "{}.{}.{}",
268                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged_header).unwrap()),
269                seg[1],
270                seg[2]
271            );
272            assert!(verify(&key, &swapped).is_err(), "accepted {forged_header}");
273        }
274    }
275
276    #[test]
277    fn verify_rejects_a_non_json_header() {
278        let key = SigningKey::generate(KID);
279        let jws = sign(&key, &json!({}), &json!({"x": 1})).unwrap();
280        let seg: Vec<&str> = jws.split('.').collect();
281        let bad = format!(
282            "{}.{}.{}",
283            URL_SAFE_NO_PAD.encode(b"not json"),
284            seg[1],
285            seg[2]
286        );
287        assert!(verify(&key, &bad).is_err());
288    }
289
290    #[test]
291    fn a_signature_verifies_against_the_signing_keys_public_half() {
292        let key = SigningKey::generate(KID);
293        let jws = sign(&key, &json!({"alg": "ES256"}), &json!({"sub": "did:plc:x"})).unwrap();
294        assert!(verify(&key, &jws).is_ok());
295    }
296
297    #[test]
298    fn a_signature_from_a_different_key_does_not_verify() {
299        let a = SigningKey::generate(KID);
300        let b = SigningKey::generate(KID);
301        let jws = sign(&a, &json!({"alg": "ES256"}), &json!({"sub": "x"})).unwrap();
302        assert!(verify(&b, &jws).is_err());
303    }
304
305    #[test]
306    fn tampering_with_the_payload_invalidates_the_signature() {
307        let key = SigningKey::generate(KID);
308        let jws = sign(&key, &json!({"alg": "ES256"}), &json!({"amount": 1})).unwrap();
309        let parts: Vec<&str> = jws.split('.').collect();
310        let forged = URL_SAFE_NO_PAD.encode(br#"{"amount":1000000}"#);
311        let tampered = format!("{}.{}.{}", parts[0], forged, parts[2]);
312        assert!(verify(&key, &tampered).is_err());
313    }
314
315    #[test]
316    fn a_malformed_jws_is_rejected_rather_than_panicking() {
317        let key = SigningKey::generate(KID);
318        for bad in [
319            "",
320            "onlyonepart",
321            "two.parts",
322            "a.b.c.d",
323            "...",
324            "!!!.@@@.###",
325            "a.b.",
326        ] {
327            assert!(verify(&key, bad).is_err(), "should reject {bad:?}");
328        }
329    }
330
331    /// Two signatures over the same input must not be byte-identical unless the
332    /// scheme is deliberately deterministic. Either is acceptable for ES256
333    /// (RFC 6979 is deterministic); this pins WHICH one we get, so a future
334    /// dependency change that flips it is visible rather than silent.
335    #[test]
336    fn signing_is_deterministic_rfc6979() {
337        let key = SigningKey::generate(KID);
338        let a = sign(&key, &json!({"alg": "ES256"}), &json!({"x": 1})).unwrap();
339        let b = sign(&key, &json!({"alg": "ES256"}), &json!({"x": 1})).unwrap();
340        assert_eq!(a, b, "p256 signs deterministically per RFC 6979");
341    }
342}