diff --git a/src/bin/signedshot.rs b/src/bin/signedshot.rs index 0ee865a..8476a5a 100644 --- a/src/bin/signedshot.rs +++ b/src/bin/signedshot.rs @@ -163,6 +163,12 @@ fn validate_command(sidecar_path: &Path, media_path: &Path, json_output: bool) - println!("[FAILED] Capture ID mismatch"); } + if result.media_integrity.fingerprint_match { + println!("[OK] Device public key fingerprint verified"); + } else { + println!("[FAILED] Device public key fingerprint mismatch"); + } + let integrity = sidecar.media_integrity(); println!(); diff --git a/src/error.rs b/src/error.rs index d8f138f..5b1c6c3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -50,6 +50,12 @@ pub enum ValidationError { #[error("Invalid public key: {0}")] InvalidPublicKey(String), + + #[error("Device public key fingerprint mismatch: JWT has {jwt_fingerprint}, computed {computed_fingerprint}")] + DevicePublicKeyFingerprintMismatch { + jwt_fingerprint: String, + computed_fingerprint: String, + }, } pub type Result = std::result::Result; diff --git a/src/integrity.rs b/src/integrity.rs index 2fdd861..0e1293c 100644 --- a/src/integrity.rs +++ b/src/integrity.rs @@ -112,6 +112,28 @@ pub fn verify_capture_id_match(jwt_capture_id: &str, integrity: &MediaIntegrity) Ok(()) } +/// Verify that the JWT's device_public_key_fingerprint matches +/// SHA-256(base64_decode(media_integrity.public_key)). +pub fn verify_device_public_key_fingerprint( + jwt_fingerprint: &str, + integrity: &MediaIntegrity, +) -> Result<()> { + let public_key_bytes = STANDARD + .decode(&integrity.public_key) + .map_err(|e| ValidationError::InvalidPublicKey(format!("Base64 decode failed: {}", e)))?; + + let computed = hex::encode(Sha256::digest(&public_key_bytes)); + + if computed != jwt_fingerprint { + return Err(ValidationError::DevicePublicKeyFingerprintMismatch { + jwt_fingerprint: jwt_fingerprint.to_string(), + computed_fingerprint: computed, + }); + } + + Ok(()) +} + /// Full media integrity verification /// /// This verifies: diff --git a/src/jwt.rs b/src/jwt.rs index c45927b..0cb3c1b 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -47,6 +47,7 @@ pub struct CaptureTrustClaims { pub publisher_id: String, pub device_id: String, pub attestation: Attestation, + pub device_public_key_fingerprint: String, } #[derive(Debug, Clone)] @@ -189,7 +190,7 @@ mod tests { #[test] fn parse_valid_jwt() { let header = r#"{"alg":"ES256","typ":"JWT","kid":"test-key"}"#; - let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"}}"#; + let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#; let token = make_jwt(header, payload); let parsed = parse_jwt(&token).unwrap(); @@ -202,7 +203,7 @@ mod tests { #[test] fn parse_jwt_with_app_id() { let header = r#"{"alg":"ES256","typ":"JWT","kid":"test-key"}"#; - let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"app_check","app_id":"io.foo.bar"}}"#; + let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"app_check","app_id":"io.foo.bar"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#; let token = make_jwt(header, payload); let parsed = parse_jwt(&token).unwrap(); @@ -216,7 +217,7 @@ mod tests { #[test] fn reject_invalid_algorithm() { let header = r#"{"alg":"HS256","typ":"JWT"}"#; - let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"}}"#; + let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#; let token = make_jwt(header, payload); let result = parse_jwt(&token); @@ -226,7 +227,7 @@ mod tests { #[test] fn reject_invalid_audience() { let header = r#"{"alg":"ES256","typ":"JWT"}"#; - let payload = r#"{"iss":"https://example.com","aud":"wrong","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"}}"#; + let payload = r#"{"iss":"https://example.com","aud":"wrong","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"sandbox"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#; let token = make_jwt(header, payload); let result = parse_jwt(&token); @@ -236,7 +237,7 @@ mod tests { #[test] fn reject_invalid_method() { let header = r#"{"alg":"ES256","typ":"JWT"}"#; - let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"invalid"}}"#; + let payload = r#"{"iss":"https://dev-api.signedshot.io","aud":"signedshot","sub":"capture-service","iat":1705312200,"capture_id":"123","publisher_id":"456","device_id":"789","attestation":{"method":"invalid"},"device_public_key_fingerprint":"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}"#; let token = make_jwt(header, payload); let result = parse_jwt(&token); @@ -295,6 +296,7 @@ mod tests { method: "sandbox".to_string(), app_id: None, }, + device_public_key_fingerprint: "a".repeat(64), }; let mut header = Header::new(Algorithm::ES256); @@ -348,6 +350,7 @@ mod tests { method: "sandbox".to_string(), app_id: None, }, + device_public_key_fingerprint: "a".repeat(64), }; let mut header = Header::new(Algorithm::ES256); diff --git a/src/lib.rs b/src/lib.rs index 5623607..8d5497e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,7 +47,8 @@ mod python; pub use error::{Result, ValidationError}; pub use integrity::{ compute_file_hash, compute_hash, verify_capture_id_match, verify_content_hash, - verify_media_integrity, verify_signature as verify_media_signature, + verify_device_public_key_fingerprint, verify_media_integrity, + verify_signature as verify_media_signature, }; pub use jwt::{ fetch_jwks, parse_jwt, verify_signature, CaptureTrustClaims, Jwk, Jwks, JwtHeader, ParsedJwt, diff --git a/src/python.rs b/src/python.rs index 035c7fb..25931f1 100644 --- a/src/python.rs +++ b/src/python.rs @@ -38,6 +38,10 @@ impl PyValidationResult { dict.set_item("app_id", &self.inner.capture_trust.app_id)?; dict.set_item("issued_at", self.inner.capture_trust.issued_at)?; dict.set_item("key_id", &self.inner.capture_trust.key_id)?; + dict.set_item( + "device_public_key_fingerprint", + &self.inner.capture_trust.device_public_key_fingerprint, + )?; Ok(dict) } @@ -57,6 +61,10 @@ impl PyValidationResult { "capture_id_match", self.inner.media_integrity.capture_id_match, )?; + dict.set_item( + "fingerprint_match", + self.inner.media_integrity.fingerprint_match, + )?; dict.set_item("content_hash", &self.inner.media_integrity.content_hash)?; dict.set_item("capture_id", &self.inner.media_integrity.capture_id)?; dict.set_item("captured_at", &self.inner.media_integrity.captured_at)?; diff --git a/src/validate.rs b/src/validate.rs index c217e9b..fda31b5 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use crate::error::{Result, ValidationError}; -use crate::integrity::{verify_capture_id_match, verify_signature as verify_media_signature}; +use crate::integrity::{ + verify_capture_id_match, verify_device_public_key_fingerprint, + verify_signature as verify_media_signature, +}; use crate::jwt::{ fetch_jwks, parse_jwks_json, parse_jwt, verify_signature as verify_jwt_signature, CaptureTrustClaims, Jwks, @@ -35,6 +38,8 @@ pub struct CaptureTrustResult { pub issued_at: i64, /// Key ID used to sign the JWT pub key_id: Option, + /// SHA-256 hex of the device's content-signing public key + pub device_public_key_fingerprint: String, } impl CaptureTrustResult { @@ -53,6 +58,7 @@ impl CaptureTrustResult { app_id: claims.attestation.app_id.clone(), issued_at: claims.iat, key_id, + device_public_key_fingerprint: claims.device_public_key_fingerprint.clone(), } } } @@ -66,6 +72,8 @@ pub struct MediaIntegrityResult { pub signature_valid: bool, /// Whether the capture_id matches between JWT and media_integrity pub capture_id_match: bool, + /// Whether the device public key fingerprint matches + pub fingerprint_match: bool, /// The content hash from the sidecar pub content_hash: String, /// The capture ID from media_integrity @@ -203,9 +211,26 @@ fn validate_sidecar_and_media( } } + // Cross-layer binding: verify device public key fingerprint + let mut fingerprint_match = false; + match verify_device_public_key_fingerprint( + &parsed.claims.device_public_key_fingerprint, + integrity, + ) { + Ok(()) => fingerprint_match = true, + Err(e) => { + if error_message.is_none() { + error_message = Some(format!("Device public key fingerprint mismatch: {}", e)); + } + } + } + // Overall validation passes only if all checks pass - let valid = - jwt_signature_valid && content_hash_valid && media_signature_valid && capture_id_match; + let valid = jwt_signature_valid + && content_hash_valid + && media_signature_valid + && capture_id_match + && fingerprint_match; Ok(ValidationResult { valid, @@ -215,6 +240,7 @@ fn validate_sidecar_and_media( content_hash_valid, signature_valid: media_signature_valid, capture_id_match, + fingerprint_match, content_hash: integrity.content_hash.clone(), capture_id: integrity.capture_id.clone(), captured_at: integrity.captured_at.clone(), @@ -263,11 +289,13 @@ mod tests { app_id: None, issued_at: 1705312200, key_id: Some("key-1".to_string()), + device_public_key_fingerprint: "a".repeat(64), }, media_integrity: MediaIntegrityResult { content_hash_valid: true, signature_valid: true, capture_id_match: true, + fingerprint_match: true, content_hash: "abc123".to_string(), capture_id: "cap-789".to_string(), captured_at: "2026-01-26T15:30:00Z".to_string(), @@ -296,11 +324,13 @@ mod tests { app_id: None, issued_at: 1705312200, key_id: None, + device_public_key_fingerprint: "a".repeat(64), }, media_integrity: MediaIntegrityResult { content_hash_valid: true, signature_valid: true, capture_id_match: true, + fingerprint_match: true, content_hash: "abc123".to_string(), capture_id: "cap-789".to_string(), captured_at: "2026-01-26T15:30:00Z".to_string(),