Skip to main content

signedshot_validator/
sidecar.rs

1//! Sidecar parsing for SignedShot media authenticity proofs.
2
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6use crate::error::{Result, ValidationError};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CaptureTrust {
10    pub jwt: String,
11}
12
13/// Media integrity proof from the device's Secure Enclave
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct MediaIntegrity {
16    /// SHA-256 hash of the media content (hex string, 64 characters)
17    pub content_hash: String,
18    /// Base64-encoded ECDSA signature of the signed message
19    pub signature: String,
20    /// Base64-encoded public key (uncompressed EC point, 65 bytes)
21    pub public_key: String,
22    /// UUID of the capture session (must match JWT capture_id)
23    pub capture_id: String,
24    /// ISO8601 UTC timestamp of when the media was captured
25    pub captured_at: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Sidecar {
30    pub version: String,
31    pub capture_trust: CaptureTrust,
32    /// Media integrity proof from the device's Secure Enclave
33    pub media_integrity: MediaIntegrity,
34}
35
36impl Sidecar {
37    pub fn from_json(json: &str) -> Result<Self> {
38        let sidecar: Sidecar = serde_json::from_str(json)?;
39        sidecar.validate()?;
40        Ok(sidecar)
41    }
42
43    pub fn from_file(path: &Path) -> Result<Self> {
44        let contents = std::fs::read_to_string(path)?;
45        Self::from_json(&contents)
46    }
47
48    fn validate(&self) -> Result<()> {
49        if self.version != "1.0" {
50            return Err(ValidationError::UnsupportedVersion(self.version.clone()));
51        }
52
53        if self.capture_trust.jwt.is_empty() {
54            return Err(ValidationError::InvalidSidecar(
55                "capture_trust.jwt is empty".to_string(),
56            ));
57        }
58
59        let parts: Vec<&str> = self.capture_trust.jwt.split('.').collect();
60        if parts.len() != 3 {
61            return Err(ValidationError::InvalidSidecar(
62                "capture_trust.jwt is not a valid JWT format".to_string(),
63            ));
64        }
65
66        Ok(())
67    }
68
69    pub fn jwt(&self) -> &str {
70        &self.capture_trust.jwt
71    }
72
73    pub fn media_integrity(&self) -> &MediaIntegrity {
74        &self.media_integrity
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    const VALID_JWT: &str =
83        "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature";
84
85    const VALID_SIDECAR_JSON: &str = r#"{
86        "version": "1.0",
87        "capture_trust": {
88            "jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"
89        },
90        "media_integrity": {
91            "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
92            "signature": "MEUCIQC...",
93            "public_key": "BF8lB7BJ5vOldMb...",
94            "capture_id": "550e8400-e29b-41d4-a716-446655440000",
95            "captured_at": "2026-01-26T15:30:00Z"
96        }
97    }"#;
98
99    #[test]
100    fn parse_valid_sidecar() {
101        let sidecar = Sidecar::from_json(VALID_SIDECAR_JSON).unwrap();
102        assert_eq!(sidecar.version, "1.0");
103        assert!(sidecar.jwt().starts_with("eyJ"));
104
105        let integrity = sidecar.media_integrity();
106        assert_eq!(integrity.content_hash.len(), 64);
107        assert_eq!(integrity.capture_id, "550e8400-e29b-41d4-a716-446655440000");
108        assert_eq!(integrity.captured_at, "2026-01-26T15:30:00Z");
109    }
110
111    #[test]
112    fn reject_unsupported_version() {
113        let json = format!(
114            r#"{{
115                "version": "2.0",
116                "capture_trust": {{"jwt": "{}"}},
117                "media_integrity": {{
118                    "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
119                    "signature": "sig",
120                    "public_key": "key",
121                    "capture_id": "uuid",
122                    "captured_at": "2026-01-26T15:30:00Z"
123                }}
124            }}"#,
125            VALID_JWT
126        );
127
128        let result = Sidecar::from_json(&json);
129        assert!(matches!(
130            result,
131            Err(ValidationError::UnsupportedVersion(_))
132        ));
133    }
134
135    #[test]
136    fn reject_empty_jwt() {
137        let json = r#"{
138            "version": "1.0",
139            "capture_trust": {"jwt": ""},
140            "media_integrity": {
141                "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
142                "signature": "sig",
143                "public_key": "key",
144                "capture_id": "uuid",
145                "captured_at": "2026-01-26T15:30:00Z"
146            }
147        }"#;
148
149        let result = Sidecar::from_json(json);
150        assert!(matches!(result, Err(ValidationError::InvalidSidecar(_))));
151    }
152
153    #[test]
154    fn reject_invalid_jwt_format() {
155        let json = r#"{
156            "version": "1.0",
157            "capture_trust": {"jwt": "not-a-jwt"},
158            "media_integrity": {
159                "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
160                "signature": "sig",
161                "public_key": "key",
162                "capture_id": "uuid",
163                "captured_at": "2026-01-26T15:30:00Z"
164            }
165        }"#;
166
167        let result = Sidecar::from_json(json);
168        assert!(matches!(result, Err(ValidationError::InvalidSidecar(_))));
169    }
170
171    #[test]
172    fn reject_missing_capture_trust() {
173        let json = r#"{"version": "1.0", "media_integrity": {}}"#;
174
175        let result = Sidecar::from_json(json);
176        assert!(matches!(result, Err(ValidationError::SidecarParseError(_))));
177    }
178
179    #[test]
180    fn reject_missing_media_integrity() {
181        let json = r#"{
182            "version": "1.0",
183            "capture_trust": {
184                "jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"
185            }
186        }"#;
187
188        let result = Sidecar::from_json(json);
189        assert!(matches!(result, Err(ValidationError::SidecarParseError(_))));
190    }
191
192    #[test]
193    fn reject_invalid_json() {
194        let json = "not valid json";
195
196        let result = Sidecar::from_json(json);
197        assert!(matches!(result, Err(ValidationError::SidecarParseError(_))));
198    }
199
200    #[test]
201    fn from_file_nonexistent() {
202        let result = Sidecar::from_file(Path::new("/nonexistent/file.json"));
203        assert!(matches!(result, Err(ValidationError::SidecarReadError(_))));
204    }
205}