Skip to main content

cairn_mod/auth/
jwt.rs

1//! Manual JWT parsing + ES256K signature verification.
2//!
3//! We don't use `jsonwebtoken` because it has no ES256K support — we'd
4//! have to hand-verify the signature either way, and owning the parser
5//! removes an auth-critical dependency from the supply chain. ~40 lines
6//! is worth the audit surface.
7//!
8//! JWT structure: `base64url(header).base64url(payload).base64url(sig)`.
9//! Signature is computed over the raw ASCII bytes of the first two
10//! segments (with the literal `.` between them) — NOT over the decoded
11//! values (§RFC 7515 §5).
12
13use base64::Engine as _;
14use serde::Deserialize;
15
16/// Header fields Cairn inspects. `typ` and `kid` are optional and
17/// ignored by the verifier — the allowlist lives on `alg`.
18#[derive(Debug, Deserialize)]
19pub struct JwtHeader {
20    /// Algorithm identifier. Checked against §5.2 allowlist.
21    pub alg: String,
22    /// Optional `typ` header; ignored by Cairn.
23    #[serde(default)]
24    pub typ: Option<String>,
25    /// Optional `kid` header; ignored by Cairn (key selection is by
26    /// verification-method fragment in the DID document, not kid).
27    #[serde(default)]
28    pub kid: Option<String>,
29}
30
31/// Claims for ATProto service auth (§5.2). Every field is required —
32/// `#[serde(deny_unknown_fields)]` is deliberately NOT set because the
33/// PDS may include future claims Cairn doesn't know about yet; presence
34/// of extra claims is not a rejection reason.
35#[derive(Debug, Deserialize)]
36pub struct JwtPayload {
37    /// Issuer DID (moderator's DID).
38    pub iss: String,
39    /// Audience — must equal Cairn's configured service DID.
40    pub aud: String,
41    /// Expiration as Unix-seconds timestamp.
42    pub exp: i64,
43    /// Issued-at as Unix-seconds timestamp.
44    pub iat: i64,
45    /// JWT ID — random per token; feeds the replay cache.
46    pub jti: String,
47    /// Lexicon method binding — must equal the target endpoint's
48    /// NSID (e.g. `com.atproto.moderation.createReport`).
49    pub lxm: String,
50}
51
52/// Result of [`parse`]: header + payload split out, plus the raw
53/// bytes the signature covers and the signature itself.
54#[derive(Debug)]
55pub struct ParsedJwt {
56    /// Decoded header.
57    pub header: JwtHeader,
58    /// Decoded payload / claims.
59    pub payload: JwtPayload,
60    /// Raw bytes `header.payload` (dot-joined, un-decoded). This is what
61    /// the ES256K signature covers per RFC 7515 §5.
62    pub signing_input: Vec<u8>,
63    /// Raw 64-byte `(r, s)` compact signature. ATProto's ES256K uses
64    /// this form, not DER.
65    pub signature: Vec<u8>,
66}
67
68/// Parse-only: no signature check, no claim check. Callers verify in
69/// the order prescribed by §5.2 (alg → structural → sig → claims →
70/// replay → authz).
71pub fn parse(token: &str) -> Result<ParsedJwt, JwtParseError> {
72    let mut it = token.split('.');
73    let header_b64 = it.next().ok_or(JwtParseError::Structure)?;
74    let payload_b64 = it.next().ok_or(JwtParseError::Structure)?;
75    let sig_b64 = it.next().ok_or(JwtParseError::Structure)?;
76    if it.next().is_some() {
77        return Err(JwtParseError::Structure);
78    }
79    if header_b64.is_empty() || payload_b64.is_empty() || sig_b64.is_empty() {
80        return Err(JwtParseError::Structure);
81    }
82
83    let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
84    let header_bytes = engine
85        .decode(header_b64)
86        .map_err(|_| JwtParseError::Base64)?;
87    let payload_bytes = engine
88        .decode(payload_b64)
89        .map_err(|_| JwtParseError::Base64)?;
90    let signature = engine.decode(sig_b64).map_err(|_| JwtParseError::Base64)?;
91
92    let header: JwtHeader =
93        serde_json::from_slice(&header_bytes).map_err(|_| JwtParseError::HeaderJson)?;
94    let payload: JwtPayload =
95        serde_json::from_slice(&payload_bytes).map_err(|_| JwtParseError::PayloadJson)?;
96
97    // Signing input is the UN-DECODED first two segments joined by `.`.
98    let mut signing_input = Vec::with_capacity(header_b64.len() + 1 + payload_b64.len());
99    signing_input.extend_from_slice(header_b64.as_bytes());
100    signing_input.push(b'.');
101    signing_input.extend_from_slice(payload_b64.as_bytes());
102
103    Ok(ParsedJwt {
104        header,
105        payload,
106        signing_input,
107        signature,
108    })
109}
110
111/// Failure modes of [`parse`]. All map to the same external
112/// `AuthenticationRequired` response per §4 non-enumeration;
113/// variants exist for internal logging.
114#[derive(Debug, thiserror::Error)]
115pub enum JwtParseError {
116    /// Token is not three non-empty dot-separated segments.
117    #[error("JWT structure invalid (not three non-empty segments)")]
118    Structure,
119    /// One of the segments failed base64url decoding.
120    #[error("JWT segment failed base64url decode")]
121    Base64,
122    /// Header bytes didn't parse as a JSON object matching
123    /// [`JwtHeader`].
124    #[error("JWT header is not valid JSON or is missing required fields")]
125    HeaderJson,
126    /// Payload bytes didn't parse as a JSON object matching
127    /// [`JwtPayload`] (a required claim was missing or the wrong
128    /// type).
129    #[error("JWT payload is not valid JSON or is missing required claims")]
130    PayloadJson,
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
137
138    fn encode_jwt(header: &str, payload: &str, sig: &[u8]) -> String {
139        format!(
140            "{}.{}.{}",
141            URL_SAFE_NO_PAD.encode(header),
142            URL_SAFE_NO_PAD.encode(payload),
143            URL_SAFE_NO_PAD.encode(sig)
144        )
145    }
146
147    #[test]
148    fn parses_well_formed_jwt() {
149        let token = encode_jwt(
150            r#"{"alg":"ES256K","typ":"JWT"}"#,
151            r#"{"iss":"did:plc:a","aud":"did:plc:b","exp":100,"iat":50,"jti":"j","lxm":"m"}"#,
152            &[0x42; 64],
153        );
154        let jwt = parse(&token).expect("parse");
155        assert_eq!(jwt.header.alg, "ES256K");
156        assert_eq!(jwt.payload.iss, "did:plc:a");
157        assert_eq!(jwt.payload.aud, "did:plc:b");
158        assert_eq!(jwt.payload.exp, 100);
159        assert_eq!(jwt.payload.lxm, "m");
160        assert_eq!(jwt.signature.len(), 64);
161        // Signing input must be the un-decoded head.payload bytes.
162        assert!(jwt.signing_input.contains(&b'.'));
163    }
164
165    #[test]
166    fn rejects_wrong_segment_count() {
167        assert!(matches!(
168            parse("only.two").unwrap_err(),
169            JwtParseError::Structure
170        ));
171        assert!(matches!(
172            parse("a.b.c.d").unwrap_err(),
173            JwtParseError::Structure
174        ));
175        assert!(matches!(parse("").unwrap_err(), JwtParseError::Structure));
176    }
177
178    #[test]
179    fn rejects_empty_segments() {
180        assert!(matches!(
181            parse("a..c").unwrap_err(),
182            JwtParseError::Structure
183        ));
184        assert!(matches!(
185            parse(".b.c").unwrap_err(),
186            JwtParseError::Structure
187        ));
188    }
189
190    #[test]
191    fn rejects_non_base64() {
192        let token = "!!!.???.@@@";
193        assert!(matches!(parse(token).unwrap_err(), JwtParseError::Base64));
194    }
195
196    #[test]
197    fn rejects_missing_claims() {
198        let token = encode_jwt(
199            r#"{"alg":"ES256K"}"#,
200            r#"{"iss":"x"}"#, // missing aud/exp/iat/jti/lxm
201            &[0; 64],
202        );
203        assert!(matches!(
204            parse(&token).unwrap_err(),
205            JwtParseError::PayloadJson
206        ));
207    }
208
209    #[test]
210    fn rejects_missing_alg_in_header() {
211        let token = encode_jwt(
212            r#"{"typ":"JWT"}"#,
213            r#"{"iss":"did:plc:a","aud":"did:plc:b","exp":100,"iat":50,"jti":"j","lxm":"m"}"#,
214            &[0; 64],
215        );
216        assert!(matches!(
217            parse(&token).unwrap_err(),
218            JwtParseError::HeaderJson
219        ));
220    }
221
222    #[test]
223    fn signing_input_is_un_decoded_bytes() {
224        let header = r#"{"alg":"ES256K"}"#;
225        let payload =
226            r#"{"iss":"did:plc:a","aud":"did:plc:b","exp":100,"iat":50,"jti":"j","lxm":"m"}"#;
227        let token = encode_jwt(header, payload, &[0; 64]);
228        let jwt = parse(&token).expect("parse");
229
230        // Signing input must NOT equal the decoded bytes; it must be the
231        // encoded segments with a literal dot. This is the RFC 7515 §5
232        // rule that trips up hand-written verifiers.
233        assert!(jwt.signing_input != format!("{header}.{payload}").as_bytes());
234        assert_eq!(
235            jwt.signing_input,
236            format!(
237                "{}.{}",
238                URL_SAFE_NO_PAD.encode(header),
239                URL_SAFE_NO_PAD.encode(payload)
240            )
241            .into_bytes()
242        );
243    }
244}