1use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9use crate::error::{Result, ValidationError};
10use crate::integrity::{
11 verify_capture_id_match, verify_device_public_key_fingerprint,
12 verify_signature as verify_media_signature,
13};
14use crate::jwt::{
15 fetch_jwks, parse_jwks_json, parse_jwt, verify_signature as verify_jwt_signature,
16 CaptureTrustClaims, Jwks,
17};
18use crate::sidecar::Sidecar;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CaptureTrustResult {
23 pub signature_valid: bool,
25 pub issuer: String,
27 pub publisher_id: String,
29 pub device_id: String,
31 pub capture_id: String,
33 pub method: String,
35 pub app_id: Option<String>,
37 pub issued_at: i64,
39 pub key_id: Option<String>,
41 pub device_public_key_fingerprint: String,
43}
44
45impl CaptureTrustResult {
46 fn from_claims(
47 claims: &CaptureTrustClaims,
48 key_id: Option<String>,
49 signature_valid: bool,
50 ) -> Self {
51 Self {
52 signature_valid,
53 issuer: claims.iss.clone(),
54 publisher_id: claims.publisher_id.clone(),
55 device_id: claims.device_id.clone(),
56 capture_id: claims.capture_id.clone(),
57 method: claims.attestation.method.clone(),
58 app_id: claims.attestation.app_id.clone(),
59 issued_at: claims.iat,
60 key_id,
61 device_public_key_fingerprint: claims.device_public_key_fingerprint.clone(),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct MediaIntegrityResult {
69 pub content_hash_valid: bool,
71 pub signature_valid: bool,
73 pub capture_id_match: bool,
75 pub fingerprint_match: bool,
77 pub content_hash: String,
79 pub capture_id: String,
81 pub captured_at: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ValidationResult {
88 pub valid: bool,
90 pub version: String,
92 pub capture_trust: CaptureTrustResult,
94 pub media_integrity: MediaIntegrityResult,
96 pub error: Option<String>,
98}
99
100pub fn validate(sidecar_path: &Path, media_path: &Path) -> Result<ValidationResult> {
113 validate_impl(sidecar_path, media_path)
114}
115
116pub fn validate_from_bytes(sidecar_json: &str, media_bytes: &[u8]) -> Result<ValidationResult> {
120 validate_bytes_impl(sidecar_json, media_bytes, None)
121}
122
123pub fn validate_from_bytes_with_jwks(
128 sidecar_json: &str,
129 media_bytes: &[u8],
130 jwks_json: &str,
131) -> Result<ValidationResult> {
132 let jwks = parse_jwks_json(jwks_json)?;
133 validate_bytes_impl(sidecar_json, media_bytes, Some(jwks))
134}
135
136fn validate_impl(sidecar_path: &Path, media_path: &Path) -> Result<ValidationResult> {
137 let sidecar = Sidecar::from_file(sidecar_path)?;
139
140 let media_bytes = std::fs::read(media_path)?;
142
143 validate_sidecar_and_media(&sidecar, &media_bytes, None)
144}
145
146fn validate_bytes_impl(
147 sidecar_json: &str,
148 media_bytes: &[u8],
149 jwks: Option<Jwks>,
150) -> Result<ValidationResult> {
151 let sidecar = Sidecar::from_json(sidecar_json)?;
153
154 validate_sidecar_and_media(&sidecar, media_bytes, jwks)
155}
156
157fn validate_sidecar_and_media(
158 sidecar: &Sidecar,
159 media_bytes: &[u8],
160 jwks: Option<Jwks>,
161) -> Result<ValidationResult> {
162 let integrity = sidecar.media_integrity();
163
164 let parsed = parse_jwt(sidecar.jwt())?;
166 let kid = parsed.header.kid.clone();
167
168 let mut jwt_signature_valid = false;
170 let mut content_hash_valid = false;
171 let mut media_signature_valid = false;
172 let mut capture_id_match = false;
173 let mut error_message: Option<String> = None;
174
175 match verify_jwt_with_jwks(sidecar.jwt(), &parsed.claims.iss, kid.as_deref(), jwks) {
177 Ok(()) => jwt_signature_valid = true,
178 Err(e) => {
179 error_message = Some(format!("JWT verification failed: {}", e));
180 }
181 }
182
183 let actual_hash = crate::integrity::compute_hash(media_bytes);
185 if actual_hash == integrity.content_hash {
186 content_hash_valid = true;
187 } else if error_message.is_none() {
188 error_message = Some(format!(
189 "Content hash mismatch: expected {}, got {}",
190 integrity.content_hash, actual_hash
191 ));
192 }
193
194 match verify_media_signature(integrity) {
196 Ok(()) => media_signature_valid = true,
197 Err(e) => {
198 if error_message.is_none() {
199 error_message = Some(format!("Media signature verification failed: {}", e));
200 }
201 }
202 }
203
204 match verify_capture_id_match(&parsed.claims.capture_id, integrity) {
206 Ok(()) => capture_id_match = true,
207 Err(e) => {
208 if error_message.is_none() {
209 error_message = Some(format!("Capture ID mismatch: {}", e));
210 }
211 }
212 }
213
214 let mut fingerprint_match = false;
216 match verify_device_public_key_fingerprint(
217 &parsed.claims.device_public_key_fingerprint,
218 integrity,
219 ) {
220 Ok(()) => fingerprint_match = true,
221 Err(e) => {
222 if error_message.is_none() {
223 error_message = Some(format!("Device public key fingerprint mismatch: {}", e));
224 }
225 }
226 }
227
228 let valid = jwt_signature_valid
230 && content_hash_valid
231 && media_signature_valid
232 && capture_id_match
233 && fingerprint_match;
234
235 Ok(ValidationResult {
236 valid,
237 version: sidecar.version.clone(),
238 capture_trust: CaptureTrustResult::from_claims(&parsed.claims, kid, jwt_signature_valid),
239 media_integrity: MediaIntegrityResult {
240 content_hash_valid,
241 signature_valid: media_signature_valid,
242 capture_id_match,
243 fingerprint_match,
244 content_hash: integrity.content_hash.clone(),
245 capture_id: integrity.capture_id.clone(),
246 captured_at: integrity.captured_at.clone(),
247 },
248 error: if valid { None } else { error_message },
249 })
250}
251
252fn verify_jwt_with_jwks(
254 token: &str,
255 issuer: &str,
256 kid: Option<&str>,
257 jwks: Option<Jwks>,
258) -> Result<()> {
259 let kid =
260 kid.ok_or_else(|| ValidationError::InvalidJwt("JWT missing kid in header".to_string()))?;
261
262 let jwks = match jwks {
264 Some(jwks) => jwks,
265 None => fetch_jwks(issuer)?,
266 };
267
268 verify_jwt_signature(token, &jwks, kid)?;
269
270 Ok(())
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_validation_result_serialization() {
279 let result = ValidationResult {
280 valid: true,
281 version: "1.0".to_string(),
282 capture_trust: CaptureTrustResult {
283 signature_valid: true,
284 issuer: "https://dev-api.signedshot.io".to_string(),
285 publisher_id: "pub-123".to_string(),
286 device_id: "dev-456".to_string(),
287 capture_id: "cap-789".to_string(),
288 method: "sandbox".to_string(),
289 app_id: None,
290 issued_at: 1705312200,
291 key_id: Some("key-1".to_string()),
292 device_public_key_fingerprint: "a".repeat(64),
293 },
294 media_integrity: MediaIntegrityResult {
295 content_hash_valid: true,
296 signature_valid: true,
297 capture_id_match: true,
298 fingerprint_match: true,
299 content_hash: "abc123".to_string(),
300 capture_id: "cap-789".to_string(),
301 captured_at: "2026-01-26T15:30:00Z".to_string(),
302 },
303 error: None,
304 };
305
306 let json = serde_json::to_string(&result).unwrap();
307 assert!(json.contains("\"valid\":true"));
308 assert!(json.contains("\"publisher_id\":\"pub-123\""));
309 assert!(json.contains("\"method\":\"sandbox\""));
310 }
311
312 #[test]
313 fn test_validation_result_with_error() {
314 let result = ValidationResult {
315 valid: false,
316 version: "1.0".to_string(),
317 capture_trust: CaptureTrustResult {
318 signature_valid: false,
319 issuer: "https://dev-api.signedshot.io".to_string(),
320 publisher_id: "pub-123".to_string(),
321 device_id: "dev-456".to_string(),
322 capture_id: "cap-789".to_string(),
323 method: "sandbox".to_string(),
324 app_id: None,
325 issued_at: 1705312200,
326 key_id: None,
327 device_public_key_fingerprint: "a".repeat(64),
328 },
329 media_integrity: MediaIntegrityResult {
330 content_hash_valid: true,
331 signature_valid: true,
332 capture_id_match: true,
333 fingerprint_match: true,
334 content_hash: "abc123".to_string(),
335 capture_id: "cap-789".to_string(),
336 captured_at: "2026-01-26T15:30:00Z".to_string(),
337 },
338 error: Some("JWT verification failed".to_string()),
339 };
340
341 let json = serde_json::to_string(&result).unwrap();
342 assert!(json.contains("\"valid\":false"));
343 assert!(json.contains("\"error\":\"JWT verification failed\""));
344 }
345}