1use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9use crate::error::{Result, ValidationError};
10use crate::integrity::{verify_capture_id_match, verify_signature as verify_media_signature};
11use crate::jwt::{
12 fetch_jwks, parse_jwks_json, parse_jwt, verify_signature as verify_jwt_signature,
13 CaptureTrustClaims, Jwks,
14};
15use crate::sidecar::Sidecar;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CaptureTrustResult {
20 pub signature_valid: bool,
22 pub issuer: String,
24 pub publisher_id: String,
26 pub device_id: String,
28 pub capture_id: String,
30 pub method: String,
32 pub app_id: Option<String>,
34 pub issued_at: i64,
36 pub key_id: Option<String>,
38}
39
40impl CaptureTrustResult {
41 fn from_claims(
42 claims: &CaptureTrustClaims,
43 key_id: Option<String>,
44 signature_valid: bool,
45 ) -> Self {
46 Self {
47 signature_valid,
48 issuer: claims.iss.clone(),
49 publisher_id: claims.publisher_id.clone(),
50 device_id: claims.device_id.clone(),
51 capture_id: claims.capture_id.clone(),
52 method: claims.attestation.method.clone(),
53 app_id: claims.attestation.app_id.clone(),
54 issued_at: claims.iat,
55 key_id,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct MediaIntegrityResult {
63 pub content_hash_valid: bool,
65 pub signature_valid: bool,
67 pub capture_id_match: bool,
69 pub content_hash: String,
71 pub capture_id: String,
73 pub captured_at: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ValidationResult {
80 pub valid: bool,
82 pub version: String,
84 pub capture_trust: CaptureTrustResult,
86 pub media_integrity: MediaIntegrityResult,
88 pub error: Option<String>,
90}
91
92pub fn validate(sidecar_path: &Path, media_path: &Path) -> Result<ValidationResult> {
105 validate_impl(sidecar_path, media_path)
106}
107
108pub fn validate_from_bytes(sidecar_json: &str, media_bytes: &[u8]) -> Result<ValidationResult> {
112 validate_bytes_impl(sidecar_json, media_bytes, None)
113}
114
115pub fn validate_from_bytes_with_jwks(
120 sidecar_json: &str,
121 media_bytes: &[u8],
122 jwks_json: &str,
123) -> Result<ValidationResult> {
124 let jwks = parse_jwks_json(jwks_json)?;
125 validate_bytes_impl(sidecar_json, media_bytes, Some(jwks))
126}
127
128fn validate_impl(sidecar_path: &Path, media_path: &Path) -> Result<ValidationResult> {
129 let sidecar = Sidecar::from_file(sidecar_path)?;
131
132 let media_bytes = std::fs::read(media_path)?;
134
135 validate_sidecar_and_media(&sidecar, &media_bytes, None)
136}
137
138fn validate_bytes_impl(
139 sidecar_json: &str,
140 media_bytes: &[u8],
141 jwks: Option<Jwks>,
142) -> Result<ValidationResult> {
143 let sidecar = Sidecar::from_json(sidecar_json)?;
145
146 validate_sidecar_and_media(&sidecar, media_bytes, jwks)
147}
148
149fn validate_sidecar_and_media(
150 sidecar: &Sidecar,
151 media_bytes: &[u8],
152 jwks: Option<Jwks>,
153) -> Result<ValidationResult> {
154 let integrity = sidecar.media_integrity();
155
156 let parsed = parse_jwt(sidecar.jwt())?;
158 let kid = parsed.header.kid.clone();
159
160 let mut jwt_signature_valid = false;
162 let mut content_hash_valid = false;
163 let mut media_signature_valid = false;
164 let mut capture_id_match = false;
165 let mut error_message: Option<String> = None;
166
167 match verify_jwt_with_jwks(sidecar.jwt(), &parsed.claims.iss, kid.as_deref(), jwks) {
169 Ok(()) => jwt_signature_valid = true,
170 Err(e) => {
171 error_message = Some(format!("JWT verification failed: {}", e));
172 }
173 }
174
175 let actual_hash = crate::integrity::compute_hash(media_bytes);
177 if actual_hash == integrity.content_hash {
178 content_hash_valid = true;
179 } else if error_message.is_none() {
180 error_message = Some(format!(
181 "Content hash mismatch: expected {}, got {}",
182 integrity.content_hash, actual_hash
183 ));
184 }
185
186 match verify_media_signature(integrity) {
188 Ok(()) => media_signature_valid = true,
189 Err(e) => {
190 if error_message.is_none() {
191 error_message = Some(format!("Media signature verification failed: {}", e));
192 }
193 }
194 }
195
196 match verify_capture_id_match(&parsed.claims.capture_id, integrity) {
198 Ok(()) => capture_id_match = true,
199 Err(e) => {
200 if error_message.is_none() {
201 error_message = Some(format!("Capture ID mismatch: {}", e));
202 }
203 }
204 }
205
206 let valid =
208 jwt_signature_valid && content_hash_valid && media_signature_valid && capture_id_match;
209
210 Ok(ValidationResult {
211 valid,
212 version: sidecar.version.clone(),
213 capture_trust: CaptureTrustResult::from_claims(&parsed.claims, kid, jwt_signature_valid),
214 media_integrity: MediaIntegrityResult {
215 content_hash_valid,
216 signature_valid: media_signature_valid,
217 capture_id_match,
218 content_hash: integrity.content_hash.clone(),
219 capture_id: integrity.capture_id.clone(),
220 captured_at: integrity.captured_at.clone(),
221 },
222 error: if valid { None } else { error_message },
223 })
224}
225
226fn verify_jwt_with_jwks(
228 token: &str,
229 issuer: &str,
230 kid: Option<&str>,
231 jwks: Option<Jwks>,
232) -> Result<()> {
233 let kid =
234 kid.ok_or_else(|| ValidationError::InvalidJwt("JWT missing kid in header".to_string()))?;
235
236 let jwks = match jwks {
238 Some(jwks) => jwks,
239 None => fetch_jwks(issuer)?,
240 };
241
242 verify_jwt_signature(token, &jwks, kid)?;
243
244 Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn test_validation_result_serialization() {
253 let result = ValidationResult {
254 valid: true,
255 version: "1.0".to_string(),
256 capture_trust: CaptureTrustResult {
257 signature_valid: true,
258 issuer: "https://dev-api.signedshot.io".to_string(),
259 publisher_id: "pub-123".to_string(),
260 device_id: "dev-456".to_string(),
261 capture_id: "cap-789".to_string(),
262 method: "sandbox".to_string(),
263 app_id: None,
264 issued_at: 1705312200,
265 key_id: Some("key-1".to_string()),
266 },
267 media_integrity: MediaIntegrityResult {
268 content_hash_valid: true,
269 signature_valid: true,
270 capture_id_match: true,
271 content_hash: "abc123".to_string(),
272 capture_id: "cap-789".to_string(),
273 captured_at: "2026-01-26T15:30:00Z".to_string(),
274 },
275 error: None,
276 };
277
278 let json = serde_json::to_string(&result).unwrap();
279 assert!(json.contains("\"valid\":true"));
280 assert!(json.contains("\"publisher_id\":\"pub-123\""));
281 assert!(json.contains("\"method\":\"sandbox\""));
282 }
283
284 #[test]
285 fn test_validation_result_with_error() {
286 let result = ValidationResult {
287 valid: false,
288 version: "1.0".to_string(),
289 capture_trust: CaptureTrustResult {
290 signature_valid: false,
291 issuer: "https://dev-api.signedshot.io".to_string(),
292 publisher_id: "pub-123".to_string(),
293 device_id: "dev-456".to_string(),
294 capture_id: "cap-789".to_string(),
295 method: "sandbox".to_string(),
296 app_id: None,
297 issued_at: 1705312200,
298 key_id: None,
299 },
300 media_integrity: MediaIntegrityResult {
301 content_hash_valid: true,
302 signature_valid: true,
303 capture_id_match: true,
304 content_hash: "abc123".to_string(),
305 capture_id: "cap-789".to_string(),
306 captured_at: "2026-01-26T15:30:00Z".to_string(),
307 },
308 error: Some("JWT verification failed".to_string()),
309 };
310
311 let json = serde_json::to_string(&result).unwrap();
312 assert!(json.contains("\"valid\":false"));
313 assert!(json.contains("\"error\":\"JWT verification failed\""));
314 }
315}