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::{
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/// Result of capture trust (JWT) validation
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CaptureTrustResult {
23    /// Whether the JWT signature was verified successfully
24    pub signature_valid: bool,
25    /// Issuer URL from the JWT
26    pub issuer: String,
27    /// Publisher ID from the JWT claims
28    pub publisher_id: String,
29    /// Device ID from the JWT claims
30    pub device_id: String,
31    /// Capture ID from the JWT claims
32    pub capture_id: String,
33    /// Attestation method (sandbox, app_check, app_attest)
34    pub method: String,
35    /// App ID from attestation (e.g., bundle ID), if available
36    pub app_id: Option<String>,
37    /// Unix timestamp when the JWT was issued
38    pub issued_at: i64,
39    /// Key ID used to sign the JWT
40    pub key_id: Option<String>,
41    /// SHA-256 hex of the device's content-signing public key
42    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/// Result of media integrity validation
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct MediaIntegrityResult {
69    /// Whether the content hash matches the media file
70    pub content_hash_valid: bool,
71    /// Whether the ECDSA signature is valid
72    pub signature_valid: bool,
73    /// Whether the capture_id matches between JWT and media_integrity
74    pub capture_id_match: bool,
75    /// Whether the device public key fingerprint matches
76    pub fingerprint_match: bool,
77    /// The content hash from the sidecar
78    pub content_hash: String,
79    /// The capture ID from media_integrity
80    pub capture_id: String,
81    /// The captured_at timestamp from media_integrity
82    pub captured_at: String,
83}
84
85/// Complete validation result
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ValidationResult {
88    /// Overall validation status (true only if all checks pass)
89    pub valid: bool,
90    /// Sidecar version
91    pub version: String,
92    /// Capture trust (JWT) validation results
93    pub capture_trust: CaptureTrustResult,
94    /// Media integrity validation results
95    pub media_integrity: MediaIntegrityResult,
96    /// Error message if validation failed (None if valid)
97    pub error: Option<String>,
98}
99
100/// Validate a sidecar file against a media file.
101///
102/// This performs complete validation:
103/// 1. Parse the sidecar JSON
104/// 2. Parse and decode the JWT
105/// 3. Fetch JWKS from the issuer
106/// 4. Verify the JWT signature
107/// 5. Verify the content hash matches the media file
108/// 6. Verify the media integrity signature
109/// 7. Verify the capture_id matches between JWT and media_integrity
110///
111/// Returns a `ValidationResult` with detailed information about each check.
112pub fn validate(sidecar_path: &Path, media_path: &Path) -> Result<ValidationResult> {
113    validate_impl(sidecar_path, media_path)
114}
115
116/// Validate from sidecar JSON string and media bytes.
117///
118/// Useful when you have the content in memory rather than files.
119pub fn validate_from_bytes(sidecar_json: &str, media_bytes: &[u8]) -> Result<ValidationResult> {
120    validate_bytes_impl(sidecar_json, media_bytes, None)
121}
122
123/// Validate from sidecar JSON string and media bytes with pre-loaded JWKS.
124///
125/// Use this when you already have the JWKS available locally, avoiding HTTP fetch.
126/// This is useful for the API service that wants to validate using its own keys.
127pub 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    // Parse sidecar
138    let sidecar = Sidecar::from_file(sidecar_path)?;
139
140    // Read media file for hash verification
141    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    // Parse sidecar
152    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    // Parse JWT (without signature verification yet)
165    let parsed = parse_jwt(sidecar.jwt())?;
166    let kid = parsed.header.kid.clone();
167
168    // Track individual check results
169    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    // Verify JWT signature (using provided JWKS or fetching from issuer)
176    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    // Verify content hash
184    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    // Verify media integrity signature
195    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    // Verify capture_id match
205    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    // Cross-layer binding: verify device public key fingerprint
215    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    // Overall validation passes only if all checks pass
229    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
252/// Verify JWT signature using provided JWKS or by fetching from issuer.
253fn 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    // Use provided JWKS or fetch from issuer
263    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}