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