diff --git a/sdk/src/assertions/labels.rs b/sdk/src/assertions/labels.rs index 36d1445ac..9e909d2e9 100644 --- a/sdk/src/assertions/labels.rs +++ b/sdk/src/assertions/labels.rs @@ -229,6 +229,11 @@ pub static METADATA_LABEL_REGEX: LazyLock = LazyLock::new(|| { } }); +/// Label prefix for multi-asset hash assertion. +/// +/// See +pub const MULTI_ASSET_HASH: &str = "c2pa.hash.multi-asset"; + /// Return the version suffix from an assertion label if it exists. /// /// When an assertion's schema is changed in a backwards-compatible manner, diff --git a/sdk/src/assertions/mod.rs b/sdk/src/assertions/mod.rs index d45d96fe5..660e9b348 100644 --- a/sdk/src/assertions/mod.rs +++ b/sdk/src/assertions/mod.rs @@ -59,6 +59,9 @@ pub use assertion_metadata::{ c2pa_source, Actor, AssertionMetadata, AssetType, DataBox, DataSource, ReviewCode, ReviewRating, }; +mod multi_asset_hash; +pub use multi_asset_hash::{ByteRangeLocator, LocatorMap, MultiAssetHash, PartHashMap}; + mod schema_org; #[allow(deprecated)] pub use schema_org::{SchemaDotOrg, SchemaDotOrgPerson}; diff --git a/sdk/src/assertions/multi_asset_hash.rs b/sdk/src/assertions/multi_asset_hash.rs new file mode 100644 index 000000000..f32deea87 --- /dev/null +++ b/sdk/src/assertions/multi_asset_hash.rs @@ -0,0 +1,355 @@ +// Copyright 2022 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +#[cfg(feature = "file_io")] +use std::fs::File; +use std::io::Cursor; + +use serde::{Deserialize, Serialize}; + +use crate::{ + assertion::{Assertion, AssertionBase, AssertionCbor}, + assertions::{labels, BmffHash, BoxHash, DataHash}, + asset_io::{AssetIO, CAIRead}, + claim::{Claim, ClaimAssetData}, + error::{Error, Result}, + jumbf_io::get_assetio_handler, + utils::io_utils::{stream_len, ReaderUtils}, + validation_status::{ + ASSERTION_MULTI_ASSET_HASH_MALFORMED, ASSERTION_MULTI_ASSET_HASH_MISSING_PART, + }, + HashedUri, +}; + +/// A `MultiAssetHash` assertion provides information on hash values for multiple parts of an asset. +/// +/// This assertion contains a list of parts, each one declaring a location within the asset and +/// the corresponding hash assertion for that part. +/// +/// See +#[derive(Deserialize, Serialize, Debug, PartialEq)] +pub struct MultiAssetHash { + pub parts: Vec, +} + +impl MultiAssetHash { + pub const LABEL: &'static str = labels::MULTI_ASSET_HASH; + + /// The parts within the parts array shall be listed in the order in which they appear in the file, + /// and the parts shall be contiguous, non-overlapping, and cover every byte of the asset. + fn verify_self(&self, total_size: u64) -> Result<()> { + if self.parts.is_empty() { + return Err(Error::C2PAValidation( + ASSERTION_MULTI_ASSET_HASH_MALFORMED.to_string(), + )); + } + + let mut expected_offset: u64 = 0; + let mut optional_sizes: u64 = 0; + + for part in &self.parts { + match &part.location { + LocatorMap::ByteRangeLocator(locator) => { + if locator.byte_offset != expected_offset { + return Err(Error::C2PAValidation( + ASSERTION_MULTI_ASSET_HASH_MALFORMED.to_string(), + )); + } + // Keep track of the size of optional parts. + if part.optional.unwrap_or(false) { + optional_sizes += locator.length; + } + expected_offset += locator.length; + } + LocatorMap::BmffBox { .. } => { + return Err(Error::NotImplemented( + "BmffBox locators not yet implemented for Multi-Asset hashes".to_string(), + )); + } + } + } + + // Deduct optional sizes and ensure that the offsets are less than the total size. + if expected_offset - optional_sizes > total_size { + return Err(Error::C2PAValidation( + ASSERTION_MULTI_ASSET_HASH_MALFORMED.to_string(), + )); + } + + Ok(()) + } + + // Verifies the multi-asset hash assertion against the provided asset data. + pub fn verify_hash(&self, asset_data: &mut ClaimAssetData<'_>, claim: &Claim) -> Result<()> { + match asset_data { + #[cfg(feature = "file_io")] + ClaimAssetData::Path(asset_path) => { + let mut file = File::open(&asset_path).map_err(Error::IoError)?; + let asset_handler = crate::jumbf_io::get_assetio_handler_from_path(asset_path); + self.verify_stream_hash(&mut file, claim, asset_handler) + } + ClaimAssetData::Bytes(asset_bytes, asset_type) => { + let mut cursor = Cursor::new(*asset_bytes); + let asset_handler = get_assetio_handler(asset_type); + self.verify_stream_hash(&mut cursor, claim, asset_handler) + } + ClaimAssetData::Stream(stream_data, asset_type) => { + let asset_handler = get_assetio_handler(asset_type); + self.verify_stream_hash(*stream_data, claim, asset_handler) + } + _ => Err(Error::UnsupportedType), + } + } + + /// Verifies each part of the multi-asset hash through comparing computed hashes. + /// Validates part locations, reads the specified byte ranges, and verifies against referenced hash assertions. + fn verify_stream_hash( + &self, + mut reader: &mut dyn CAIRead, + claim: &Claim, + asset_handler: Option<&dyn AssetIO>, + ) -> Result<()> { + let length = stream_len(reader)?; + self.verify_self(length)?; + + for part in &self.parts { + if part.optional.unwrap_or(false) { + continue; + } + + // Retrieve the assertion linked in the multi-asset assertions. + let assertion = claim + .get_assertion_from_link(&part.hash_assertion.url()) + .ok_or_else(|| { + Error::C2PAValidation(ASSERTION_MULTI_ASSET_HASH_MISSING_PART.to_string()) + })?; + + let label = assertion.label(); + + match &part.location { + LocatorMap::ByteRangeLocator(locator) => { + let offset = locator.byte_offset; + let length = locator.length; + + // Read only the specified parts within the larger stream. + reader.seek(std::io::SeekFrom::Start(offset))?; + let buf = reader.read_to_vec(length).map_err(|_| { + Error::C2PAValidation(ASSERTION_MULTI_ASSET_HASH_MISSING_PART.to_string()) + })?; + let mut part_reader = Cursor::new(buf); + + // Perform validation on each part depending on type of hash. + match label.as_str() { + l if l.starts_with(DataHash::LABEL) => { + let dh = DataHash::from_assertion(assertion)?; + let alg = match &dh.alg { + Some(alg) => alg, + None => claim.alg(), + }; + dh.verify_stream_hash(&mut part_reader, Some(alg))?; + } + l if l.starts_with(BoxHash::LABEL) => { + let bh = BoxHash::from_assertion(assertion)?; + let box_hash_processor = asset_handler + .ok_or(Error::UnsupportedType)? + .asset_box_hash_ref() + .ok_or(Error::HashMismatch("Box hash not supported".to_string()))?; + bh.verify_stream_hash( + &mut part_reader, + Some(claim.alg()), + box_hash_processor, + )?; + } + l if l.starts_with(BmffHash::LABEL) => { + return Err(Error::NotImplemented( + "BmffHash not yet implemented for Multi-Asset hashes".to_string(), + )); + } + _ => {} + } + } + LocatorMap::BmffBox { .. } => { + return Err(Error::NotImplemented( + "BmffBox locators not yet implemented for Multi-Asset hashes".to_string(), + )); + } + } + } + + Ok(()) + } +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PartHashMap { + pub location: LocatorMap, + pub hash_assertion: HashedUri, + #[serde(skip_serializing_if = "Option::is_none")] + pub optional: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +#[serde(rename_all = "camelCase")] +pub enum LocatorMap { + ByteRangeLocator(ByteRangeLocator), + BmffBox { bmff_box: String }, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ByteRangeLocator { + pub byte_offset: u64, + pub length: u64, +} + +impl AssertionCbor for MultiAssetHash {} + +impl AssertionBase for MultiAssetHash { + const LABEL: &'static str = Self::LABEL; + + fn to_assertion(&self) -> Result { + Self::to_cbor_assertion(self) + } + + fn from_assertion(assertion: &Assertion) -> Result { + Self::from_cbor_assertion(assertion) + } +} + +#[cfg(test)] +pub mod tests { + #![allow(clippy::expect_used)] + #![allow(clippy::unwrap_used)] + + use std::io::Cursor; + + use crate::{ + assertion::AssertionBase, assertions::MultiAssetHash, status_tracker::StatusTracker, + store::Store, + }; + + const MOTION_PHOTO: &[u8] = include_bytes!("../../tests/fixtures/motion_photo.jpg"); + const MOTION_PHOTO_2: &[u8] = include_bytes!("../../tests/fixtures/motion_photo2.jpg"); + const NO_MOVIE_MOTION_PHOTO: &[u8] = + include_bytes!("../../tests/fixtures/no_movie_motion_photo.jpg"); + const STRIPPED_PHOTO: &[u8] = include_bytes!("../../tests/fixtures/stripped.jpg"); + + #[test] + fn test_validation() { + let mut validation_log = StatusTracker::default(); + let source = Cursor::new(MOTION_PHOTO); + let store = Store::from_stream("image/jpeg", source, true, &mut validation_log).unwrap(); + let claim = store.provenance_claim().unwrap(); + let assertion = + MultiAssetHash::from_assertion(claim.get_assertion(MultiAssetHash::LABEL, 0).unwrap()) + .unwrap(); + let mut source = Cursor::new(MOTION_PHOTO); + assertion + .verify_stream_hash(&mut source, claim, None) + .unwrap(); + } + + #[test] + fn test_multiple_parts_validation() { + let mut validation_log = StatusTracker::default(); + let source = Cursor::new(MOTION_PHOTO_2); + let store = Store::from_stream("image/jpeg", source, true, &mut validation_log).unwrap(); + let claim = store.provenance_claim().unwrap(); + let assertion = + MultiAssetHash::from_assertion(claim.get_assertion(MultiAssetHash::LABEL, 0).unwrap()) + .unwrap(); + let mut source = Cursor::new(MOTION_PHOTO_2); + assertion + .verify_stream_hash(&mut source, claim, None) + .unwrap(); + } + + #[test] + fn test_stripped_validation() { + let mut validation_log = StatusTracker::default(); + let source = Cursor::new(STRIPPED_PHOTO); + let store = Store::from_stream("image/jpeg", source, true, &mut validation_log).unwrap(); + let claim = store.provenance_claim().unwrap(); + let assertion = + MultiAssetHash::from_assertion(claim.get_assertion(MultiAssetHash::LABEL, 0).unwrap()) + .unwrap(); + let mut source = Cursor::new(STRIPPED_PHOTO); + assertion + .verify_stream_hash(&mut source, claim, None) + .unwrap(); + } + + #[test] + fn test_validation_with_exclusion_of_optional_data_hash() { + let mut validation_log = StatusTracker::default(); + let source = Cursor::new(NO_MOVIE_MOTION_PHOTO); + let store = Store::from_stream("image/jpeg", source, true, &mut validation_log).unwrap(); + let claim = store.provenance_claim().unwrap(); + let assertion = + MultiAssetHash::from_assertion(claim.get_assertion(MultiAssetHash::LABEL, 0).unwrap()) + .unwrap(); + let mut source = Cursor::new(NO_MOVIE_MOTION_PHOTO); + assertion + .verify_stream_hash(&mut source, claim, None) + .unwrap(); + } + + #[test] + fn test_json_round_trip() { + let json = serde_json::json!({ + "parts": [ + { + "location": { + "byteOffset": 0, + "length": 3211426 + }, + "hashAssertion": { + "url": "self#jumbf=c2pa.assertions/c2pa.hash.data.part", + "hash": "Lq2kdBpPG002xct74CAEOb93d/aRhDHhwzK0EGj9y98=" + }, + "optional": false + }, + { + "location": { + "byteOffset": 3211426, + "length": 38044 + }, + "hashAssertion": { + "url": "self#jumbf=c2pa.assertions/c2pa.hash.data.part__1", + "hash": "KlwzkqoUjclLdqKN0N+T3eGCd45iwGncE4lcwiGXlKs=" + }, + "optional": false + }, + { + "location": { + "byteOffset": 3249470, + "length": 1403182 + }, + "hashAssertion": { + "url": "self#jumbf=c2pa.assertions/c2pa.hash.data.part__2", + "hash": "GykUNh5wHwRVpfsduK2ylqY5IfuHZLyuwIkUTuD7O0E=" + }, + "optional": true + } + ] + }); + + let original: MultiAssetHash = serde_json::from_value(json).unwrap(); + let assertion = original.to_assertion().unwrap(); + let result = MultiAssetHash::from_assertion(&assertion).unwrap(); + + assert_eq!(result, original); + } +} diff --git a/sdk/src/asset_handlers/jpeg_io.rs b/sdk/src/asset_handlers/jpeg_io.rs index fa9a47e5c..fbc159c79 100644 --- a/sdk/src/asset_handlers/jpeg_io.rs +++ b/sdk/src/asset_handlers/jpeg_io.rs @@ -18,7 +18,7 @@ use std::{ path::*, }; -use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use byteorder::{BigEndian, ByteOrder, ReadBytesExt, WriteBytesExt}; use img_parts::{ jpeg::{ markers::{self, APP0, APP15, COM, DQT, DRI, P, RST0, RST7, SOF0, SOF15, SOS, Z}, @@ -47,10 +47,24 @@ static SUPPORTED_TYPES: [&str; 3] = ["jpg", "jpeg", "image/jpeg"]; const XMP_SIGNATURE: &str = "http://ns.adobe.com/xap/1.0/"; const XMP_SIGNATURE_BUFFER_SIZE: usize = XMP_SIGNATURE.len() + 1; // skip null or space char at end +const XMP_EXTENSION_SIGNATURE: &str = "http://ns.adobe.com/xmp/extension/"; +const XMP_EXTENSION_SIGNATURE_BUFFER_SIZE: usize = XMP_EXTENSION_SIGNATURE.len() + 1; // skip null or space char at end + +const XMP_EXTENDED_NOTE: &str = "xmpNote:HasExtendedXMP"; +const XMP_EXTENDED_NOTE_SIZE: usize = XMP_EXTENDED_NOTE.len() + 1 + 1; // skip '=' and '"' + const MAX_JPEG_MARKER_SIZE: usize = 64000; // technically it's 64K but a bit smaller is fine const C2PA_MARKER: [u8; 4] = [0x63, 0x32, 0x70, 0x61]; +struct XmpExtension { + guid: String, + #[allow(dead_code)] + length: u32, + offset: u32, + content: String, +} + fn vec_compare(va: &[u8], vb: &[u8]) -> bool { (va.len() == vb.len()) && // zip stops at the shortest va.iter() @@ -68,11 +82,72 @@ fn extract_xmp(seg: &JpegSegment) -> Option<&str> { } } -// Extract XMP from bytes. +fn extract_xmp_extensions(seg: &JpegSegment) -> Option { + let (sig, rest) = seg + .contents() + .split_at_checked(XMP_EXTENSION_SIGNATURE_BUFFER_SIZE)?; + + if sig.starts_with(XMP_EXTENSION_SIGNATURE.as_bytes()) && rest.len() > 40 { + // 32 byte GUID, 4 byte length, 4 byte offset + let guid = std::str::from_utf8(&rest[..32]).ok()?.to_string(); + let length = BigEndian::read_u32(&rest[32..36]); + let offset = BigEndian::read_u32(&rest[36..40]); + let content = std::str::from_utf8(&rest[40..]).ok()?.to_string(); + return Some(XmpExtension { + guid, + length, + offset, + content, + }); + } + None +} + fn xmp_from_bytes(asset_bytes: &[u8]) -> Option { let jpeg = Jpeg::from_bytes(Bytes::copy_from_slice(asset_bytes)).ok()?; let mut segs = jpeg.segments_by_marker(markers::APP1); - segs.find_map(extract_xmp).map(String::from) + + let mut standard_xmp = segs.find_map(extract_xmp).map(String::from)?; + + // Check for the existence of XMP extensions. + if let Some(pos) = standard_xmp.find(XMP_EXTENDED_NOTE) { + let beginning = pos + XMP_EXTENDED_NOTE_SIZE; + let guid = &standard_xmp[beginning..beginning + 32]; + + // Only incorporate ExtendedXMP blocks whose GUID matches the value of xmpNote:HasExtendedXMP. + let mut extensions: Vec = segs + .filter_map(|seg| { + let extension = extract_xmp_extensions(seg)?; + if extension.guid != guid { + return None; + } + Some(extension) + }) + .collect(); + + extensions.sort_by_key(|extensions| extensions.offset); + + // The first chunk has offset 0, the second chunk has an offset equal to the first chunk's size, and so on. + for i in 1..extensions.len() { + let prev = &extensions[i - 1]; + let curr = &extensions[i]; + + if prev.offset + (prev.content.len() as u32) != curr.offset { + return None; + } + } + + let extensions_content: Vec = extensions + .into_iter() + .map(|extension| extension.content) + .collect(); + + if !extensions_content.is_empty() { + standard_xmp.push_str(&extensions_content.join("")); + } + } + + Some(standard_xmp) } fn add_required_segs_to_stream( @@ -1132,7 +1207,7 @@ impl ComposedManifestRef for JpegIO { pub mod tests { #![allow(clippy::unwrap_used)] - use std::io::{Read, Seek}; + use std::io::{Read, Seek, Write}; use c2pa_macros::c2pa_test_async; #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] @@ -1140,6 +1215,32 @@ pub mod tests { use super::*; use crate::utils::io_utils::tempdirectory; + + #[test] + fn test_extract_extensions_xmp() { + let jpeg = Jpeg::from_bytes(Bytes::copy_from_slice(include_bytes!( + "../../tests/fixtures/motion_photo.jpg" + ))) + .ok() + .unwrap(); + let segs = jpeg.segments_by_marker(markers::APP1); + + let mut extensions: Vec = segs.filter_map(extract_xmp_extensions).collect(); + + extensions.sort_by_key(|extension| extension.offset); + + let extensions_content: Vec = extensions + .iter() + .map(|extensions| extensions.content.clone()) + .collect(); + + assert_eq!(extensions_content.len(), 11); + + for i in 1..extensions.len() { + assert!(extensions[i].offset >= extensions[i - 1].offset); + } + } + #[test] fn test_extract_xmp() { let contents = Bytes::from_static(b"http://ns.adobe.com/xap/1.0/\0stuff"); diff --git a/sdk/src/claim.rs b/sdk/src/claim.rs index 3fd6112d6..51c7f7c92 100644 --- a/sdk/src/claim.rs +++ b/sdk/src/claim.rs @@ -36,7 +36,7 @@ use crate::{ DATABOX_STORE, METADATA_LABEL_REGEX, }, Actions, AssertionMetadata, AssetType, BmffHash, BoxHash, DataBox, DataHash, Ingredient, - Metadata, Relationship, V2_DEPRECATED_ACTIONS, + Metadata, MultiAssetHash, Relationship, V2_DEPRECATED_ACTIONS, }, asset_io::CAIRead, cbor_types::{map_cbor_to_type, value_cbor_to_type}, @@ -75,7 +75,8 @@ use crate::{ status_tracker::{ErrorBehavior, StatusTracker}, store::StoreValidationInfo, utils::hash_utils::{hash_by_alg, vec_compare}, - validation_status, ClaimGeneratorInfo, + validation_status::{self, ASSERTION_MULTI_ASSET_HASH_MALFORMED}, + ClaimGeneratorInfo, }; const BUILD_HASH_ALG: &str = "sha256"; @@ -2759,15 +2760,15 @@ impl Claim { continue; } Err(e) => { - log_item!( - claim.assertion_uri(&hash_binding_assertion.label()), - format!("asset hash error, name: {name}, error: {e}"), - "verify_internal" - ) - .validation_status(validation_status::ASSERTION_DATAHASH_MISMATCH) - .failure( + // If standard asset hard binding fails, try multi-asset hash validation. + // Only one multi-asset hash assertion is allowed per manifest. + Claim::verify_multi_asset_hash( + claim, + asset_data, validation_log, - Error::HashMismatch(format!("Asset hash failure: {e}")), + hash_binding_assertion, + &e.to_string(), + Some(&name), )?; } } @@ -2844,6 +2845,15 @@ impl Claim { _ => validation_status::ASSERTION_BMFFHASH_MISMATCH, }; + Claim::verify_multi_asset_hash( + claim, + asset_data, + validation_log, + hash_binding_assertion, + err_str, + Some(&name), + )?; + log_item!( claim.assertion_uri(&hash_binding_assertion.label()), format!("asset hash error, name: {name}, error: {}", err_str), @@ -2921,15 +2931,13 @@ impl Claim { continue; } Err(e) => { - log_item!( - claim.assertion_uri(&hash_binding_assertion.label()), - format!("asset hash error: {e}"), - "verify_internal" - ) - .validation_status(validation_status::ASSERTION_BOXHASH_MISMATCH) - .failure( + Claim::verify_multi_asset_hash( + claim, + asset_data, validation_log, - Error::HashMismatch(format!("Asset hash failure: {e}")), + hash_binding_assertion, + &e.to_string(), + None, )?; } } @@ -3317,7 +3325,103 @@ impl Claim { Ok(()) } - ///Returns list of metadata assertions + fn verify_multi_asset_hash( + claim: &Claim, + asset_data: &mut ClaimAssetData, + validation_log: &mut StatusTracker, + hash_binding_assertion: &ClaimAssertion, + hash_binding_err_str: &str, + hash_binding_name: Option<&str>, + ) -> Result<()> { + let multi_asset_hash_assertions = claim.multi_asset_hash_assertions(); + if multi_asset_hash_assertions.len() > 1 { + return Err(Error::C2PAValidation( + ASSERTION_MULTI_ASSET_HASH_MALFORMED.to_string(), + )); + } + + if let Some(assertion) = multi_asset_hash_assertions.first() { + let multi_asset_hash_assertion = MultiAssetHash::from_assertion(assertion.assertion())?; + let multi_hash_result = multi_asset_hash_assertion.verify_hash(asset_data, claim); + + match &multi_hash_result { + Ok(_) => { + log_item!( + claim.assertion_uri(MultiAssetHash::LABEL), + "multi-asset hash valid", + "verify_multi_asset_hash" + ) + .validation_status(validation_status::ASSERTION_MULTI_ASSET_HASH_MATCH) + .success(validation_log); + } + Err(multi_e) => { + let err_str = match multi_e { + Error::C2PAValidation(ref es) => { + if es == validation_status::ASSERTION_MULTI_ASSET_HASH_MALFORMED { + validation_status::ASSERTION_MULTI_ASSET_HASH_MALFORMED + } else if es + == validation_status::ASSERTION_MULTI_ASSET_HASH_MISSING_PART + { + validation_status::ASSERTION_MULTI_ASSET_HASH_MISSING_PART + } else { + validation_status::ASSERTION_MULTI_ASSET_HASH_MISMATCH + } + } + _ => validation_status::ASSERTION_MULTI_ASSET_HASH_MISMATCH, + }; + + log_item!( + claim.assertion_uri(multi_asset_hash_assertion.label()), + format!("multi asset hash error, error: {}", err_str), + "verify_multi_asset_hash" + ) + .validation_status(err_str) + .failure( + validation_log, + Error::HashMismatch(format!("Asset hash failure: {err_str}")), + )?; + } + } + return multi_hash_result; + } + + // If there is no multi asset assertion, passthrough the error handling + // reporting from the caller. + let description = if let Some(name) = hash_binding_name { + format!("asset hash error, name:{name}, error: {hash_binding_err_str}") + } else { + format!("asset hash error, error: {hash_binding_err_str}") + }; + + let validation_status = match hash_binding_assertion.label_raw() { + l if l.starts_with(DataHash::LABEL) => validation_status::ASSERTION_DATAHASH_MISMATCH, + l if l.starts_with(BoxHash::LABEL) => validation_status::ASSERTION_BOXHASH_MISMATCH, + l if l.starts_with(BmffHash::LABEL) => validation_status::ASSERTION_BMFFHASH_MISMATCH, + _ => "", + }; + + log_item!( + claim.assertion_uri(&hash_binding_assertion.label()), + description, + "verify_multi_asset_hash" + ) + .validation_status(validation_status) + .failure( + validation_log, + Error::HashMismatch(format!("Asset hash failure: {hash_binding_err_str}")), + )?; + Ok(()) + } + + /// Returns list of multi asset hash assertions + pub fn multi_asset_hash_assertions(&self) -> Vec<&ClaimAssertion> { + let dummy_data = AssertionData::Cbor(Vec::new()); + let dummy_multi_asset_hash = + Assertion::new(assertions::labels::MULTI_ASSET_HASH, None, dummy_data); + self.assertions_by_type(&dummy_multi_asset_hash, None) + } + + /// Returns list of metadata assertions pub fn metadata_assertions(&self) -> Vec<&ClaimAssertion> { let mut mda: Vec<&ClaimAssertion> = self .assertion_store @@ -3905,6 +4009,13 @@ impl Claim { .find(|ca| ca.label_raw() == assertion_label && ca.instance() == instance) } + /// TODO: Refactor instances of this pattern to use this method + /// returns assertion from link + pub fn get_assertion_from_link(&self, assertion_link: &str) -> Option<&Assertion> { + let (label, instance) = Claim::assertion_label_from_link(assertion_link); + self.get_assertion(&label, instance) + } + /// returns hash of an assertion whose label and instance match pub fn get_claim_assertion_hash(&self, assertion_label: &str) -> Option> { let (l, i) = Claim::assertion_label_from_link(assertion_label); diff --git a/sdk/src/validation_results.rs b/sdk/src/validation_results.rs index fdfa0daff..1d289e1ed 100644 --- a/sdk/src/validation_results.rs +++ b/sdk/src/validation_results.rs @@ -807,6 +807,29 @@ pub mod validation_codes { /// Any corresponding URL should point to a C2PA assertion box. pub const ASSERTION_COLLECTIONHASH_MALFORMED: &str = "assertion.collectionHash.malformed"; + /// The hash of one part of a multi-asset hash assertion matches + /// the corresponding hash in the assertion’s multi-asset-hash-map. + /// + /// Any corresponding URL should point to a C2PA assertion box. + pub const ASSERTION_MULTI_ASSET_HASH_MATCH: &str = "assertion.multiAssetHash.match"; + + /// A multi asset hash assertion is malformed. + /// + /// Any corresponding URL should point to a C2PA assertion box. + pub const ASSERTION_MULTI_ASSET_HASH_MALFORMED: &str = "assertion.multiAssetHash.malformed"; + + /// The hash of a part of a multi-part asset does not match the hash + /// declared in the multi-asset hash assertion. + /// + /// Any corresponding URL should point to a C2PA assertion box. + pub const ASSERTION_MULTI_ASSET_HASH_MISMATCH: &str = "assertion.multiAssetHash.mismatch"; + + /// A required part of the multi-part asset cannot be located. + /// + /// Any corresponding URL should point to a C2PA assertion box. + pub const ASSERTION_MULTI_ASSET_HASH_MISSING_PART: &str = + "assertion.multiAssetHash.missingPart"; + /// The ingredient assertion was incomplete. /// /// Any corresponding URL should point to a C2PA assertion box. diff --git a/sdk/tests/fixtures/motion_photo.jpg b/sdk/tests/fixtures/motion_photo.jpg new file mode 100644 index 000000000..4caa2f965 Binary files /dev/null and b/sdk/tests/fixtures/motion_photo.jpg differ diff --git a/sdk/tests/fixtures/motion_photo2.jpg b/sdk/tests/fixtures/motion_photo2.jpg new file mode 100644 index 000000000..46b652e81 Binary files /dev/null and b/sdk/tests/fixtures/motion_photo2.jpg differ diff --git a/sdk/tests/fixtures/no_movie_motion_photo.jpg b/sdk/tests/fixtures/no_movie_motion_photo.jpg new file mode 100644 index 000000000..68b67f3e6 Binary files /dev/null and b/sdk/tests/fixtures/no_movie_motion_photo.jpg differ diff --git a/sdk/tests/fixtures/stripped.jpg b/sdk/tests/fixtures/stripped.jpg new file mode 100644 index 000000000..78f1d1626 Binary files /dev/null and b/sdk/tests/fixtures/stripped.jpg differ