1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Attestation {
33 pub method: String,
35 #[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
140pub 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]
263 fn verify_signature_with_valid_jwt() {
264 use jsonwebtoken::{encode, EncodingKey, Header};
265
266 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 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 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 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 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 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}