Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/bin/signedshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!();
Expand Down
6 changes: 6 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = std::result::Result<T, ValidationError>;
22 changes: 22 additions & 0 deletions src/integrity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 8 additions & 5 deletions src/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)?;
Expand Down
36 changes: 33 additions & 3 deletions src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -35,6 +38,8 @@ pub struct CaptureTrustResult {
pub issued_at: i64,
/// Key ID used to sign the JWT
pub key_id: Option<String>,
/// SHA-256 hex of the device's content-signing public key
pub device_public_key_fingerprint: String,
}

impl CaptureTrustResult {
Expand All @@ -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(),
}
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down