Skip to main content

signedshot_validator/
jwt.rs

1//! JWT parsing for SignedShot capture trust tokens.
2
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
4use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{Result, ValidationError};
8
9#[derive(Debug, Clone, Deserialize)]
10pub struct Jwk {
11    pub kty: String,
12    pub crv: String,
13    pub x: String,
14    pub y: String,
15    pub kid: String,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19pub struct Jwks {
20    pub keys: Vec<Jwk>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct JwtHeader {
25    pub alg: String,
26    pub typ: Option<String>,
27    pub kid: Option<String>,
28}
29
30/// Attestation information from the JWT
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Attestation {
33    /// Attestation method (sandbox, app_check, app_attest)
34    pub method: String,
35    /// App ID from attestation (e.g., bundle ID), if available
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub app_id: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct CaptureTrustClaims {
42    pub iss: String,
43    pub aud: String,
44    pub sub: String,
45    pub iat: i64,
46    pub capture_id: String,
47    pub publisher_id: String,
48    pub device_id: String,
49    pub attestation: Attestation,
50    pub device_public_key_fingerprint: String,
51}
52
53#[derive(Debug, Clone)]
54pub struct ParsedJwt {
55    pub header: JwtHeader,
56    pub claims: CaptureTrustClaims,
57    pub signature: String,
58}
59
60pub fn parse_jwt(token: &str) -> Result<ParsedJwt> {
61    let parts: Vec<&str> = token.split('.').collect();
62    if parts.len() != 3 {
63        return Err(ValidationError::InvalidJwt(
64            "JWT must have 3 parts separated by dots".to_string(),
65        ));
66    }
67
68    let header = decode_part::<JwtHeader>(parts[0], "header")?;
69    let claims = decode_part::<CaptureTrustClaims>(parts[1], "claims")?;
70    let signature = parts[2].to_string();
71
72    validate_header(&header)?;
73    validate_claims(&claims)?;
74
75    Ok(ParsedJwt {
76        header,
77        claims,
78        signature,
79    })
80}
81
82fn decode_part<T: for<'de> Deserialize<'de>>(encoded: &str, part_name: &str) -> Result<T> {
83    let bytes = URL_SAFE_NO_PAD.decode(encoded).map_err(|e| {
84        ValidationError::JwtDecodeError(format!("Failed to decode {}: {}", part_name, e))
85    })?;
86
87    serde_json::from_slice(&bytes).map_err(|e| {
88        ValidationError::JwtDecodeError(format!("Failed to parse {}: {}", part_name, e))
89    })
90}
91
92fn validate_header(header: &JwtHeader) -> Result<()> {
93    if header.alg != "ES256" {
94        return Err(ValidationError::InvalidJwt(format!(
95            "Expected algorithm ES256, got {}",
96            header.alg
97        )));
98    }
99    Ok(())
100}
101
102fn validate_claims(claims: &CaptureTrustClaims) -> Result<()> {
103    if claims.aud != "signedshot" {
104        return Err(ValidationError::InvalidJwt(format!(
105            "Expected audience 'signedshot', got '{}'",
106            claims.aud
107        )));
108    }
109
110    let valid_methods = ["sandbox", "app_check", "app_attest"];
111    if !valid_methods.contains(&claims.attestation.method.as_str()) {
112        return Err(ValidationError::InvalidJwt(format!(
113            "Invalid attestation method '{}', expected one of: {:?}",
114            claims.attestation.method, valid_methods
115        )));
116    }
117
118    Ok(())
119}
120
121pub fn fetch_jwks(issuer: &str) -> Result<Jwks> {
122    let url = format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/'));
123
124    let response = reqwest::blocking::get(&url)
125        .map_err(|e| ValidationError::JwksFetchError(format!("HTTP request failed: {}", e)))?;
126
127    if !response.status().is_success() {
128        return Err(ValidationError::JwksFetchError(format!(
129            "HTTP {} from {}",
130            response.status(),
131            url
132        )));
133    }
134
135    response
136        .json::<Jwks>()
137        .map_err(|e| ValidationError::JwksFetchError(format!("Failed to parse JWKS: {}", e)))
138}
139
140/// Parse JWKS from a JSON string.
141///
142/// Useful when the JWKS is already available locally (e.g., from the API's own keys).
143pub fn parse_jwks_json(jwks_json: &str) -> Result<Jwks> {
144    serde_json::from_str(jwks_json)
145        .map_err(|e| ValidationError::JwksFetchError(format!("Failed to parse JWKS JSON: {}", e)))
146}
147
148pub fn verify_signature(token: &str, jwks: &Jwks, kid: &str) -> Result<()> {
149    let jwk = jwks
150        .keys
151        .iter()
152        .find(|k| k.kid == kid)
153        .ok_or_else(|| ValidationError::KeyNotFound(kid.to_string()))?;
154
155    let x_bytes = URL_SAFE_NO_PAD
156        .decode(&jwk.x)
157        .map_err(|e| ValidationError::SignatureError(format!("Invalid x coordinate: {}", e)))?;
158    let y_bytes = URL_SAFE_NO_PAD
159        .decode(&jwk.y)
160        .map_err(|e| ValidationError::SignatureError(format!("Invalid y coordinate: {}", e)))?;
161
162    let mut public_key = Vec::with_capacity(1 + x_bytes.len() + y_bytes.len());
163    public_key.push(0x04);
164    public_key.extend_from_slice(&x_bytes);
165    public_key.extend_from_slice(&y_bytes);
166
167    let decoding_key = DecodingKey::from_ec_der(&public_key);
168
169    let mut validation = Validation::new(Algorithm::ES256);
170    validation.set_audience(&["signedshot"]);
171    validation.validate_exp = false;
172    validation.set_required_spec_claims::<&str>(&[]);
173
174    decode::<CaptureTrustClaims>(token, &decoding_key, &validation)
175        .map_err(|e| ValidationError::SignatureError(format!("{}", e)))?;
176
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn make_jwt(header: &str, payload: &str) -> String {
185        let h = URL_SAFE_NO_PAD.encode(header);
186        let p = URL_SAFE_NO_PAD.encode(payload);
187        format!("{}.{}.fake-signature", h, p)
188    }
189
190    #[test]
191    fn parse_valid_jwt() {
192        let header = r#"{"alg":"ES256","typ":"JWT","kid":"test-key"}"#;
193        let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#;
194        let token = make_jwt(header, payload);
195
196        let parsed = parse_jwt(&token).unwrap();
197        assert_eq!(parsed.header.alg, "ES256");
198        assert_eq!(parsed.claims.capture_id, "123");
199        assert_eq!(parsed.claims.attestation.method, "sandbox");
200        assert_eq!(parsed.claims.attestation.app_id, None);
201    }
202
203    #[test]
204    fn parse_jwt_with_app_id() {
205        let header = r#"{"alg":"ES256","typ":"JWT","kid":"test-key"}"#;
206        let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"app_check","app_id":"io.foo.bar"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#;
207        let token = make_jwt(header, payload);
208
209        let parsed = parse_jwt(&token).unwrap();
210        assert_eq!(parsed.claims.attestation.method, "app_check");
211        assert_eq!(
212            parsed.claims.attestation.app_id,
213            Some("io.foo.bar".to_string())
214        );
215    }
216
217    #[test]
218    fn reject_invalid_algorithm() {
219        let header = r#"{"alg":"HS256","typ":"JWT"}"#;
220        let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#;
221        let token = make_jwt(header, payload);
222
223        let result = parse_jwt(&token);
224        assert!(matches!(result, Err(ValidationError::InvalidJwt(_))));
225    }
226
227    #[test]
228    fn reject_invalid_audience() {
229        let header = r#"{"alg":"ES256","typ":"JWT"}"#;
230        let payload = r#"{"iss":"https://example.com","aud":"wrong","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#;
231        let token = make_jwt(header, payload);
232
233        let result = parse_jwt(&token);
234        assert!(matches!(result, Err(ValidationError::InvalidJwt(_))));
235    }
236
237    #[test]
238    fn reject_invalid_method() {
239        let header = r#"{"alg":"ES256","typ":"JWT"}"#;
240        let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"invalid"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#;
241        let token = make_jwt(header, payload);
242
243        let result = parse_jwt(&token);
244        assert!(matches!(result, Err(ValidationError::InvalidJwt(_))));
245    }
246
247    #[test]
248    fn reject_malformed_jwt() {
249        let result = parse_jwt("not.a.valid.jwt");
250        assert!(matches!(result, Err(ValidationError::InvalidJwt(_))));
251    }
252
253    #[test]
254    fn reject_invalid_base64() {
255        let result = parse_jwt("!!!.@@@.###");
256        assert!(matches!(result, Err(ValidationError::JwtDecodeError(_))));
257    }
258
259    /// Test that JWT signature verification works end-to-end with jsonwebtoken.
260    /// This exercises jsonwebtoken::decode() which requires a CryptoProvider.
261    /// The jsonwebtoken 9→10 upgrade broke this; this test prevents regressions.
262    #[test]
263    fn verify_signature_with_valid_jwt() {
264        use jsonwebtoken::{encode, EncodingKey, Header};
265
266        // Hardcoded EC P-256 test key (PKCS8 PEM format)
267        let private_pem = concat!(
268            "-----BEGIN PRIVATE KEY-----\n",
269            "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1KUid6KzUOny1siR\n",
270            "Pl1OMJgN161C1yXZ7/8KCXRtulmhRANCAAQJ5T/BXZMxSXgh67vjlgAnA1b9mr2B\n",
271            "tGYEnljojfpGAa5tqRxPFTZ2IP8IZDqKSX9j7n0GLPHE3QuLRV3MEAXn\n",
272            "-----END PRIVATE KEY-----\n",
273        );
274
275        // Public key x,y coordinates (base64url, no padding)
276        let jwks = Jwks {
277            keys: vec![Jwk {
278                kty: "EC".to_string(),
279                crv: "P-256".to_string(),
280                x: "CeU_wV2TMUl4Ieu745YAJwNW_Zq9gbRmBJ5Y6I36RgE".to_string(),
281                y: "rm2pHE8VNnYg_whkOopJf2PufQYs8cTdC4tFXcwQBec".to_string(),
282                kid: "test-key-1".to_string(),
283            }],
284        };
285
286        // Sign a JWT with valid claims
287        let claims = CaptureTrustClaims {
288            iss: "https://dev-api.signedshot.io".to_string(),
289            aud: "signedshot".to_string(),
290            sub: "capture-service".to_string(),
291            iat: 1705312200,
292            capture_id: "test-capture-123".to_string(),
293            publisher_id: "test-publisher-456".to_string(),
294            device_id: "test-device-789".to_string(),
295            attestation: Attestation {
296                method: "sandbox".to_string(),
297                app_id: None,
298            },
299            device_public_key_fingerprint: "a".repeat(64),
300        };
301
302        let mut header = Header::new(Algorithm::ES256);
303        header.kid = Some("test-key-1".to_string());
304
305        let encoding_key = EncodingKey::from_ec_pem(private_pem.as_bytes()).unwrap();
306        let token = encode(&header, &claims, &encoding_key).unwrap();
307
308        // This calls jsonwebtoken::decode() — would panic without CryptoProvider
309        let result = verify_signature(&token, &jwks, "test-key-1");
310        assert!(
311            result.is_ok(),
312            "verify_signature failed: {:?}",
313            result.err()
314        );
315    }
316
317    #[test]
318    fn verify_signature_rejects_wrong_key() {
319        use jsonwebtoken::{encode, EncodingKey, Header};
320
321        // Hardcoded EC P-256 test key (PKCS8 PEM format)
322        let private_pem = concat!(
323            "-----BEGIN PRIVATE KEY-----\n",
324            "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1KUid6KzUOny1siR\n",
325            "Pl1OMJgN161C1yXZ7/8KCXRtulmhRANCAAQJ5T/BXZMxSXgh67vjlgAnA1b9mr2B\n",
326            "tGYEnljojfpGAa5tqRxPFTZ2IP8IZDqKSX9j7n0GLPHE3QuLRV3MEAXn\n",
327            "-----END PRIVATE KEY-----\n",
328        );
329
330        // JWKS with a DIFFERENT public key (all zeros x,y — will fail verification)
331        let wrong_jwks = Jwks {
332            keys: vec![Jwk {
333                kty: "EC".to_string(),
334                crv: "P-256".to_string(),
335                x: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
336                y: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
337                kid: "test-key-1".to_string(),
338            }],
339        };
340
341        let claims = CaptureTrustClaims {
342            iss: "https://dev-api.signedshot.io".to_string(),
343            aud: "signedshot".to_string(),
344            sub: "capture-service".to_string(),
345            iat: 1705312200,
346            capture_id: "test-capture-123".to_string(),
347            publisher_id: "test-publisher-456".to_string(),
348            device_id: "test-device-789".to_string(),
349            attestation: Attestation {
350                method: "sandbox".to_string(),
351                app_id: None,
352            },
353            device_public_key_fingerprint: "a".repeat(64),
354        };
355
356        let mut header = Header::new(Algorithm::ES256);
357        header.kid = Some("test-key-1".to_string());
358
359        let encoding_key = EncodingKey::from_ec_pem(private_pem.as_bytes()).unwrap();
360        let token = encode(&header, &claims, &encoding_key).unwrap();
361
362        let result = verify_signature(&token, &wrong_jwks, "test-key-1");
363        assert!(
364            result.is_err(),
365            "Should reject JWT signed with different key"
366        );
367    }
368
369    #[test]
370    fn verify_signature_rejects_missing_kid() {
371        let jwks = Jwks {
372            keys: vec![Jwk {
373                kty: "EC".to_string(),
374                crv: "P-256".to_string(),
375                x: "CeU_wV2TMUl4Ieu745YAJwNW_Zq9gbRmBJ5Y6I36RgE".to_string(),
376                y: "rm2pHE8VNnYg_whkOopJf2PufQYs8cTdC4tFXcwQBec".to_string(),
377                kid: "test-key-1".to_string(),
378            }],
379        };
380
381        let result = verify_signature("fake.jwt.token", &jwks, "nonexistent-kid");
382        assert!(matches!(result, Err(ValidationError::KeyNotFound(_))));
383    }
384}