c2pa-rs

A fork of https://github.com/contentauth/c2pa-rs/
git clone git://archive.git.mtrnord.blog/mtrnords-photography-manager/c2pa-rs.git
Log | Files | Refs | README

commit 2cb845f1a9dc2a1faaebabb85eef78af09dc9800
parent 268e9e6b23bc0b25945bfc9e1e688bdc4cbcda37
Author: mauricefisher64 <92736594+mauricefisher64@users.noreply.github.com>
Date:   Mon, 10 Jul 2023 21:54:41 -0400

(MINOR) Embeddable manifest support (#266)

* Initial box hash support

* Full box hash implementation for JPG

* Box hash fixes

* More unit tests and safety checks

* Add API to get embeddable manifest

* Add missing test file

* Change timestamp message imprint to include entire protected header

* Format fixes

* Fix for mixing cbor and json in a single object

* Simplify fix to use existing code

* Rename test file to independent of other uses.

* Initial support for new external hashing support

* New external hashing support

* Switch new functions to be async

* Allow users to set data hash

* Disable unit test that is not ready to be enabled

* Adds manifest SDK support for box and data hashes
manifest.data_hash_placeholder()
manifest.data_hash_embeddable_manifest
manifest.box_hash_embeddable_manifest

* added manifest box hash unit test

---------

Co-authored-by: Gavin  Peacock <gpeacock@adobe.com>
Diffstat:
Msdk/src/asset_handlers/jpeg_io.rs | 139++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msdk/src/asset_handlers/png_io.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msdk/src/asset_handlers/tiff_io.rs | 15+++++++++++++--
Msdk/src/asset_io.rs | 15++++++++++++++-
Msdk/src/manifest.rs | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msdk/src/openssl/temp_signer.rs | 6+++---
Msdk/src/store.rs | 388+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msdk/src/utils/hash_utils.rs | 3++-
Msdk/src/utils/test.rs | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Asdk/tests/fixtures/boxhash.jpg | 0
10 files changed, 817 insertions(+), 77 deletions(-)

diff --git a/sdk/src/asset_handlers/jpeg_io.rs b/sdk/src/asset_handlers/jpeg_io.rs @@ -15,11 +15,11 @@ use std::{ collections::HashMap, convert::{From, TryFrom}, fs::File, - io::{BufReader, Cursor}, + io::{BufReader, Cursor, Write}, path::*, }; -use byteorder::{BigEndian, ReadBytesExt}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use img_parts::{ jpeg::{ markers::{self, P, RST0, RST7, Z}, @@ -33,8 +33,8 @@ use tempfile::Builder; use crate::{ assertions::{BoxMap, C2PA_BOXHASH}, asset_io::{ - AssetBoxHash, AssetIO, CAIRead, CAIReadWrite, CAIReader, CAIWriter, HashBlockObjectType, - HashObjectPositions, RemoteRefEmbed, RemoteRefEmbedType, + AssetBoxHash, AssetIO, CAIRead, CAIReadWrite, CAIReader, CAIWriter, ComposedManifestRef, + HashBlockObjectType, HashObjectPositions, RemoteRefEmbed, RemoteRefEmbedType, }, error::{Error, Result}, }; @@ -541,6 +541,10 @@ impl AssetIO for JpegIO { Some(self) } + fn composed_data_ref(&self) -> Option<&dyn ComposedManifestRef> { + Some(self) + } + fn supported_types(&self) -> &[&str] { &SUPPORTED_TYPES } @@ -832,10 +836,82 @@ impl AssetBoxHash for JpegIO { } } +impl ComposedManifestRef for JpegIO { + fn compose_manifest(&self, manifest_data: &[u8], _format: &str) -> Result<Vec<u8>> { + let jumbf_len = manifest_data.len(); + let num_segments = (jumbf_len / MAX_JPEG_MARKER_SIZE) + 1; + let mut seg_chucks = manifest_data.chunks(MAX_JPEG_MARKER_SIZE); + + let mut segments = Vec::new(); + + for seg in 1..num_segments + 1 { + /* + If the size of the box payload is less than 2^32-8 bytes, + then all fields except the XLBox field, that is: Le, CI, En, Z, LBox and TBox, + shall be present in all JPEG XT marker segment representing this box, + regardless of whether the marker segments starts this box, + or continues a box started by a former JPEG XT Marker segment. + */ + // we need to prefix the JUMBF with the JPEG XT markers (ISO 19566-5) + // CI: JPEG extensions marker - JP + // En: Box Instance Number - 0x0001 + // (NOTE: can be any unique ID, so we pick one that shouldn't conflict) + // Z: Packet sequence number - 0x00000001... + let ci = vec![0x4a, 0x50]; + let en = vec![0x02, 0x11]; + let z: u32 = u32::try_from(seg) + .map_err(|_| Error::InvalidAsset("Too many JUMBF segments".to_string()))?; //seg.to_be_bytes(); + + let mut seg_data = Vec::new(); + seg_data.extend(ci); + seg_data.extend(en); + seg_data.extend(z.to_be_bytes()); + if seg > 1 { + // the LBox and TBox are already in the JUMBF + // but we need to duplicate them in all other segments + let lbox_tbox = &manifest_data[..8]; + seg_data.extend(lbox_tbox); + } + if seg_chucks.len() > 0 { + // make sure we have some... + if let Some(next_seg) = seg_chucks.next() { + seg_data.extend(next_seg); + } + } else { + seg_data.extend(manifest_data); + } + + let seg_bytes = Bytes::from(seg_data); + let app11_segment = JpegSegment::new_with_contents(markers::APP11, seg_bytes); + segments.push(app11_segment); + } + + let output = Vec::with_capacity(manifest_data.len() * 2); + let mut out_stream = Cursor::new(output); + + // right out segments + for s in segments { + // maker + out_stream.write_u8(markers::P)?; + out_stream.write_u8(s.marker())?; + + //len + out_stream.write_u16::<BigEndian>(s.contents().len() as u16 + 2)?; + + // data + out_stream.write_all(s.contents())?; + } + + Ok(out_stream.into_inner()) + } +} + #[cfg(test)] pub mod tests { #![allow(clippy::unwrap_used)] + use std::io::{Read, Seek}; + use img_parts::Bytes; use super::*; @@ -933,4 +1009,59 @@ pub mod tests { assert!(read_xmp.contains(test_msg)); } + + #[test] + fn test_embeddable_manifest() { + let jpeg_io = JpegIO {}; + + let source = crate::utils::test::fixture_path("CA.jpg"); + + let ol = jpeg_io.get_object_locations(&source).unwrap(); + + let cai_loc = ol + .iter() + .find(|o| o.htype == HashBlockObjectType::Cai) + .unwrap(); + let curr_manifest = jpeg_io.read_cai_store(&source).unwrap(); + + let temp_dir = tempfile::tempdir().unwrap(); + let output = crate::utils::test::temp_dir_path(&temp_dir, "CA_test.jpg"); + + std::fs::copy(source, &output).unwrap(); + + // remove existing + jpeg_io.remove_cai_store(&output).unwrap(); + + // generate new manifest data + let em = jpeg_io + .composed_data_ref() + .unwrap() + .compose_manifest(&curr_manifest, "jpeg") + .unwrap(); + + // insert new manifest + let outbuf = Vec::new(); + let mut out_stream = Cursor::new(outbuf); + + let mut before = vec![0u8; cai_loc.offset]; + let mut in_file = std::fs::File::open(&output).unwrap(); + + // write before + in_file.read_exact(before.as_mut_slice()).unwrap(); + out_stream.write_all(&before).unwrap(); + + // write composed bytes + out_stream.write_all(&em).unwrap(); + + // write bytes after + let mut after_buf = Vec::new(); + in_file.read_to_end(&mut after_buf).unwrap(); + out_stream.write_all(&after_buf).unwrap(); + + // read manifest back in from new in-memory JPEG + out_stream.rewind().unwrap(); + let restored_manifest = jpeg_io.read_cai(&mut out_stream).unwrap(); + + assert_eq!(&curr_manifest, &restored_manifest); + } } diff --git a/sdk/src/asset_handlers/png_io.rs b/sdk/src/asset_handlers/png_io.rs @@ -24,8 +24,8 @@ use serde_bytes::ByteBuf; use crate::{ assertions::{BoxMap, C2PA_BOXHASH}, asset_io::{ - AssetBoxHash, AssetIO, CAIRead, CAIReadWrite, CAIReader, CAIWriter, HashBlockObjectType, - HashObjectPositions, RemoteRefEmbed, RemoteRefEmbedType, + AssetBoxHash, AssetIO, CAIRead, CAIReadWrite, CAIReader, CAIWriter, ComposedManifestRef, + HashBlockObjectType, HashObjectPositions, RemoteRefEmbed, RemoteRefEmbedType, }, error::{Error, Result}, }; @@ -565,6 +565,10 @@ impl AssetIO for PngIO { Some(self) } + fn composed_data_ref(&self) -> Option<&dyn ComposedManifestRef> { + Some(self) + } + fn supported_types(&self) -> &[&str] { &SUPPORTED_TYPES } @@ -654,11 +658,33 @@ impl AssetBoxHash for PngIO { } } +impl ComposedManifestRef for PngIO { + fn compose_manifest(&self, manifest_data: &[u8], _format: &str) -> Result<Vec<u8>> { + let mut cai_data = Vec::new(); + let mut cai_encoder = png_pong::Encoder::new(&mut cai_data).into_chunk_enc(); + + // create CAI store chunk + let cai_unknown = png_pong::chunk::Unknown { + name: CAI_CHUNK, + data: manifest_data.to_vec(), + }; + + let mut cai_chunk = png_pong::chunk::Chunk::Unknown(cai_unknown); + cai_encoder + .encode(&mut cai_chunk) + .map_err(|_| Error::EmbeddingError)?; + + Ok(cai_data) + } +} + #[cfg(test)] pub mod tests { #![allow(clippy::panic)] #![allow(clippy::unwrap_used)] + use std::io::Write; + use twoway::find_bytes; use super::*; @@ -850,4 +876,59 @@ pub mod tests { _ => unreachable!(), } } + + #[test] + fn test_embeddable_manifest() { + let png_io = PngIO {}; + + let source = crate::utils::test::fixture_path("exp-test1.png"); + + let ol = png_io.get_object_locations(&source).unwrap(); + + let cai_loc = ol + .iter() + .find(|o| o.htype == HashBlockObjectType::Cai) + .unwrap(); + let curr_manifest = png_io.read_cai_store(&source).unwrap(); + + let temp_dir = tempfile::tempdir().unwrap(); + let output = crate::utils::test::temp_dir_path(&temp_dir, "exp-test1-out.png"); + + std::fs::copy(source, &output).unwrap(); + + // remove existing + png_io.remove_cai_store(&output).unwrap(); + + // generate new manifest data + let em = png_io + .composed_data_ref() + .unwrap() + .compose_manifest(&curr_manifest, "png") + .unwrap(); + + // insert new manifest + let outbuf = Vec::new(); + let mut out_stream = Cursor::new(outbuf); + + let mut before = vec![0u8; cai_loc.offset]; + let mut in_file = std::fs::File::open(&output).unwrap(); + + // write before + in_file.read_exact(before.as_mut_slice()).unwrap(); + out_stream.write_all(&before).unwrap(); + + // write composed bytes + out_stream.write_all(&em).unwrap(); + + // write bytes after + let mut after_buf = Vec::new(); + in_file.read_to_end(&mut after_buf).unwrap(); + out_stream.write_all(&after_buf).unwrap(); + + // read manifest back in from new in-memory PNG + out_stream.rewind().unwrap(); + let restored_manifest = png_io.read_cai(&mut out_stream).unwrap(); + + assert_eq!(&curr_manifest, &restored_manifest); + } } diff --git a/sdk/src/asset_handlers/tiff_io.rs b/sdk/src/asset_handlers/tiff_io.rs @@ -26,8 +26,8 @@ use tempfile::Builder; use crate::{ asset_io::{ - AssetIO, AssetPatch, CAIRead, CAIReadWrite, CAIReader, HashBlockObjectType, - HashObjectPositions, RemoteRefEmbed, RemoteRefEmbedType, + AssetIO, AssetPatch, CAIRead, CAIReadWrite, CAIReader, ComposedManifestRef, + HashBlockObjectType, HashObjectPositions, RemoteRefEmbed, RemoteRefEmbedType, }, error::{Error, Result}, }; @@ -1486,6 +1486,10 @@ impl AssetIO for TiffIO { Some(self) } + fn composed_data_ref(&self) -> Option<&dyn ComposedManifestRef> { + Some(self) + } + fn supported_types(&self) -> &[&str] { &SUPPORTED_TYPES } @@ -1565,6 +1569,13 @@ impl RemoteRefEmbed for TiffIO { } } +impl ComposedManifestRef for TiffIO { + // Return entire CAI block as Vec<u8> + fn compose_manifest(&self, manifest_data: &[u8], _format: &str) -> Result<Vec<u8>> { + Ok(manifest_data.to_vec()) + } +} + #[cfg(test)] pub mod tests { #![allow(clippy::panic)] diff --git a/sdk/src/asset_io.rs b/sdk/src/asset_io.rs @@ -188,10 +188,15 @@ pub trait AssetIO: Sync + Send { None } - // Returns [`AssetBoxHah`] trait if this I/O handler supports box hashing. + // Returns [`AssetBoxHash`] trait if this I/O handler supports box hashing. fn asset_box_hash_ref(&self) -> Option<&dyn AssetBoxHash> { None } + + // Returns [`ComposedManifestRefEmbed`] trait if this I/O handler supports composed data. + fn composed_data_ref(&self) -> Option<&dyn ComposedManifestRef> { + None + } } // `AssetPatch` optimizes output generation for asset_io handlers that @@ -239,3 +244,11 @@ pub trait RemoteRefEmbed { embed_ref: RemoteRefEmbedType, ) -> Result<()>; } + +/// `ComposedManifestRefEmbed` is used to generate a C2PA manifest. The +/// returned `Vec<u8>` contains data preformatted to be directly compatible +/// with the type specified in `format`. +pub trait ComposedManifestRef { + // Return entire CAI block as Vec<u8> + fn compose_manifest(&self, manifest_data: &[u8], format: &str) -> Result<Vec<u8>>; +} diff --git a/sdk/src/manifest.rs b/sdk/src/manifest.rs @@ -22,19 +22,21 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; -#[cfg(feature = "file_io")] -use crate::AsyncSigner; use crate::{ assertion::{AssertionBase, AssertionData}, - assertions::{labels, Actions, CreativeWork, Exif, SoftwareAgent, Thumbnail, User, UserCbor}, + assertions::{ + labels, Actions, CreativeWork, DataHash, Exif, SoftwareAgent, Thumbnail, User, UserCbor, + }, asset_io::CAIRead, claim::{Claim, RemoteManifest}, error::{Error, Result}, + hash_utils::HashRange, jumbf, resource_store::{skip_serializing_resources, ResourceRef, ResourceStore}, salt::DefaultSalt, store::Store, - ClaimGeneratorInfo, Ingredient, ManifestAssertion, ManifestAssertionKind, RemoteSigner, Signer, + AsyncSigner, ClaimGeneratorInfo, Ingredient, ManifestAssertion, ManifestAssertionKind, + RemoteSigner, Signer, }; /// A Manifest represents all the information in a c2pa manifest @@ -1148,11 +1150,81 @@ impl Manifest { .await } + /// Removes any existing manifest from a file + /// + /// This should only be used for special cases, such as converting an embedded manifest + /// to a cloud manifest #[cfg(feature = "file_io")] pub fn remove_manifest<P: AsRef<Path>>(asset_path: P) -> Result<()> { use crate::jumbf_io::remove_jumbf_from_file; remove_jumbf_from_file(asset_path.as_ref()) } + + /// Generates a data hashed placeholder manifest for a file + /// + /// The return value is pre-formatted for insertion into a file of the given format + /// For JPEG it is a series of App11 JPEG segments containing space for a manifest + /// This is used to create a properly formatted file ready for signing + pub fn data_hash_placeholder( + &mut self, + signer: &dyn AsyncSigner, + format: &str, + ) -> Result<Vec<u8>> { + let dh: Result<DataHash> = self.find_assertion(DataHash::LABEL); + if dh.is_err() { + let mut ph = DataHash::new("jumbf manifest", "sha256"); + for _ in 0..10 { + ph.add_exclusion(HashRange::new(0, 2)); + } + let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + let mut stream = std::io::Cursor::new(data); + ph.gen_hash_from_stream(&mut stream)?; + self.add_assertion(&ph)?; + } + let mut store = self.to_store()?; + let placeholder = store.get_data_hashed_manifest_placeholder(signer, format)?; + Ok(placeholder) + } + + /// Generates an data hashed embeddable manifest for a file + /// + /// The return value is pre-formatted for insertion into a file of the given format + /// For JPEG it is a series of App11 JPEG segments containing a signed manifest + /// This can directly replace a placeholder manifest to create a properly signed asset + /// The data hash must contain exclusions and may contain pre-calculated hashes + /// if an asset reader is provided, it will be used to calculate the data hash + pub async fn data_hash_embeddable_manifest( + &mut self, + dh: &DataHash, + signer: &dyn AsyncSigner, + format: &str, + mut asset_reader: Option<&mut dyn CAIRead>, + ) -> Result<Vec<u8>> { + let mut store = self.to_store()?; + if let Some(asset_reader) = asset_reader.as_deref_mut() { + asset_reader.rewind()?; + } + let cm = store + .get_data_hashed_embeddable_manifest(dh, signer, format, asset_reader) + .await?; + Ok(cm) + } + + /// Generates a signed box hashed manifest, optionally preformatted for embedding + /// + /// The manifest must include a box hash assertion with correct hashes + pub async fn box_hash_embeddable_manifest( + &mut self, + signer: &dyn AsyncSigner, + format: Option<&str>, + ) -> Result<Vec<u8>> { + let mut store = self.to_store()?; + let mut cm = store.get_box_hashed_embeddable_manifest(signer).await?; + if let Some(format) = format { + cm = store.get_composed_manifest(&cm, format)?; + } + Ok(cm) + } } impl std::fmt::Display for Manifest { @@ -1191,20 +1263,24 @@ pub(crate) mod tests { #[cfg(target_arch = "wasm32")] wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + use crate::{ + assertions::{c2pa_action, Action, Actions}, + utils::test::{temp_signer, TEST_VC}, + Ingredient, Manifest, Result, + }; #[cfg(feature = "file_io")] use crate::{ - assertions::labels::ACTIONS, + assertions::{labels::ACTIONS, DataHash}, error::Error, + hash_utils::HashRange, resource_store::ResourceRef, status_tracker::{DetailedStatusTracker, StatusTracker}, store::Store, - utils::test::{fixture_path, temp_dir_path, temp_fixture_path, TEST_SMALL_JPEG}, - validation_status, - }; - use crate::{ - assertions::{c2pa_action, Action, Actions}, - utils::test::{temp_signer, TEST_VC}, - Ingredient, Manifest, Result, + utils::test::{ + fixture_path, temp_dir_path, temp_fixture_path, write_jpeg_placeholder_file, + TEST_SMALL_JPEG, + }, + validation_status, SigningAlg, }; // example of random data structure as an assertion @@ -2103,4 +2179,88 @@ pub(crate) mod tests { .embed(&output, &output, signer.as_ref()) .expect("embed"); } + + #[actix::test] + #[cfg(feature = "file_io")] + async fn test_data_hash_embeddable_manifest() { + let ap = fixture_path("cloud.jpg"); + + let signer = crate::openssl::temp_signer_async::AsyncSignerAdapter::new(SigningAlg::Ps256); + + let mut manifest = Manifest::new("claim_generator"); + + // get a placeholder the manifest + let placeholder = manifest.data_hash_placeholder(&signer, "jpeg").unwrap(); + + let temp_dir = tempfile::tempdir().unwrap(); + let output = temp_dir_path(&temp_dir, "boxhash-out.jpg"); + let mut output_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&output) + .unwrap(); + + // write a jpeg file with a placeholder for the manifest (returns offset of the placeholder) + let offset = + write_jpeg_placeholder_file(&placeholder, &ap, &mut output_file, None).unwrap(); + + // build manifest to insert in the hole + + // create an hash exclusion for the manifest + let exclusion = HashRange::new(offset, placeholder.len()); + let exclusions = vec![exclusion]; + + let mut dh = DataHash::new("source_hash", "sha256"); + dh.exclusions = Some(exclusions); + + let signed_manifest = manifest + .data_hash_embeddable_manifest(&dh, &signer, "image/jpeg", Some(&mut output_file)) + .await + .unwrap(); + + use std::io::{Seek, SeekFrom, Write}; + + // path in new composed manifest + output_file.seek(SeekFrom::Start(offset as u64)).unwrap(); + output_file.write_all(&signed_manifest).unwrap(); + + let manifest_store = crate::ManifestStore::from_file(&output).expect("from_file"); + println!("{manifest_store}"); + assert!(manifest_store.validation_status().is_none()); + } + + #[actix::test] + #[cfg(feature = "file_io")] + async fn test_box_hash_embedable_manifest() { + let asset_bytes = include_bytes!("../tests/fixtures/CA.jpg"); + let box_hash_data = include_bytes!("../tests/fixtures/boxhash.json"); + let box_hash: crate::assertions::BoxHash = serde_json::from_slice(box_hash_data).unwrap(); + + let mut manifest = Manifest::new("test_app".to_owned()); + manifest.set_title("BoxHashTest").set_format("image/jpeg"); + + manifest + .add_labeled_assertion(crate::assertions::labels::BOX_HASH, &box_hash) + .unwrap(); + + let signer = crate::openssl::temp_signer_async::AsyncSignerAdapter::new(SigningAlg::Ps256); + + let embeddable = manifest + .box_hash_embeddable_manifest(&signer, None) + .await + .expect("embeddable_manifest"); + + // Validate the embeddable manifest against the asset bytes + let manifest_store = crate::ManifestStore::from_manifest_and_asset_bytes_async( + &embeddable, + "image/jpeg", + asset_bytes, + ) + .await + .unwrap(); + assert!(!manifest_store.manifests().is_empty()); + assert!(manifest_store.validation_status().is_none()); + println!("{manifest_store}"); + } } diff --git a/sdk/src/openssl/temp_signer.rs b/sdk/src/openssl/temp_signer.rs @@ -68,7 +68,7 @@ pub fn get_ec_signer<P: AsRef<Path>>( match alg { SigningAlg::Es256 | SigningAlg::Es384 | SigningAlg::Es512 => (), _ => { - panic!("Unknown EC signer alg {:#?}", alg); + panic!("Unknown EC signer alg {alg:#?}"); } } @@ -111,7 +111,7 @@ pub fn get_ed_signer<P: AsRef<Path>>( tsa_url: Option<String>, ) -> (EdSigner, PathBuf) { if alg != SigningAlg::Ed25519 { - panic!("Unknown ED signer alg {:#?}", alg); + panic!("Unknown ED signer alg {alg:#?}"); } let mut sign_cert_path = path.as_ref().to_path_buf(); @@ -155,7 +155,7 @@ pub fn get_rsa_signer<P: AsRef<Path>>( match alg { SigningAlg::Ps256 | SigningAlg::Ps384 | SigningAlg::Ps512 => (), _ => { - panic!("Unknown RSA signer alg {:#?}", alg); + panic!("Unknown RSA signer alg {alg:#?}"); } } diff --git a/sdk/src/store.rs b/sdk/src/store.rs @@ -45,6 +45,7 @@ use crate::{ get_assetio_handler, load_jumbf_from_stream, object_locations_from_stream, save_jumbf_to_memory, save_jumbf_to_stream, }, + salt::DefaultSalt, status_tracker::{log_item, OneShotStatusTracker, StatusTracker}, utils::{ hash_utils::{hash256, HashRange}, @@ -1619,14 +1620,59 @@ impl Store { Ok(()) } - /// Returns a manifest suitible for direct embedding by a client. The - /// manfiest are only supported for cases when the client has provided - /// a content hash binding. Note, will not work for cases like BMFF where - /// the position of the content is also encoded. - pub fn get_embeddable_manifest(&mut self, signer: &dyn Signer) -> Result<Vec<u8>> { - let mut jumbf_bytes = self.to_jumbf_internal(signer.reserve_size())?; + /// This function is used to pre-generate a manifest with place holders for the final + /// DataHash and Manifest Signature. The DataHash will reserve space for at least 10 + /// Exclusion ranges. The Signature box reserved size is based on the size returned by + /// the Signer. This function is not needed when using Box Hash. This function is used + /// in conjunction with `get_data_hashed_embeddable_manifest`. The manifest returned + /// from `get_data_hashed_embeddable_manifest` will have a size that matches this function. + pub fn get_data_hashed_manifest_placeholder( + &mut self, + signer: &dyn AsyncSigner, + format: &str, + ) -> Result<Vec<u8>> { + let pc = self.provenance_claim_mut().ok_or(Error::ClaimEncoding)?; - let pc = self.provenance_claim().ok_or(Error::ClaimEncoding)?; + // if user did not supply a hash + if pc.hash_assertions().is_empty() { + // create placholder DataHash large enough for 10 Exclusions + let mut ph = DataHash::new("jumbf manifest", pc.alg()); + for _ in 0..10 { + ph.add_exclusion(HashRange::new(0, 2)); + } + let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + let mut stream = Cursor::new(data); + ph.gen_hash_from_stream(&mut stream)?; + + pc.add_assertion_with_salt(&ph, &DefaultSalt::default())?; + } + + let jumbf_bytes = self.to_jumbf_internal(signer.reserve_size())?; + + let composed = self.get_composed_manifest(&jumbf_bytes, format)?; + + Ok(composed) + } + + /// Returns a finalized, signed manifest. The manfiest are only supported + /// for cases when the client has provided a data hash content hash binding. Note, + /// this function will not work for cases like BMFF where the position + /// of the content is also encoded. This function is not compatible with + /// BMFF hash binding. If a BMFF data hash or box hash is detected that is + /// an error. The DataHash placeholder assertion will be adjusted to the contain + /// the correct values. If the asset_reader value is supplied it will also perform + /// the hash calulations, otherwise the function uses the caller supplied values. + /// It is an error if `get_data_hashed_manifest_placeholder` was not called first + /// as this call inserts the DataHash placeholder assertion to reserve space for the + /// actual hash values not required when using BoxHashes. + pub async fn get_data_hashed_embeddable_manifest( + &mut self, + dh: &DataHash, + signer: &dyn AsyncSigner, + format: &str, + asset_reader: Option<&mut dyn CAIRead>, + ) -> Result<Vec<u8>> { + let pc = self.provenance_claim_mut().ok_or(Error::ClaimEncoding)?; // make sure there are data hashes present before generating if pc.hash_assertions().is_empty() { @@ -1642,8 +1688,64 @@ impl Store { )); } + let mut adusted_dh = DataHash::new("jumbf manifest", pc.alg()); + adusted_dh.exclusions = dh.exclusions.clone(); + adusted_dh.hash = dh.hash.clone(); + + if let Some(reader) = asset_reader { + // calc hashes + adusted_dh.gen_hash_from_stream(reader)?; + } + + // update the placeholder hash + pc.update_data_hash(adusted_dh)?; + + // reborrow immuttable + let pc = self.provenance_claim().ok_or(Error::ClaimEncoding)?; + let mut jumbf_bytes = self.to_jumbf_internal(signer.reserve_size())?; + // sign contents - let sig = self.sign_claim(pc, signer, signer.reserve_size())?; + let sig = self + .sign_claim_async(pc, signer, signer.reserve_size()) + .await?; + let sig_placeholder = Store::sign_claim_placeholder(pc, signer.reserve_size()); + + if sig_placeholder.len() != sig.len() { + return Err(Error::CoseSigboxTooSmall); + } + + patch_bytes(&mut jumbf_bytes, &sig_placeholder, &sig) + .map_err(|_| Error::JumbfCreationError)?; + + self.get_composed_manifest(&jumbf_bytes, format) + } + + /// Returns a finalized, signed manifest. The client is required to have + /// included the necessary box hash assertion with the pregenerated hashes. + pub async fn get_box_hashed_embeddable_manifest( + &mut self, + signer: &dyn AsyncSigner, + ) -> Result<Vec<u8>> { + let pc = self.provenance_claim().ok_or(Error::ClaimEncoding)?; + + // make sure there is only one + if pc.hash_assertions().len() != 1 { + return Err(Error::BadParam( + "Claim must have exactly one hash binding assertion".to_string(), + )); + } + + // only allow box hash assertions to be present + if pc.box_hash_assertions().is_empty() { + return Err(Error::BadParam("Missing box hash assertion".to_string())); + } + + let mut jumbf_bytes = self.to_jumbf_internal(signer.reserve_size())?; + + // sign contents + let sig = self + .sign_claim_async(pc, signer, signer.reserve_size()) + .await?; let sig_placeholder = Store::sign_claim_placeholder(pc, signer.reserve_size()); if sig_placeholder.len() != sig.len() { @@ -1656,6 +1758,18 @@ impl Store { Ok(jumbf_bytes) } + /// Returns the supplied manifest composed to be directly compatibile with the desired format. + /// For example, if format is JPEG funtion will return the set of APP11 segments that contains + /// the manifest. Similarly for PNG it would be the PNG chunk complete with header and CRC. + pub fn get_composed_manifest(&self, manifest_bytes: &[u8], format: &str) -> Result<Vec<u8>> { + if let Some(h) = get_assetio_handler(format) { + if let Some(composed_data_handler) = h.composed_data_ref() { + return composed_data_handler.compose_manifest(manifest_bytes, format); + } + } + Err(Error::UnsupportedType) + } + /// Embed the claims store as jumbf into a stream. Updates XMP with provenance record. /// When called, the stream should contain an asset matching format. /// on return, the stream will contain the new manifest signed with signer @@ -1960,7 +2074,8 @@ impl Store { } // we will not do automatic hashing if we detect a box hash present - if pc.box_hash_assertions().is_empty() { + let mut needs_hashing = false; + if pc.hash_assertions().is_empty() { // 2) Get hash ranges if needed, do not generate for update manifests let mut hash_ranges = object_locations_from_stream(format, &mut intermediate_stream)?; let hashes: Vec<DataHash> = if pc.update_manifest() { @@ -1970,7 +2085,7 @@ impl Store { &mut intermediate_stream, pc.alg(), &mut hash_ranges, - false, + true, )? }; @@ -1982,6 +2097,7 @@ impl Store { pc.add_assertion(&hash)?; } + needs_hashing = true; } // 3) Generate in memory CAI jumbf block @@ -1995,10 +2111,9 @@ impl Store { // 4) determine final object locations and patch the asset hashes with correct offset // replace the source with correct asset hashes so that the claim hash will be correct - let pc = self.provenance_claim_mut().ok_or(Error::ClaimEncoding)?; + if needs_hashing { + let pc = self.provenance_claim_mut().ok_or(Error::ClaimEncoding)?; - // we will not do automatic hashing if we detect a box hash present - if pc.box_hash_assertions().is_empty() { // get the final hash ranges, but not for update manifests intermediate_stream.rewind()?; output_stream.rewind()?; @@ -2185,6 +2300,7 @@ impl Store { } } else { // we will not do automatic hashing if we detect a box hash present + let mut needs_hashing = false; if pc.box_hash_assertions().is_empty() { // 2) Get hash ranges if needed, do not generate for update manifests let mut hash_ranges = object_locations(&output_path)?; @@ -2202,6 +2318,7 @@ impl Store { pc.add_assertion(&hash)?; } + needs_hashing = true; } // 3) Generate in memory CAI jumbf block @@ -2214,8 +2331,9 @@ impl Store { // 4) determine final object locations and patch the asset hashes with correct offset // replace the source with correct asset hashes so that the claim hash will be correct // If box hash is present we don't do any other - let pc = self.provenance_claim_mut().ok_or(Error::ClaimEncoding)?; - if pc.box_hash_assertions().is_empty() { + if needs_hashing { + let pc = self.provenance_claim_mut().ok_or(Error::ClaimEncoding)?; + // get the final hash ranges, but not for update manifests let mut new_hash_ranges = object_locations(&output_path)?; let updated_hashes = if pc.update_manifest() { @@ -2759,6 +2877,9 @@ pub mod tests { #![allow(clippy::panic)] #![allow(clippy::unwrap_used)] + use std::io::Write; + + use sha2::{Digest, Sha256}; use tempfile::tempdir; use twoway::find_bytes; @@ -2772,9 +2893,11 @@ pub mod tests { }, status_tracker::*, utils::{ + hash_utils::Hasher, patch::patch_file, test::{ create_test_claim, fixture_path, temp_dir_path, temp_fixture_path, temp_signer, + write_jpeg_placeholder_file, }, }, AssertionJson, SigningAlg, @@ -3998,25 +4121,25 @@ pub mod tests { } /* - #[test] - fn test_bmff_fragments() { - let init_stream_path = fixture_path("dashinit.mp4"); - let segment_stream_path = fixture_path("dash1.m4s"); - - let init_stream = std::fs::read(init_stream_path).unwrap(); - let segment_stream = std::fs::read(segment_stream_path).unwrap(); - - let mut report = DetailedStatusTracker::new(); - let store = Store::load_fragment_from_memory( - "mp4", - &init_stream, - &segment_stream, - true, - &mut report, - ) - .expect("load_from_asset"); - println!("store = {store}"); - } + #[test] + fn test_bmff_fragments() { + let init_stream_path = fixture_path("dashinit.mp4"); + let segment_stream_path = fixture_path("dash1.m4s"); + + let init_stream = std::fs::read(init_stream_path).unwrap(); + let segment_stream = std::fs::read(segment_stream_path).unwrap(); + + let mut report = DetailedStatusTracker::new(); + let store = Store::load_fragment_from_memory( + "mp4", + &init_stream, + &segment_stream, + true, + &mut report, + ) + .expect("load_from_asset"); + println!("store = {store}"); + } */ #[test] @@ -4256,7 +4379,7 @@ pub mod tests { Ok(_store) => panic!("did not expect to have a store"), Err(e) => match e { Error::JumbfNotFound => {} - e => panic!("unexpected error: {}", e), + e => panic!("unexpected error: {e}"), }, } } @@ -4337,6 +4460,9 @@ pub mod tests { // read from new file let new_store = Store::load_from_asset(&op, true, &mut report).unwrap(); + let errors = report_split_errors(report.get_log_mut()); + assert!(errors.is_empty()); + // dump store and compare to original for claim in new_store.claims() { let _restored_json = claim @@ -4365,10 +4491,10 @@ pub mod tests { } } - #[test] - fn test_embeddable_manifest() { + #[actix::test] + async fn test_boxhash_embeddable_manifest() { // test adding to actual image - let ap = fixture_path("CA.jpg"); + let ap = fixture_path("boxhash.jpg"); let box_hash_path = fixture_path("boxhash.json"); // Create claims store. @@ -4386,23 +4512,187 @@ pub mod tests { store.commit_claim(claim).unwrap(); // Do we generate JUMBF? - let signer = temp_signer(); + let signer = crate::openssl::temp_signer_async::AsyncSignerAdapter::new(SigningAlg::Ps256); // get the embeddable manifest - let em = store.get_embeddable_manifest(signer.as_ref()).unwrap(); + let em = store + .get_box_hashed_embeddable_manifest(&signer) + .await + .unwrap(); + + // get composed version for embedding to JPEG + let cm = store.get_composed_manifest(&em, "jpg").unwrap(); + + // insert manifest into ouput asset + let jpeg_io = get_assetio_handler_from_path(&ap).unwrap(); + let ol = jpeg_io.get_object_locations(&ap).unwrap(); + + let cai_loc = ol + .iter() + .find(|o| o.htype == HashBlockObjectType::Cai) + .unwrap(); + + // remove any existing manifest + jpeg_io.read_cai_store(&ap).unwrap(); + + // build new asset in memory inserting new manifest + let outbuf = Vec::new(); + let mut out_stream = Cursor::new(outbuf); + let mut input_file = std::fs::File::open(&ap).unwrap(); + + // write before + let mut before = vec![0u8; cai_loc.offset]; + input_file.read_exact(before.as_mut_slice()).unwrap(); + out_stream.write_all(&before).unwrap(); + + // write composed bytes + out_stream.write_all(&cm).unwrap(); + + // write bytes after + let mut after_buf = Vec::new(); + input_file.read_to_end(&mut after_buf).unwrap(); + out_stream.write_all(&after_buf).unwrap(); + + // save to output file + let temp_dir = tempfile::tempdir().unwrap(); + let output = temp_dir_path(&temp_dir, "boxhash-out.jpg"); + let mut output_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&output) + .unwrap(); + output_file.write_all(&out_stream.into_inner()).unwrap(); let mut report = DetailedStatusTracker::new(); - let new_store = Store::from_jumbf(&em, &mut report).unwrap(); + let _new_store = Store::load_from_asset(&output, true, &mut report).unwrap(); - let pc = new_store.provenance_claim().unwrap(); - let bhp = get_assetio_handler_from_path(&ap) - .unwrap() - .asset_box_hash_ref() + let errors = report_split_errors(report.get_log_mut()); + assert!(errors.is_empty()); + } + + #[actix::test] + async fn test_datahash_embeddable_manifest() { + // test adding to actual image + let ap = fixture_path("cloud.jpg"); + + // Do we generate JUMBF? + let signer = crate::openssl::temp_signer_async::AsyncSignerAdapter::new(SigningAlg::Ps256); + + // Create claims store. + let mut store = Store::new(); + + // Create a new claim. + let claim = create_test_claim().unwrap(); + + store.commit_claim(claim).unwrap(); + + // get a placeholder the manifest + let placeholder = store + .get_data_hashed_manifest_placeholder(&signer, "jpeg") .unwrap(); - for h in pc.box_hash_assertions() { - let bh = BoxHash::from_assertion(h).unwrap(); - bh.verify_hash(&ap, None, bhp).unwrap(); - } + let temp_dir = tempfile::tempdir().unwrap(); + let output = temp_dir_path(&temp_dir, "boxhash-out.jpg"); + let mut output_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&output) + .unwrap(); + + // write a jpeg file with a placeholder for the manifest (returns offset of the placeholder) + let offset = + write_jpeg_placeholder_file(&placeholder, &ap, &mut output_file, None).unwrap(); + + // build manifest to insert in the hole + + // create an hash exclusion for the manifest + let exclusion = HashRange::new(offset, placeholder.len()); + let exclusions = vec![exclusion]; + + let mut dh = DataHash::new("source_hash", "sha256"); + dh.exclusions = Some(exclusions); + + // get the embeddable manifest, letting API do the hashing + output_file.rewind().unwrap(); + let cm = store + .get_data_hashed_embeddable_manifest(&dh, &signer, "jpeg", Some(&mut output_file)) + .await + .unwrap(); + + // path in new composed manifest + output_file.seek(SeekFrom::Start(offset as u64)).unwrap(); + output_file.write_all(&cm).unwrap(); + + let mut report = DetailedStatusTracker::new(); + let _new_store = Store::load_from_asset(&output, true, &mut report).unwrap(); + + let errors = report_split_errors(report.get_log_mut()); + assert!(errors.is_empty()); + } + + #[actix::test] + async fn test_datahash_embeddable_manifest_user_hashed() { + // test adding to actual image + let ap = fixture_path("cloud.jpg"); + + let mut hasher = Hasher::SHA256(Sha256::new()); + + // Do we generate JUMBF? + let signer = crate::openssl::temp_signer_async::AsyncSignerAdapter::new(SigningAlg::Ps256); + + // Create claims store. + let mut store = Store::new(); + + // Create a new claim. + let claim = create_test_claim().unwrap(); + + store.commit_claim(claim).unwrap(); + + // get a placeholder for the manifest + let placeholder = store + .get_data_hashed_manifest_placeholder(&signer, "jpeg") + .unwrap(); + + let temp_dir = tempfile::tempdir().unwrap(); + let output = temp_dir_path(&temp_dir, "boxhash-out.jpg"); + let mut output_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&output) + .unwrap(); + + // write a jpeg file with a placeholder for the manifest (returns offset of the placeholder) + let offset = + write_jpeg_placeholder_file(&placeholder, &ap, &mut output_file, Some(&mut hasher)) + .unwrap(); + + // create target data hash + // create an hash exclusion for the manifest + let exclusion = HashRange::new(offset, placeholder.len()); + let exclusions = vec![exclusion]; + + //input_file.rewind().unwrap(); + let mut dh = DataHash::new("source_hash", "sha256"); + dh.hash = Hasher::finalize(hasher); + dh.exclusions = Some(exclusions); + + // get the embeddable manifest, using user hashing + let cm = store + .get_data_hashed_embeddable_manifest(&dh, &signer, "jpeg", None) + .await + .unwrap(); + + // path in new composed manifest + output_file.seek(SeekFrom::Start(offset as u64)).unwrap(); + output_file.write_all(&cm).unwrap(); + + let mut report = DetailedStatusTracker::new(); + let _new_store = Store::load_from_asset(&output, true, &mut report).unwrap(); + + let errors = report_split_errors(report.get_log_mut()); + assert!(errors.is_empty()); } } diff --git a/sdk/src/utils/hash_utils.rs b/sdk/src/utils/hash_utils.rs @@ -272,7 +272,8 @@ where // merge standard ranges and BMFF V2 ranges into single list if !bmff_v2_starts.is_empty() { // remove any offset hashes that would be excluded - bmff_v2_starts.retain(|o| ranges.iter().any(|r| *o + 1 == r)); + let test_ranges = ranges.clone().into_smallvec(); + bmff_v2_starts.retain(|o| test_ranges.iter().any(|r| r.contains(&(*o + 1)))); // add in remaining BMFF V2 offsets for os in bmff_v2_starts.iter() { diff --git a/sdk/src/utils/test.rs b/sdk/src/utils/test.rs @@ -7,18 +7,21 @@ // 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 +// implied. See the LICENSE-MIT and LICENSE-APACHE files for thema // specific language governing permissions and limitations under // each license. #![allow(clippy::unwrap_used)] use std::path::PathBuf; +#[cfg(feature = "file_io")] +use std::{ + io::{Cursor, Read, Write}, + path::Path, +}; use tempfile::TempDir; -#[cfg(feature = "file_io")] -use crate::create_signer; use crate::{ assertions::{labels, Action, Actions, Ingredient, ReviewRating, SchemaDotOrg, Thumbnail}, claim::Claim, @@ -26,6 +29,11 @@ use crate::{ store::Store, Result, Signer, SigningAlg, }; +#[cfg(feature = "file_io")] +use crate::{ + asset_io::CAIReadWrite, create_signer, hash_utils::Hasher, + jumbf_io::get_assetio_handler_from_path, +}; #[cfg(feature = "openssl_sign")] use crate::{openssl::RsaSigner, signer::ConfigurableSigner}; @@ -212,6 +220,51 @@ pub fn temp_signer_file() -> RsaSigner { .expect("get_temp_signer") } +/// Utility to create a test file with a placeholder for a manifest +#[cfg(feature = "file_io")] +pub fn write_jpeg_placeholder_file( + placeholder: &[u8], + input: &Path, + output_file: &mut dyn CAIReadWrite, + mut hasher: Option<&mut Hasher>, +) -> Result<usize> { + // get where we will put the data + let mut f = std::fs::File::open(input).unwrap(); + let jpeg_io = get_assetio_handler_from_path(input).unwrap(); + let box_mapper = jpeg_io.asset_box_hash_ref().unwrap(); + let boxes = box_mapper.get_box_map(&mut f).unwrap(); + let sof = boxes.iter().find(|b| b.names[0] == "SOF0").unwrap(); + + // build new asset with hole for new manifest + let outbuf = Vec::new(); + let mut out_stream = Cursor::new(outbuf); + let mut input_file = std::fs::File::open(input).unwrap(); + + // write before + let mut before = vec![0u8; sof.range_start]; + input_file.read_exact(before.as_mut_slice()).unwrap(); + if let Some(hasher) = hasher.as_deref_mut() { + hasher.update(&before); + } + out_stream.write_all(&before).unwrap(); + + // write placeholder + out_stream.write_all(placeholder).unwrap(); + + // write bytes after + let mut after_buf = Vec::new(); + input_file.read_to_end(&mut after_buf).unwrap(); + if let Some(hasher) = hasher { + hasher.update(&after_buf); + } + out_stream.write_all(&after_buf).unwrap(); + + // save to output file + output_file.write_all(&out_stream.into_inner()).unwrap(); + + Ok(sof.range_start) +} + pub(crate) struct TestGoodSigner {} impl crate::Signer for TestGoodSigner { fn sign(&self, _data: &[u8]) -> Result<Vec<u8>> { diff --git a/sdk/tests/fixtures/boxhash.jpg b/sdk/tests/fixtures/boxhash.jpg Binary files differ.