Skip to main content

signedshot_validator/
validate.rs

1//! High-level validation API for SignedShot.
2//!
3//! This module provides a unified validation function that returns
4//! structured results suitable for API responses.
5
6use 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/// Result of capture trust (JWT) validation
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CaptureTrustResult {
20    /// Whether the JWT signature was verified successfully
21    pub signature_valid: bool,
22    /// Issuer URL from the JWT
23    pub issuer: String,
24    /// Publisher ID from the JWT claims
25    pub publisher_id: String,
26    /// Device ID from the JWT claims
27    pub device_id: String,
28    /// Capture ID from the JWT claims
29    pub capture_id: String,
30    /// Attestation method (sandbox, app_check, app_attest)
31    pub method: String,
32    /// App ID from attestation (e.g., bundle ID), if available
33    pub app_id: Option<String>,
34    /// Unix timestamp when the JWT was issued
35    pub issued_at: i64,
36    /// Key ID used to sign the JWT
37    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/// Result of media integrity validation
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct MediaIntegrityResult {
63    /// Whether the content hash matches the media file
64    pub content_hash_valid: bool,
65    /// Whether the ECDSA signature is valid
66    pub signature_valid: bool,
67    /// Whether the capture_id matches between JWT and media_integrity
68    pub capture_id_match: bool,
69    /// The content hash from the sidecar
70    pub content_hash: String,
71    /// The capture ID from media_integrity
72    pub capture_id: String,
73    /// The captured_at timestamp from media_integrity
74    pub captured_at: String,
75}
76
77/// Complete validation result
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ValidationResult {
80    /// Overall validation status (true only if all checks pass)
81    pub valid: bool,
82    /// Sidecar version
83    pub version: String,
84    /// Capture trust (JWT) validation results
85    pub capture_trust: CaptureTrustResult,
86    /// Media integrity validation results
87    pub media_integrity: MediaIntegrityResult,
88    /// Error message if validation failed (None if valid)
89    pub error: Option<String>,
90}
91
92/// Validate a sidecar file against a media file.
93///
94/// This performs complete validation:
95/// 1. Parse the sidecar JSON
96/// 2. Parse and decode the JWT
97/// 3. Fetch JWKS from the issuer
98/// 4. Verify the JWT signature
99/// 5. Verify the content hash matches the media file
100/// 6. Verify the media integrity signature
101/// 7. Verify the capture_id matches between JWT and media_integrity
102///
103/// Returns a `ValidationResult` with detailed information about each check.
104pub fn validate(sidecar_path: &Path, media_path: &Path) -> Result<ValidationResult> {
105    validate_impl(sidecar_path, media_path)
106}
107
108/// Validate from sidecar JSON string and media bytes.
109///
110/// Useful when you have the content in memory rather than files.
111pub fn validate_from_bytes(sidecar_json: &str, media_bytes: &[u8]) -> Result<ValidationResult> {
112    validate_bytes_impl(sidecar_json, media_bytes, None)
113}
114
115/// Validate from sidecar JSON string and media bytes with pre-loaded JWKS.
116///
117/// Use this when you already have the JWKS available locally, avoiding HTTP fetch.
118/// This is useful for the API service that wants to validate using its own keys.
119pub 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    // Parse sidecar
130    let sidecar = Sidecar::from_file(sidecar_path)?;
131
132    // Read media file for hash verification
133    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    // Parse sidecar
144    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    // Parse JWT (without signature verification yet)
157    let parsed = parse_jwt(sidecar.jwt())?;
158    let kid = parsed.header.kid.clone();
159
160    // Track individual check results
161    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    // Verify JWT signature (using provided JWKS or fetching from issuer)
168    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    // Verify content hash
176    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    // Verify media integrity signature
187    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    // Verify capture_id match
197    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    // Overall validation passes only if all checks pass
207    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
226/// Verify JWT signature using provided JWKS or by fetching from issuer.
227fn 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    // Use provided JWKS or fetch from issuer
237    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}