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 8f1ae2e9f7a843ec32f9ae40e26f56031aa5a3f6
parent 4116c22ca09c10df7131dd7715b1372d024a74bc
Author: mauricefisher64 <92736594+mauricefisher64@users.noreply.github.com>
Date:   Thu, 23 Jun 2022 18:30:06 -0400

Initial BMFF support (#39)

* initial BMFF support

* Disable reading of BMFF for wasm

* clippy fixes

* PR review fixes

* cargo fmt

* Another clippy fix & wasm fix

* Cargo ffmt

* cliippy fixes

* Make sure we handle capitalized file extensions.  Allow for MOV and M4A writing

* Fix build issue.

* Minor cleanup, address PR comments

* fix formating

* cleanup some comments

* Proofreading doc comments

* Proofreading

* Proofreading

* Remove commented-out use statement

* Quick tweaks

* Exclude test fixtures from crates.io package

* Remove double space

* fix formatting

* Smaller video sample

* Reduced size of sample MP4.  Fixed typo for Co64 box processing.

* Restore limits on bmff tests

* clippy fixes

Co-authored-by: Eric Scouten <scouten@adobe.com>
Diffstat:
MREADME.md | 1+
Msdk/Cargo.toml | 3+++
Asdk/src/assertions/bmff_hash.rs | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msdk/src/assertions/mod.rs | 3+++
Asdk/src/asset_handlers/bmff_io.rs | 1141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msdk/src/asset_handlers/mod.rs | 1+
Msdk/src/claim.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msdk/src/jumbf_io.rs | 59++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msdk/src/store.rs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Asdk/tests/fixtures/video1.mp4 | 0
10 files changed, 1732 insertions(+), 55 deletions(-)

diff --git a/README.md b/README.md @@ -49,6 +49,7 @@ c2pa = "0.5.2" ## Crate features * `async_signer` enables signing via asynchronous services which require `async` support. +* `bmff` enables handling of BMFF file formats. Currently only MP4, M4A, and MOV are enabled for writing. * `file_io` enables manifest generation, signing via OpenSSL, and embedding manifests in various file formats. * `serialize_thumbnails` includes binary thumbnail data in the [Serde](https://serde.rs/) serialization output. * `xmp_write` enables updating XMP on embed with the `dcterms:provenance` field (requires [xmp_toolkit](https://crates.io/crates/xmp_toolkit)). diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml @@ -10,9 +10,11 @@ keywords = ["xmp", "metadata"] categories = ["api-bindings"] edition = "2018" rust-version = "1.58.0" +exclude = ["tests/fixtures"] [features] async_signer = ["async-trait"] +bmff = [] # Work in progress support for BMFF-based containers file_io = ["openssl"] serialize_thumbnails = [] xmp_write = ["xmp_toolkit"] @@ -26,6 +28,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] async-trait = { version = "0.1.48", optional = true } +atree = "0.5.2" base64 = "0.13.0" bcder = "0.6.0" blake3 = "1.0.0" diff --git a/sdk/src/assertions/bmff_hash.rs b/sdk/src/assertions/bmff_hash.rs @@ -0,0 +1,254 @@ +// 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. + +use std::{ + fs, + io::Cursor, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use serde_bytes::ByteBuf; + +use crate::{ + assertion::{Assertion, AssertionBase, AssertionCbor}, + assertions::labels, + asset_handlers::bmff_io::bmff_to_jumbf_exclusions, + cbor_types::UriT, + error::{wrap_io_err, Result}, + utils::hash_utils::{hash_by_alg, verify_by_alg}, + Error, +}; + +const ASSERTION_CREATION_VERSION: usize = 1; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct ExclusionsMap { + pub xpath: String, + pub length: Option<u32>, + pub data: Option<Vec<DataMap>>, + pub subset: Option<Vec<SubsetMap>>, + pub version: Option<u8>, + pub flags: Option<ByteBuf>, + pub exact: Option<bool>, +} + +impl ExclusionsMap { + pub fn new(xpath: String) -> Self { + ExclusionsMap { + xpath, + length: None, + data: None, + subset: None, + version: None, + flags: None, + exact: None, + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct MerkleMap { + #[serde(rename = "uniqueId")] + pub unique_id: u32, + + #[serde(rename = "localId")] + pub local_id: u32, + + pub count: u32, + + #[serde(skip_serializing_if = "Option::is_none")] + pub alg: Option<String>, + + #[serde(rename = "initHash")] + pub init_hash: Vec<u8>, + + pub hashes: Vec<ByteBuf>, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct DataMap { + pub offset: u32, + #[serde(with = "serde_bytes")] + pub value: Vec<u8>, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct SubsetMap { + pub offset: u32, + pub length: u32, +} + +/// Helper class to create BmffHash assertion. (These are auto-generated by the SDK.) +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct BmffHash { + exclusions: Vec<ExclusionsMap>, + + #[serde(skip_serializing_if = "Option::is_none")] + alg: Option<String>, + + #[serde(with = "serde_bytes")] + hash: Vec<u8>, + + #[serde(skip_serializing_if = "Option::is_none")] + merkle: Option<Vec<MerkleMap>>, + + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + url: Option<UriT>, + + #[serde(skip_deserializing, skip_serializing)] + pub path: PathBuf, +} + +impl BmffHash { + pub fn new(name: &str, alg: &str, url: Option<UriT>) -> Self { + BmffHash { + exclusions: Vec::new(), + alg: Some(alg.to_string()), + hash: Vec::new(), + merkle: None, + name: Some(name.to_string()), + url, + path: PathBuf::new(), + } + } + + /// Label prefix for a BMFF hash assertion. + /// + /// See <https://c2pa.org/specifications/specifications/1.0/specs/C2PA_Specification.html#_bmff_based_hash>. + pub const LABEL: &'static str = labels::BMFF_HASH; + + pub fn exclusions(&self) -> &[ExclusionsMap] { + self.exclusions.as_ref() + } + + pub fn exclusions_mut(&mut self) -> &mut Vec<ExclusionsMap> { + &mut self.exclusions + } + + pub fn alg(&self) -> Option<&String> { + self.alg.as_ref() + } + + pub fn hash(&self) -> &[u8] { + self.hash.as_ref() + } + + pub fn set_hash(&mut self, hash: Vec<u8>) { + self.hash = hash; + } + + pub fn name(&self) -> Option<&String> { + self.name.as_ref() + } + + pub fn url(&self) -> Option<&UriT> { + self.url.as_ref() + } + + /// Returns `true` if this is a remote hash. + pub fn is_remote_hash(&self) -> bool { + self.url.is_some() + } + + pub fn set_merkle(&mut self, merkle: Vec<MerkleMap>) { + self.merkle = Some(merkle); + } + + /// Generate the hash value for the asset using the range from the BmffHash. + pub fn gen_hash(&mut self, asset_path: &Path) -> Result<()> { + self.hash = self.hash_from_asset(asset_path)?; + self.path = PathBuf::from(asset_path); + Ok(()) + } + + /// Generate the hash again. + pub fn regen_hash(&mut self) -> Result<()> { + let p = self.path.clone(); + self.hash = self.hash_from_asset(p.as_path())?; + Ok(()) + } + + /// Generate the asset hash from a file asset using the constructed + /// start and length values. + fn hash_from_asset(&mut self, asset_path: &Path) -> Result<Vec<u8>> { + if self.is_remote_hash() { + return Err(Error::BadParam( + "asset hash is remote, not yet supported".to_owned(), + )); + } + + let mut data = fs::read(asset_path).map_err(wrap_io_err)?; + let mut data_reader = Cursor::new(data); + + let alg = match self.alg { + Some(ref a) => a.clone(), + None => "sha256".to_string(), + }; + + let bmff_exclusions = &self.exclusions; + + // convert BMFF exclusion map to flat exclusion list + let exclusions = bmff_to_jumbf_exclusions(&mut data_reader, bmff_exclusions)?; + + data = data_reader.into_inner(); // back to buffer + let hash = hash_by_alg(&alg, &data, Some(exclusions)); + + if hash.is_empty() { + Err(Error::BadParam("could not generate data hash".to_string())) + } else { + Ok(hash) + } + } + + pub fn verify_in_memory_hash(&self, data: &[u8], alg: Option<String>) -> Result<()> { + let curr_alg = match alg { + Some(a) => a, + None => match self.alg { + Some(ref a) => a.clone(), + None => "sha256".to_string(), + }, + }; + + let bmff_exclusions = &self.exclusions; + + let mut data_reader = Cursor::new(data); + + // convert BMFF exclusion map to flat exclusion list + let exclusions = bmff_to_jumbf_exclusions(&mut data_reader, bmff_exclusions)?; + + if verify_by_alg(&curr_alg, &self.hash, data, Some(exclusions)) { + Ok(()) + } else { + Err(Error::HashMismatch("Hashes do not match".to_owned())) + } + } +} + +impl AssertionCbor for BmffHash {} + +impl AssertionBase for BmffHash { + const LABEL: &'static str = Self::LABEL; + const VERSION: Option<usize> = Some(ASSERTION_CREATION_VERSION); + + fn to_assertion(&self) -> Result<Assertion> { + Self::to_cbor_assertion(self) + } + + fn from_assertion(assertion: &Assertion) -> Result<Self> { + Self::from_cbor_assertion(assertion) + } +} diff --git a/sdk/src/assertions/mod.rs b/sdk/src/assertions/mod.rs @@ -16,6 +16,9 @@ mod actions; pub use actions::*; +mod bmff_hash; +pub use bmff_hash::{BmffHash, DataMap, ExclusionsMap, SubsetMap}; + #[allow(dead_code)] // will become public later mod data_hash; pub(crate) use data_hash::DataHash; diff --git a/sdk/src/asset_handlers/bmff_io.rs b/sdk/src/asset_handlers/bmff_io.rs @@ -0,0 +1,1141 @@ +// 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. + +use std::collections::HashMap; +use std::convert::From; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use conv::ValueFrom; +use serde::{Deserialize, Serialize}; +use serde_bytes::ByteBuf; + +use atree::{Arena, Token}; +use tempfile::{Builder, NamedTempFile}; + +use crate::assertions::ExclusionsMap; +use crate::asset_io::{AssetIO, CAILoader, CAIRead, HashObjectPositions}; +use crate::error::{Error, Result}; +use crate::utils::hash_utils::{vec_compare, Exclusion}; + +pub struct BmffIO { + #[allow(dead_code)] + bmff_format: String, // can be used for specialized BMFF cases +} +impl BmffIO { + pub fn new(bmff_format: &str) -> Self { + BmffIO { + bmff_format: bmff_format.to_string(), + } + } +} + +const HEADER_SIZE: u64 = 8; +const HEADER_EXT_SIZE: u64 = 4; + +const C2PA_UUID: [u8; 16] = [ + 0xD8, 0xFE, 0xC3, 0xD6, 0x1B, 0x0E, 0x48, 0x3C, 0x92, 0x97, 0x58, 0x28, 0x87, 0x7E, 0xC4, 0x81, +]; +const MANIFEST: &str = "manifest"; +const MERKLE: &str = "merkle"; + +// ISO IEC 14496-12_2022 FullBoxes +const FULL_BOX_TYPES: &[&str; 80] = &[ + "pdin", "mvhd", "tkhd", "mdhd", "hdlr", "nmhd", "elng", "stsd", "stdp", "stts", "ctts", "cslg", + "stss", "stsh", "stdp", "elst", "dref", "stsz", "stz2", "stsc", "stco", "co64", "padb", "subs", + "saiz", "saio", "mehd", "trex", "mfhd", "tfhd", "trun", "tfra", "mfro", "tfdt", "leva", "trep", + "assp", "sbgp", "sgpd", "csgp", "cprt", "tsel", "kind", "meta", "xml ", "bxml", "iloc", "pitm", + "ipro", "infe", "iinf", "iref", "ipma", "schm", "fiin", "fpar", "fecr", "gitn", "fire", "stri", + "stsg", "stvi", "csch", "sidx", "ssix", "prft", "srpp", "vmhd", "smhd", "srat", "chnl", "dmix", + "txtC", "mime", "uri ", "uriI", "hmhd", "sthd", "vvhd", "medc", +]; + +// define CAIRead for tempfile +impl CAIRead for NamedTempFile {} + +macro_rules! boxtype { + ($( $name:ident => $value:expr ),*) => { + #[derive(Clone, Copy, Debug, PartialEq)] + pub enum BoxType { + $( $name, )* + UnknownBox(u32), + } + + impl From<u32> for BoxType { + fn from(t: u32) -> BoxType { + match t { + $( $value => BoxType::$name, )* + _ => BoxType::UnknownBox(t), + } + } + } + + impl From<BoxType> for u32 { + fn from(t: BoxType) -> u32 { + match t { + $( BoxType::$name => $value, )* + BoxType::UnknownBox(t) => t, + } + } + } + } +} + +boxtype! { + Empty => 0x0000_0000, + UuidBox => 0x75756964, + FtypBox => 0x66747970, + MvhdBox => 0x6d766864, + MfhdBox => 0x6d666864, + FreeBox => 0x66726565, + MdatBox => 0x6d646174, + MoovBox => 0x6d6f6f76, + MvexBox => 0x6d766578, + MehdBox => 0x6d656864, + TrexBox => 0x74726578, + EmsgBox => 0x656d7367, + MoofBox => 0x6d6f6f66, + TkhdBox => 0x746b6864, + TfhdBox => 0x74666864, + EdtsBox => 0x65647473, + MdiaBox => 0x6d646961, + ElstBox => 0x656c7374, + MdhdBox => 0x6d646864, + HdlrBox => 0x68646c72, + MinfBox => 0x6d696e66, + VmhdBox => 0x766d6864, + StblBox => 0x7374626c, + StsdBox => 0x73747364, + SttsBox => 0x73747473, + CttsBox => 0x63747473, + StssBox => 0x73747373, + StscBox => 0x73747363, + StszBox => 0x7374737A, + StcoBox => 0x7374636F, + Co64Box => 0x636F3634, + TrakBox => 0x7472616b, + TrafBox => 0x74726166, + TrunBox => 0x7472756E, + UdtaBox => 0x75647461, + DinfBox => 0x64696e66, + DrefBox => 0x64726566, + UrlBox => 0x75726C20, + SmhdBox => 0x736d6864, + Avc1Box => 0x61766331, + AvcCBox => 0x61766343, + Hev1Box => 0x68657631, + HvcCBox => 0x68766343, + Mp4aBox => 0x6d703461, + EsdsBox => 0x65736473, + Tx3gBox => 0x74783367, + VpccBox => 0x76706343, + Vp09Box => 0x76703039, + MetaBox => 0x6D657461 +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct BmffMerkleMap { + #[serde(rename = "uniqueId")] + unique_id: u32, + #[serde(rename = "localId")] + local_id: u32, + location: u32, + hashes: Option<Vec<ByteBuf>>, +} + +struct BoxHeaderLite { + pub name: BoxType, + pub size: u64, + pub fourcc: String, +} + +impl BoxHeaderLite { + pub fn new(name: BoxType, size: u64, fourcc: &str) -> Self { + Self { + name, + size, + fourcc: fourcc.to_string(), + } + } + pub fn read<R: Read + ?Sized>(reader: &mut R) -> Result<Self> { + // Create and read to buf. + let mut buf = [0u8; 8]; // 8 bytes for box header. + reader.read_exact(&mut buf)?; + + // Get size. + let mut s = [0u8; 4]; + s.clone_from_slice(&buf[0..4]); + let size = u32::from_be_bytes(s); + + // Get box type string. + let mut t = [0u8; 4]; + t.clone_from_slice(&buf[4..8]); + let fourcc = String::from_utf8(buf[4..8].to_vec()) + .map_err(|_err| Error::BadParam("value out of range".to_string()))?; + let typ = u32::from_be_bytes(t); + + // Get largesize if size is 1 + if size == 1 { + reader.read_exact(&mut buf)?; + let largesize = u64::from_be_bytes(buf); + + Ok(BoxHeaderLite { + name: BoxType::from(typ), + size: largesize - HEADER_SIZE, + fourcc, + }) + } else { + Ok(BoxHeaderLite { + name: BoxType::from(typ), + size: size as u64, + fourcc, + }) + } + } + + pub fn write<W: Write>(&self, writer: &mut W) -> Result<u64> { + if self.size > u32::MAX as u64 { + writer.write_u32::<BigEndian>(1)?; + writer.write_u32::<BigEndian>(self.name.into())?; + writer.write_u64::<BigEndian>(self.size)?; + Ok(16) + } else { + writer.write_u32::<BigEndian>(self.size as u32)?; + writer.write_u32::<BigEndian>(self.name.into())?; + Ok(8) + } + } +} + +fn write_box_uuid_extension<W: Write>(w: &mut W, uuid: &[u8; 16]) -> Result<u64> { + w.write_all(uuid)?; + Ok(16) +} + +#[derive(Debug, PartialEq)] +pub(crate) struct BoxInfo { + path: String, + parent: Option<Token>, + offset: u64, + size: u64, + box_type: BoxType, + user_type: Option<Vec<u8>>, + version: Option<u8>, + flags: Option<u32>, +} + +fn read_box_header_ext(reader: &mut dyn CAIRead) -> Result<(u8, u32)> { + let version = reader.read_u8()?; + let flags = reader.read_u24::<BigEndian>()?; + Ok((version, flags)) +} +fn write_box_header_ext<W: Write>(w: &mut W, v: u8, f: u32) -> Result<u64> { + w.write_u8(v)?; + w.write_u24::<BigEndian>(f)?; + Ok(4) +} + +fn box_start(reader: &mut dyn CAIRead) -> Result<u64> { + Ok(reader.seek(SeekFrom::Current(0))? - HEADER_SIZE) +} + +fn skip_bytes(reader: &mut dyn CAIRead, size: u64) -> Result<()> { + reader.seek(SeekFrom::Current(size as i64))?; + Ok(()) +} + +fn skip_bytes_to(reader: &mut dyn CAIRead, pos: u64) -> Result<u64> { + let pos = reader.seek(SeekFrom::Start(pos))?; + Ok(pos) +} + +fn _skip_box(reader: &mut dyn CAIRead, size: u64) -> Result<()> { + let start = box_start(reader)?; + skip_bytes_to(reader, start + size)?; + Ok(()) +} + +fn write_c2pa_box<W: Write>( + w: &mut W, + data: &[u8], + is_manifest: bool, + merkle_data: &[u8], +) -> Result<()> { + let purpose_size = if is_manifest { + MANIFEST.len() + 1 + } else { + MERKLE.len() + 1 + }; + let merkle_size = if is_manifest { 8 } else { merkle_data.len() }; + let size = 8 + 16 + 4 + purpose_size + merkle_size + data.len(); // header + UUID + version/flags + data + zero terminated purpose + merkle data + let bh = BoxHeaderLite::new(BoxType::UuidBox, size as u64, "uuid"); + + // write out header + bh.write(w)?; + + // write out c2pa extension UUID + write_box_uuid_extension(w, &C2PA_UUID)?; + + // write out version and flags + let version: u8 = 0; + let flags: u32 = 0; + write_box_header_ext(w, version, flags)?; + + // write purpose + if is_manifest { + w.write_all(MANIFEST.as_bytes())?; + w.write_u8(0)?; + + // write no merkle flag + w.write_u64::<BigEndian>(0)?; + } else { + w.write_all(MERKLE.as_bytes())?; + w.write_u8(0)?; + + // write merkle cbor + w.write_all(merkle_data)?; + } + + // write out data + w.write_all(data)?; + + Ok(()) +} + +fn _write_free_box<W: Write>(w: &mut W, size: usize) -> Result<()> { + if size < 8 { + return Err(Error::BadParam("cannot adjust free space".to_string())); + } + + let zeros = vec![0u8; size - 8]; + let bh = BoxHeaderLite::new(BoxType::FreeBox, size as u64, "free"); + + // write out header + bh.write(w)?; + + // write out header + w.write_all(&zeros)?; + + Ok(()) +} + +fn add_token_to_cache(bmff_path_map: &mut HashMap<String, Vec<Token>>, path: String, token: Token) { + if let Some(token_list) = bmff_path_map.get_mut(&path) { + token_list.push(token); + } else { + let token_list = vec![token]; + bmff_path_map.insert(path, token_list); + } +} + +fn path_from_token(bmff_tree: &mut Arena<BoxInfo>, current_node_token: &Token) -> Result<String> { + let ancestors = current_node_token.ancestors(bmff_tree); + let mut path = bmff_tree[*current_node_token].data.path.clone(); + + for parent in ancestors { + path = format!("{}/{}", parent.data.path, path); + } + + if path.is_empty() { + path = "/".to_string(); + } + + Ok(path) +} + +pub fn bmff_to_jumbf_exclusions( + reader: &mut dyn CAIRead, + bmff_exclusions: &[ExclusionsMap], +) -> Result<Vec<Exclusion>> { + let start = reader.seek(SeekFrom::Current(0))?; + let size = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(start))?; + + // create root node + let root_box = BoxInfo { + path: "".to_string(), + offset: 0, + size, + box_type: BoxType::Empty, + parent: None, + user_type: None, + version: None, + flags: None, + }; + + let (mut bmff_tree, root_token) = Arena::with_data(root_box); + let mut bmff_map: HashMap<String, Vec<Token>> = HashMap::new(); + + // build layout of the BMFF structure + build_bmff_tree(reader, size, &mut bmff_tree, &root_token, &mut bmff_map)?; + + let mut exclusions = Vec::new(); + + for bmff_exclusion in bmff_exclusions { + if let Some(box_token_list) = bmff_map.get(&bmff_exclusion.xpath) { + for box_token in box_token_list { + let box_info = &bmff_tree[*box_token].data; + + let box_start = box_info.offset; + let box_length = box_info.size; + + let exclusion_start = box_start; + let exclusion_length = box_length; + + // adjust exclusion bounds as needed + + // check the length + if let Some(desired_length) = bmff_exclusion.length { + if desired_length as u64 != box_length { + continue; + } + } + + // check the version + if let Some(desired_version) = bmff_exclusion.version { + if let Some(box_version) = box_info.version { + if desired_version != box_version { + continue; + } + } + } + + // check the flags + if let Some(desired_flag_bytes) = &bmff_exclusion.flags { + let mut temp_bytes = [0u8; 4]; + if desired_flag_bytes.len() >= 3 { + temp_bytes[0] = desired_flag_bytes[0]; + temp_bytes[1] = desired_flag_bytes[1]; + temp_bytes[2] = desired_flag_bytes[2]; + } + let desired_flags = u32::from_be_bytes(temp_bytes); + + if let Some(box_flags) = box_info.flags { + let exact = if let Some(is_exact) = bmff_exclusion.exact { + is_exact + } else { + true + }; + + if exact { + if desired_flags != box_flags { + continue; + } + } else { + // bitwise match + if (desired_flags | box_flags) != desired_flags { + continue; + } + } + } + } + + // check data match + if let Some(data_map_vec) = &bmff_exclusion.data { + let mut should_add = true; + + for data_map in data_map_vec { + // move to the start of exclusion + skip_bytes_to(reader, box_start + data_map.offset as u64)?; + + // match the data + let mut buf = vec![0u8; data_map.value.len()]; + reader.read_exact(&mut buf)?; + + // does not match so skip + if !vec_compare(&data_map.value, &buf) { + should_add = false; + break; + } + } + if !should_add { + continue; + } + } + + // reduce range if desired + if let Some(subset_vec) = &bmff_exclusion.subset { + for subset in subset_vec { + let exclusion = Exclusion::new( + (exclusion_start + subset.offset as u64) as usize, + (if subset.length == 0 { + exclusion_length - subset.offset as u64 + } else { + subset.length as u64 + }) as usize, + ); + exclusions.push(exclusion); + } + } else { + let exclusion = + Exclusion::new(exclusion_start as usize, exclusion_length as usize); + exclusions.push(exclusion); + } + } + } + } + + Ok(exclusions) +} + +// `stco` and `co64` elements contain absolute file offsets so they need to be adjusted based on whether content was added or removed. +fn adjust_stco_and_co64<W: Write + CAIRead>( + output: &mut W, + bmff_tree: &Arena<BoxInfo>, + bmff_path_map: &HashMap<String, Vec<Token>>, + adjust: i32, +) -> Result<()> { + let start_pos = output.seek(SeekFrom::Current(0))?; // save starting point + + // handle 32 bit offsets + if let Some(stco_list) = bmff_path_map.get("/moov/trak/mdia/minf/stbl/stco") { + for stco_token in stco_list { + let stco_box_info = &bmff_tree[*stco_token].data; + if stco_box_info.box_type != BoxType::StcoBox { + return Err(Error::BadParam("Bad BMFF".to_string())); + } + + // read stco box and patch + output.seek(SeekFrom::Start(stco_box_info.offset))?; + + // read header + let header = BoxHeaderLite::read(output) + .map_err(|_err| Error::BadParam("Bad BMFF".to_string()))?; + if header.name != BoxType::StcoBox { + return Err(Error::BadParam("Bad BMFF".to_string())); + } + + // read extended header + let (_version, _flags) = read_box_header_ext(output)?; // box extensions + + // get count of offsets + let entry_count = output.read_u32::<BigEndian>()?; + + // read and patch offsets + let entry_start_pos = output.seek(SeekFrom::Current(0))?; + let mut entries: Vec<u32> = Vec::new(); + for _e in 0..entry_count { + let offset = output.read_u32::<BigEndian>()?; + let new_offset = if adjust < 0 { + offset - adjust.abs() as u32 + } else { + offset + adjust as u32 + }; + entries.push(new_offset); + } + + // write updated offsets + output.seek(SeekFrom::Start(entry_start_pos))?; + for e in entries { + output.write_u32::<BigEndian>(e)?; + } + } + } + + // handle 64 offsets + if let Some(co64_list) = bmff_path_map.get("/moov/trak/mdia/minf/stbl/co64") { + for co64_token in co64_list { + let co64_box_info = &bmff_tree[*co64_token].data; + if co64_box_info.box_type != BoxType::Co64Box { + return Err(Error::BadParam("Bad BMFF".to_string())); + } + + // read co64 box and patch + output.seek(SeekFrom::Start(co64_box_info.offset))?; + + // read header + let header = BoxHeaderLite::read(output) + .map_err(|_err| Error::BadParam("Bad BMFF".to_string()))?; + if header.name != BoxType::Co64Box { + return Err(Error::BadParam("Bad BMFF".to_string())); + } + + // read extended header + let (_version, _flags) = read_box_header_ext(output)?; // box extensions + + // get count of offsets + let entry_count = output.read_u32::<BigEndian>()?; + + // read and patch offsets + let entry_start_pos = output.seek(SeekFrom::Current(0))?; + let mut entries: Vec<u64> = Vec::new(); + for _e in 0..entry_count { + let offset = output.read_u64::<BigEndian>()?; + let new_offset = if adjust < 0 { + offset - adjust.abs() as u64 + } else { + offset + adjust as u64 + }; + entries.push(new_offset); + } + + // write updated offsets + output.seek(SeekFrom::Start(entry_start_pos))?; + for e in entries { + output.write_u64::<BigEndian>(e)?; + } + } + } + + // restore seek point + output.seek(SeekFrom::Start(start_pos))?; + output.flush()?; + + Ok(()) +} + +pub(crate) fn build_bmff_tree( + reader: &mut dyn CAIRead, + end: u64, + bmff_tree: &mut Arena<BoxInfo>, + current_node: &Token, + bmff_path_map: &mut HashMap<String, Vec<Token>>, +) -> Result<()> { + let start = reader.seek(SeekFrom::Current(0))?; + + let mut current = start; + while current < end { + // Get box header. + let header = + BoxHeaderLite::read(reader).map_err(|_err| Error::BadParam("Bad BMFF".to_string()))?; + + // Break if size zero BoxHeader + let s = header.size; + if s == 0 { + break; + } + + // Match and parse the supported atom boxes. + match header.name { + BoxType::UuidBox => { + let start = box_start(reader)?; + + let mut extended_type = [0u8; 16]; // 16 bytes of UUID + reader.read_exact(&mut extended_type)?; + + let (version, flags) = read_box_header_ext(reader)?; + + let b = BoxInfo { + path: header.fourcc.clone(), + offset: start, + size: s, + box_type: BoxType::UuidBox, + parent: Some(*current_node), + user_type: Some(extended_type.to_vec()), + version: Some(version), + flags: Some(flags), + }; + + let new_token = current_node.append(bmff_tree, b); + + let path = path_from_token(bmff_tree, &new_token)?; + add_token_to_cache(bmff_path_map, path, new_token); + + // position seek pointer + skip_bytes_to(reader, start + s)?; + } + // container box types + BoxType::MoovBox + | BoxType::TrakBox + | BoxType::MdiaBox + | BoxType::MinfBox + | BoxType::StblBox + | BoxType::MoofBox + | BoxType::TrafBox => { + let start = box_start(reader)?; + + let b = BoxInfo { + path: header.fourcc.clone(), + offset: start, + size: s, + box_type: header.name, + parent: Some(*current_node), + user_type: None, + version: None, + flags: None, + }; + + let new_token = bmff_tree.new_node(b); + current_node + .append_node(bmff_tree, new_token) + .map_err(|_err| Error::BadParam("Bad BMFF Graph".to_string()))?; + + let path = path_from_token(bmff_tree, &new_token)?; + add_token_to_cache(bmff_path_map, path, new_token); + + // consume all sub-boxes + let mut current = reader.seek(SeekFrom::Current(0))?; + let end = start + s; + while current < end { + build_bmff_tree(reader, end, bmff_tree, &new_token, bmff_path_map)?; + current = reader.seek(SeekFrom::Current(0))?; + } + + // position seek pointer + skip_bytes_to(reader, start + s)?; + } + // handle the meta box since it is explicitly listed in the spec + BoxType::MetaBox => { + let start = box_start(reader)?; + + let b = BoxInfo { + path: header.fourcc.clone(), + offset: start, + size: s, + box_type: header.name, + parent: Some(*current_node), + user_type: None, + version: None, + flags: None, + }; + + let new_token = current_node.append(bmff_tree, b); + + let path = path_from_token(bmff_tree, &new_token)?; + add_token_to_cache(bmff_path_map, path, new_token); + + // parse 'hdlr' box + let _handler = { + let header = BoxHeaderLite::read(reader) + .map_err(|_err| Error::BadParam("Bad BMFF".to_string()))?; + + // Break if size zero BoxHeader, which can result in dead-loop. + let handler_size = header.size; + + let start = box_start(reader)?; + + let (_version, _flags) = read_box_header_ext(reader)?; + + let _pre_defined = reader.read_u32::<BigEndian>()?; // pre-defined + let _handler = reader.read_u32::<BigEndian>()?; + + skip_bytes(reader, 12)?; // reserved + + let buf_size = handler_size - HEADER_SIZE - HEADER_EXT_SIZE - 20 - 1; + let mut buf = vec![0u8; buf_size as usize]; + reader.read_exact(&mut buf)?; + + let handler_string = match String::from_utf8(buf) { + Ok(t) => { + if t.len() != buf_size as usize { + return Err(Error::BadParam( + "string size does not match buffer".to_string(), + )); + } + t + } + _ => String::from("null"), + }; + + skip_bytes_to(reader, start + handler_size)?; + + handler_string + }; + + // consume enclosed boxes + let end = current + s; + build_bmff_tree(reader, end, bmff_tree, &new_token, bmff_path_map)?; + + // position seek pointer + skip_bytes_to(reader, start + s)?; + } + _ => { + let start = reader.seek(SeekFrom::Current(0))? - HEADER_SIZE; + + let b = if FULL_BOX_TYPES.contains(&header.fourcc.as_str()) { + let (version, flags) = read_box_header_ext(reader)?; // box extensions + BoxInfo { + path: header.fourcc.clone(), + offset: start, + size: s, + box_type: header.name, + parent: Some(*current_node), + user_type: None, + version: Some(version), + flags: Some(flags), + } + } else { + BoxInfo { + path: header.fourcc.clone(), + offset: start, + size: s, + box_type: header.name, + parent: Some(*current_node), + user_type: None, + version: None, + flags: None, + } + }; + + let new_token = current_node.append(bmff_tree, b); + + let path = path_from_token(bmff_tree, &new_token)?; + add_token_to_cache(bmff_path_map, path, new_token); + + // position seek pointer + skip_bytes_to(reader, start + s)?; + } + } + current = reader.seek(SeekFrom::Current(0))?; + } + + Ok(()) +} + +impl CAILoader for BmffIO { + fn read_cai(&self, reader: &mut dyn CAIRead) -> Result<Vec<u8>> { + let start = reader.seek(SeekFrom::Current(0))?; + let size = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(start))?; + + // create root node + let root_box = BoxInfo { + path: "".to_string(), + offset: 0, + size, + box_type: BoxType::Empty, + parent: None, + user_type: None, + version: None, + flags: None, + }; + + let (mut bmff_tree, root_token) = Arena::with_data(root_box); + let mut bmff_map: HashMap<String, Vec<Token>> = HashMap::new(); + + // build layout of the BMFF structure + build_bmff_tree(reader, size, &mut bmff_tree, &root_token, &mut bmff_map)?; + + // grab top level (for now) C2PA box + if let Some(uuid_list) = bmff_map.get("/uuid") { + let uuid_token = &uuid_list[0]; + let box_info = &bmff_tree[*uuid_token]; + + // make sure it is UUID box + if box_info.data.box_type == BoxType::UuidBox { + if let Some(uuid) = &box_info.data.user_type { + // make sure it is a C2PA ContentProvenanceBox box + if vec_compare(&C2PA_UUID, uuid) { + let mut data_len = box_info.data.size - HEADER_SIZE - 16 /*UUID*/; + + // set reader to start of box contents + skip_bytes_to(reader, box_info.data.offset + HEADER_SIZE + 16)?; + + // Fullbox => 8 bits for version 24 bits for flags + let (_version, _flags) = read_box_header_ext(reader)?; + data_len -= 4; + + // get the purpose + let mut purpose = Vec::with_capacity(64); + loop { + let mut buf = [0; 1]; + reader.read_exact(&mut buf)?; + data_len -= 1; + if buf[0] == 0x00 { + break; + } else { + purpose.push(buf[0]); + } + } + + // is the purpose manifest? + if vec_compare(&purpose, MANIFEST.as_bytes()) { + // offset to first aux uuid with purpose merkle + let mut buf = [0u8; 8]; + reader.read_exact(&mut buf)?; + data_len -= 8; + + // offset to first aux uuid + let offset = u64::from_be_bytes(buf); + + // if no offset this contains the manifest + if offset == 0 { + let mut buf = vec![0u8; data_len as usize]; + reader.read_exact(&mut buf)?; + + return Ok(buf); + } else { + // handle aux uuids + let mut buf = vec![0u8; data_len as usize]; + reader.read_exact(&mut buf)?; + + let _mm: BmffMerkleMap = serde_cbor::from_slice(&buf)?; + } + } else if vec_compare(&purpose, MERKLE.as_bytes()) { + // handle merkle boxes not yet handled + return Err(Error::UnsupportedType); + } + } + } + } + } + + Err(Error::JumbfNotFound) + } + + // Get XMP block + fn read_xmp(&self, _asset_reader: &mut dyn CAIRead) -> Option<String> { + None + } +} + +impl AssetIO for BmffIO { + fn read_cai_store(&self, asset_path: &Path) -> Result<Vec<u8>> { + let mut f = File::open(asset_path)?; + self.read_cai(&mut f) + } + fn save_cai_store(&self, asset_path: &std::path::Path, store_bytes: &[u8]) -> Result<()> { + let mut input = File::open(asset_path)?; + let size = input.seek(SeekFrom::End(0))?; + input.seek(SeekFrom::Start(0))?; + + // create root node + let root_box = BoxInfo { + path: "".to_string(), + offset: 0, + size: size as u64, + box_type: BoxType::Empty, + parent: None, + user_type: None, + version: None, + flags: None, + }; + + let (mut bmff_tree, root_token) = Arena::with_data(root_box); + let mut bmff_map: HashMap<String, Vec<Token>> = HashMap::new(); + + // build layout of the BMFF structure + build_bmff_tree( + &mut input, + size as u64, + &mut bmff_tree, + &root_token, + &mut bmff_map, + )?; + + // get position to insert c2pa + let (c2pa_start, c2pa_length) = if let Some(uuid_tokens) = bmff_map.get("/uuid") { + let uuid_info = &bmff_tree[uuid_tokens[0]].data; + + // is this a C2PA manifest + let is_c2pa = if let Some(uuid) = &uuid_info.user_type { + // make sure it is a C2PA box + vec_compare(&C2PA_UUID, uuid) + } else { + false + }; + + if is_c2pa { + (uuid_info.offset, Some(uuid_info.size)) + } else { + (0, None) + } + } else { + // start after ftyp + let ftyp_token = bmff_map.get("/ftyp").ok_or(Error::UnsupportedType)?; // todo check ftyps to make sure we supprt any special format requirements + let ftyp_info = &bmff_tree[ftyp_token[0]].data; + ((ftyp_info.offset + ftyp_info.size), None) + }; + + let mut new_c2pa_box: Vec<u8> = Vec::with_capacity(store_bytes.len() * 2); + let merkle_data: &[u8] = &[]; // not yet supported + write_c2pa_box(&mut new_c2pa_box, store_bytes, true, merkle_data)?; + let new_c2pa_box_size = new_c2pa_box.len(); + + let mut temp_file = Builder::new() + .prefix("c2pa_temp") + .rand_bytes(5) + .tempfile()?; + + let (start, end) = if let Some(c2pa_length) = c2pa_length { + let start = usize::value_from(c2pa_start) + .map_err(|_err| Error::BadParam("value out of range".to_string()))?; // get beginning of chunk which starts 4 bytes before label + + let end = usize::value_from(c2pa_start + c2pa_length) + .map_err(|_err| Error::BadParam("value out of range".to_string()))?; + + (start, end) + } else { + // insert new c2pa + let end = usize::value_from(c2pa_start) + .map_err(|_err| Error::BadParam("value out of range".to_string()))?; + + (end, end) + }; + + // write content before ContentProvenanceBox + input.seek(SeekFrom::Start(0))?; + let mut b = vec![0u8; start]; + input.read_exact(&mut b)?; + temp_file.write_all(&b)?; + + // write ContentProvenanceBox + temp_file.write_all(&new_c2pa_box)?; + + // calc offset adjustments + let offset_adjust: i32 = if end == 0 { + new_c2pa_box_size as i32 + } else { + // value could be negative is box is truncated + let existing_c2pa_box_size = end - start; + let pad_size: i32 = new_c2pa_box_size as i32 - existing_c2pa_box_size as i32; + pad_size + }; + + // write content after ContentProvenanceBox + input.seek(SeekFrom::Start(end as u64))?; + let mut chunk = vec![0u8; 1024 * 1024]; + loop { + let len = match input.read(&mut chunk) { + Ok(0) => break, + Ok(len) => len, + Err(e) => return Err(Error::IoError(e)), + }; + + temp_file.write_all(&chunk[0..len])?; + } + temp_file.flush()?; + + // Manipulating the UUID box means we may need some patch offsets if they are file absolute offsets. + match self.bmff_format.as_ref() { + "m4a" | "mp4" | "mov" => { + // create root node + let root_box = BoxInfo { + path: "".to_string(), + offset: 0, + size: size as u64, + box_type: BoxType::Empty, + parent: None, + user_type: None, + version: None, + flags: None, + }; + + // rebuild box layout for output file + let (mut output_bmff_tree, root_token) = Arena::with_data(root_box); + let mut output_bmff_map: HashMap<String, Vec<Token>> = HashMap::new(); + + let size = temp_file.seek(SeekFrom::End(0))?; + temp_file.seek(SeekFrom::Start(0))?; + build_bmff_tree( + &mut temp_file, + size as u64, + &mut output_bmff_tree, + &root_token, + &mut output_bmff_map, + )?; + + // adjust based on current layyout + adjust_stco_and_co64( + &mut temp_file, + &output_bmff_tree, + &output_bmff_map, + offset_adjust, + )?; + } + _ => (), // todo: handle more patching cases as necessary + } + + std::fs::copy(temp_file.path(), asset_path)?; + + Ok(()) + } + + fn get_object_locations( + &self, + _asset_path: &std::path::Path, + ) -> Result<Vec<HashObjectPositions>> { + let vec: Vec<HashObjectPositions> = Vec::new(); + Ok(vec) + } +} + +#[cfg(feature = "bmff")] +#[cfg(test)] +pub mod tests { + use tempfile::tempdir; + + use super::*; + use crate::{ + status_tracker::{report_split_errors, DetailedStatusTracker, StatusTracker}, + store::Store, + utils::test::{fixture_path, temp_dir_path}, + }; + + #[test] + fn test_read_mp4() { + let ap = fixture_path("video1.mp4"); + + let mut log = DetailedStatusTracker::default(); + let store = Store::load_from_asset(&ap, true, &mut log); + + let errors = report_split_errors(log.get_log_mut()); + assert!(errors.is_empty()); + + if let Ok(s) = store { + print!("Store: \n{}", s); + } + } + + #[test] + fn test_truncated_c2pa_write_mp4() { + let test_data = "some test data".as_bytes(); + let source = fixture_path("video1.mp4"); + + let mut success = false; + if let Ok(temp_dir) = tempdir() { + let output = temp_dir_path(&temp_dir, "mp4_test.mp4"); + + if let Ok(_size) = std::fs::copy(&source, &output) { + let bmff = BmffIO::new("mp4"); + + //let test_data = bmff.read_cai_store(&source).unwrap(); + if let Ok(()) = bmff.save_cai_store(&output, test_data) { + if let Ok(read_test_data) = bmff.read_cai_store(&output) { + assert!(vec_compare(test_data, &read_test_data)); + success = true; + } + } + } + } + assert!(success) + } + + #[test] + fn test_expanded_c2pa_write_mp4() { + let mut more_data = "some more test data".as_bytes().to_vec(); + let source = fixture_path("video1.mp4"); + + let mut success = false; + if let Ok(temp_dir) = tempdir() { + let output = temp_dir_path(&temp_dir, "mp4_test.mp4"); + + if let Ok(_size) = std::fs::copy(&source, &output) { + let bmff = BmffIO::new("mp4"); + + if let Ok(mut test_data) = bmff.read_cai_store(&source) { + test_data.append(&mut more_data); + if let Ok(()) = bmff.save_cai_store(&output, &test_data) { + if let Ok(read_test_data) = bmff.read_cai_store(&output) { + assert!(vec_compare(&test_data, &read_test_data)); + success = true; + } + } + } + } + } + assert!(success) + } +} diff --git a/sdk/src/asset_handlers/mod.rs b/sdk/src/asset_handlers/mod.rs @@ -11,6 +11,7 @@ // specific language governing permissions and limitations under // each license. +pub mod bmff_io; pub mod c2pa_io; pub mod jpeg_io; pub mod png_io; diff --git a/sdk/src/claim.rs b/sdk/src/claim.rs @@ -22,7 +22,7 @@ use crate::assertion::{ get_thumbnail_image_type, get_thumbnail_instance, get_thumbnail_type, Assertion, AssertionBase, AssertionData, }; -use crate::assertions::{self, labels, DataHash}; +use crate::assertions::{self, labels, BmffHash, DataHash}; use crate::cose_validator::{get_signing_info, verify_cose, verify_cose_async}; use crate::hashed_uri::HashedUri; use crate::jumbf::{ @@ -609,6 +609,46 @@ impl Claim { } } + // crate private function to allow for patching a BMFF hash with final contents + #[cfg(feature = "file_io")] + pub(crate) fn update_bmff_hash(&mut self, bmff_hash: BmffHash) -> Result<()> { + let replacement_assertion = bmff_hash.to_assertion()?; + + match self.assertion_store.iter_mut().find(|assertion| { + // is this a BMFFHash Assertion + Assertion::assertions_eq(&replacement_assertion, assertion.assertion()) + }) { + Some(ref mut bmff_assertion) => { + let original_hash = bmff_assertion.hash().to_vec(); + + let replacement_hash = Claim::calc_box_hash( + &bmff_assertion.label(), + &replacement_assertion, + bmff_assertion.salt().clone(), + bmff_assertion.hash_alg(), + )?; + bmff_assertion.update_assertion(replacement_assertion, replacement_hash)?; + + // fix up hashed uri + match self.assertions.iter_mut().find_map(|f| { + if f.url().contains(&bmff_assertion.label()) + && vec_compare(&f.hash(), &original_hash) + { + // replace with newly updated hash + f.update_hash(bmff_assertion.hash().to_vec()); + Some(f) + } else { + None + } + }) { + Some(_) => Ok(()), + None => Err(Error::NotFound), + } + } + None => Err(Error::NotFound), + } + } + /// Not ready for use!!!!! /// Redact an assertion from a prior claim. /// This will remove the assertion from the JUMBF @@ -760,6 +800,7 @@ impl Claim { verified: Result<ValidationInfo>, validation_log: &mut impl StatusTracker, ) -> Result<()> { + const UNNAMED: &str = "unnamed"; let default_str = |s: &String| s.clone(); match verified { @@ -916,10 +957,45 @@ impl Claim { } for dh_assertion in claim.data_hash_assertions() { - let dh = DataHash::from_assertion(&dh_assertion)?; - let name = dh.name.as_ref().map_or("unnamed".to_string(), default_str); - if !dh.is_remote_hash() { - // only verify local hashes here + if dh_assertion.label_root() == DataHash::LABEL { + let dh = DataHash::from_assertion(dh_assertion)?; + let name = dh.name.as_ref().map_or(UNNAMED.to_string(), default_str); + if !dh.is_remote_hash() { + // only verify local hashes here + match dh.verify_in_memory_hash(asset_bytes, Some(claim.alg().to_string())) { + Ok(_a) => { + let log_item = log_item!( + claim.assertion_uri(&dh_assertion.label()), + "data hash valid", + "verify_internal" + ) + .validation_status(validation_status::ASSERTION_DATAHASH_MATCH); + validation_log.log_silent(log_item); + + continue; + } + Err(e) => { + let log_item = log_item!( + claim.assertion_uri(&dh_assertion.label()), + format!("asset hash error, name: {}, error: {}", name, e), + "verify_internal" + ) + .error(Error::HashMismatch(format!("Asset hash failure: {}", e))) + .validation_status(validation_status::ASSERTION_DATAHASH_MISMATCH); + + validation_log.log( + log_item, + Some(Error::HashMismatch(format!("Asset hash failure: {}", e))), + )?; + } + } + } + } else { + // handle BMFF data hashes + let dh = BmffHash::from_assertion(dh_assertion)?; + + let name = dh.name().map_or("unnamed".to_string(), default_str); + match dh.verify_in_memory_hash(asset_bytes, Some(claim.alg().to_string())) { Ok(_a) => { let log_item = log_item!( @@ -967,7 +1043,7 @@ impl Claim { } /// Return list of data hash assertions - pub fn data_hash_assertions(&self) -> Vec<Assertion> { + pub fn data_hash_assertions(&self) -> Vec<&Assertion> { let dummy_data = AssertionData::Cbor(Vec::new()); let dummy_hash = Assertion::new(DataHash::LABEL, None, dummy_data); let mut data_hashes = self.assertions_by_type(&dummy_hash); @@ -980,10 +1056,16 @@ impl Claim { data_hashes } + pub fn bmff_hash_assertions(&self) -> Vec<&Assertion> { + // add in an BMFF hashes + let dummy_bmff_data = AssertionData::Cbor(Vec::new()); + let dummy_bmff_hash = Assertion::new(assertions::labels::BMFF_HASH, None, dummy_bmff_data); + self.assertions_by_type(&dummy_bmff_hash) + } /// Return list of ingredient assertions. This function /// is only useful on commited or loaded claims since ingredients /// are resolved at commit time. - pub fn ingredient_assertions(&self) -> Vec<Assertion> { + pub fn ingredient_assertions(&self) -> Vec<&Assertion> { let dummy_data = AssertionData::Cbor(Vec::new()); let dummy_ingredient = Assertion::new(labels::INGREDIENT, None, dummy_data); self.assertions_by_type(&dummy_ingredient) @@ -1050,12 +1132,12 @@ impl Claim { .collect() } - pub fn assertions_by_type(&self, assertion_proto: &Assertion) -> Vec<Assertion> { + pub fn assertions_by_type(&self, assertion_proto: &Assertion) -> Vec<&Assertion> { self.assertion_store .iter() .filter_map(|x| { if Assertion::assertions_eq(assertion_proto, x.assertion()) { - Some(x.assertion.clone()) + Some(&x.assertion) } else { None } diff --git a/sdk/src/jumbf_io.rs b/sdk/src/jumbf_io.rs @@ -12,26 +12,59 @@ // each license. use crate::{ - asset_handlers::{c2pa_io::C2paIO, jpeg_io::JpegIO, png_io::PngIO}, + asset_handlers::{bmff_io::BmffIO, c2pa_io::C2paIO, jpeg_io::JpegIO, png_io::PngIO}, asset_io::{AssetIO, CAILoader, HashObjectPositions}, error::{Error, Result}, }; use std::{ - fs, + fs::{self, File}, io::Cursor, path::{Path, PathBuf}, }; -static SUPPORTED_TYPES: &[&str; 6] = &[ +static SUPPORTED_TYPES: [&str; 18] = [ + "avif", "c2pa", // stand-alone manifest file + "heif", + "heic", "jpg", "jpeg", + "mp4", + "m4a", + "mov", "png", + "application/mp4", + "audio/mp4", + "image/avif", + "image/heic", + "image/heif", "image/jpeg", "image/png", + "video/mp4", ]; +#[cfg(feature = "file_io")] +static BMFF_TYPES: [&str; 12] = [ + "avif", + "heif", + "heic", + "mp4", + "m4a", + "mov", + "application/mp4", + "audio/mp4", + "image/avif", + "image/heic", + "image/heif", + "video/mp4", +]; + +#[cfg(feature = "file_io")] +pub(crate) fn is_bmff_format(asset_type: &str) -> bool { + BMFF_TYPES.contains(&asset_type) +} + /// Return jumbf block from in memory asset pub fn load_jumbf_from_memory(asset_type: &str, data: &[u8]) -> Result<Vec<u8>> { let mut buf_reader = Cursor::new(data); @@ -47,19 +80,28 @@ pub fn load_jumbf_from_memory(asset_type: &str, data: &[u8]) -> Result<Vec<u8>> } pub fn get_assetio_handler(ext: &str) -> Option<Box<dyn AssetIO>> { - match ext { + let ext = ext.to_lowercase(); + match ext.as_ref() { "c2pa" => Some(Box::new(C2paIO {})), "jpg" | "jpeg" => Some(Box::new(JpegIO {})), "png" => Some(Box::new(PngIO {})), + "mp4" | "m4a" | "mov" if cfg!(feature = "bmff") => Some(Box::new(BmffIO::new(&ext))), _ => None, } } pub fn get_cailoader_handler(asset_type: &str) -> Option<Box<dyn CAILoader>> { - match asset_type { + let asset_type = asset_type.to_lowercase(); + match asset_type.as_ref() { "c2pa" | "application/c2pa" => Some(Box::new(C2paIO {})), "jpg" | "jpeg" | "image/jpeg" => Some(Box::new(JpegIO {})), "png" | "image/png" => Some(Box::new(PngIO {})), + "avif" | "heif" | "heic" | "mp4" | "m4a" | "application/mp4" | "audio/mp4" + | "image/avif" | "image/heic" | "image/heif" | "video/mp4" + if cfg!(feature = "bmff") && !cfg!(target_arch = "wasm32") => + { + Some(Box::new(BmffIO::new(&asset_type))) + } _ => None, } } @@ -144,8 +186,11 @@ pub fn update_file_jumbf( pub fn load_jumbf_from_file(in_path: &Path) -> Result<Vec<u8>> { let ext = get_file_extension(in_path).ok_or(Error::UnsupportedType)?; - match get_assetio_handler(&ext) { - Some(asset_handler) => asset_handler.read_cai_store(in_path), + match get_cailoader_handler(&ext) { + Some(asset_handler) => { + let mut f = File::open(in_path)?; + asset_handler.read_cai(&mut f) + } _ => Err(Error::UnsupportedType), } } diff --git a/sdk/src/store.rs b/sdk/src/store.rs @@ -27,12 +27,13 @@ use crate::{ #[cfg(feature = "file_io")] use crate::{ assertion::AssertionData, - assertions::DataHash, + assertions::{BmffHash, DataHash, DataMap, ExclusionsMap, SubsetMap}, asset_io::{HashBlockObjectType, HashObjectPositions}, cose_sign::cose_sign, cose_validator::verify_cose, jumbf_io::{ - get_supported_file_extension, load_jumbf_from_file, object_locations, save_jumbf_to_file, + get_supported_file_extension, is_bmff_format, load_jumbf_from_file, object_locations, + save_jumbf_to_file, }, utils::{ hash_utils::{hash256, Exclusion}, @@ -989,7 +990,7 @@ impl Store { // walk the ingredients for i in claim.ingredient_assertions() { - let ingredient_assertion = Ingredient::from_assertion(&i)?; + let ingredient_assertion = Ingredient::from_assertion(i)?; // is this an ingredient if let Some(ref c2pa_manifest) = &ingredient_assertion.c2pa_manifest { @@ -1097,7 +1098,7 @@ impl Store { ) -> Result<()> { // walk the ingredients for i in claim.ingredient_assertions() { - let ingredient_assertion = Ingredient::from_assertion(&i)?; + let ingredient_assertion = Ingredient::from_assertion(i)?; // is this an ingredient if let Some(ref c2pa_manifest) = &ingredient_assertion.c2pa_manifest { @@ -1262,6 +1263,102 @@ impl Store { Ok(hashes) } + #[cfg(feature = "file_io")] + fn generate_bmff_data_hashes( + asset_path: &Path, + alg: &str, + calc_hashes: bool, + ) -> Result<Vec<BmffHash>> { + use serde_bytes::ByteBuf; + + // The spec has mandatory BMFF exclusion ranges for certain atoms. + // The function makes sure those are included. + + let mut hashes: Vec<BmffHash> = Vec::new(); + + let mut dh = BmffHash::new("jumbf manifest", alg, None); + let exclusions = dh.exclusions_mut(); + + // jumbf exclusion + let mut uuid = ExclusionsMap::new("/uuid".to_owned()); + let data = DataMap { + offset: 8, + value: vec![ + 216, 254, 195, 214, 27, 14, 72, 60, 146, 151, 88, 40, 135, 126, 196, 129, + ], // C2PA identifier + }; + let data_vec = vec![data]; + uuid.data = Some(data_vec); + exclusions.push(uuid); + + // ftyp exclusion + let ftyp = ExclusionsMap::new("/ftyp".to_owned()); + exclusions.push(ftyp); + + // meta/iloc exclusion + let iloc = ExclusionsMap::new("/meta/iloc".to_owned()); + exclusions.push(iloc); + + // /mfra/tfra exclusion + let tfra = ExclusionsMap::new("/mfra/tfra".to_owned()); + exclusions.push(tfra); + + // /moov/trak/mdia/minf/stbl/stco exclusion + let mut stco = ExclusionsMap::new("/moov/trak/mdia/minf/stbl/stco".to_owned()); + let subset_stco = SubsetMap { + offset: 16, + length: 0, + }; + let subset_stco_vec = vec![subset_stco]; + stco.subset = Some(subset_stco_vec); + exclusions.push(stco); + + // /moov/trak/mdia/minf/stbl/co64 exclusion + let mut co64 = ExclusionsMap::new("/moov/trak/mdia/minf/stbl/co64".to_owned()); + let subset_co64 = SubsetMap { + offset: 16, + length: 0, + }; + let subset_co64_vec = vec![subset_co64]; + co64.subset = Some(subset_co64_vec); + exclusions.push(co64); + + // /moof/traf/tfhd exclusion + let mut tfhd = ExclusionsMap::new("/moof/traf/tfhd".to_owned()); + let subset_tfhd = SubsetMap { + offset: 16, + length: 8, + }; + let subset_tfhd_vec = vec![subset_tfhd]; + tfhd.subset = Some(subset_tfhd_vec); + tfhd.flags = Some(ByteBuf::from([1, 0, 0])); + exclusions.push(tfhd); + + // /moof/traf/trun exclusion + let mut trun = ExclusionsMap::new("/moof/traf/trun".to_owned()); + let subset_trun = SubsetMap { + offset: 16, + length: 4, + }; + let subset_trun_vec = vec![subset_trun]; + trun.subset = Some(subset_trun_vec); + trun.flags = Some(ByteBuf::from([1, 0, 0])); + exclusions.push(trun); + + if calc_hashes { + dh.gen_hash(asset_path)?; + } else { + match alg { + "sha256" => dh.set_hash([0u8; 32].to_vec()), + "sha384" => dh.set_hash([0u8; 48].to_vec()), + "sha512" => dh.set_hash([0u8; 64].to_vec()), + _ => return Err(Error::UnsupportedType), + } + } + hashes.push(dh); + + Ok(hashes) + } /// Embed the claims store as jumbf into an asset. Updates XMP with provenance record. #[cfg(feature = "file_io")] pub fn save_to_asset( @@ -1321,7 +1418,7 @@ impl Store { ) -> Result<Vec<u8>> { // clone the source to working copy if requested get_supported_file_extension(asset_path).ok_or(Error::UnsupportedType)?; // verify extensions - let _ext = get_supported_file_extension(output_path).ok_or(Error::UnsupportedType)?; + let ext = get_supported_file_extension(output_path).ok_or(Error::UnsupportedType)?; if asset_path != output_path { fs::copy(&asset_path, &output_path).map_err(Error::IoError)?; } @@ -1338,49 +1435,84 @@ impl Store { return Err(Error::XmpWriteError); } + let is_bmff = is_bmff_format(&ext); + + let mut data; + let jumbf_size; + // get the provenance claim let pc = self.provenance_claim_mut().ok_or(Error::ClaimEncoding)?; - // 2) Get hash ranges if needed, do not generate for update manifests - let mut hash_ranges = object_locations(output_path)?; - let hashes: Vec<DataHash> = if pc.update_manifest() { - Vec::new() - } else { - Store::generate_data_hashes(output_path, pc.alg(), &mut hash_ranges, false)? - }; - - // add the placeholder data hashes to provenance claim so that the required space is reserved - for mut hash in hashes { - // add padding to account for possible cbor expansion of final DataHash - let padding: Vec<u8> = vec![0x0; 10]; - hash.add_padding(padding); + if is_bmff { + // 2) Get hash ranges if needed, do not generate for update manifests + if !pc.update_manifest() { + let bmff_hashes = Store::generate_bmff_data_hashes(output_path, pc.alg(), false)?; + for hash in bmff_hashes { + pc.add_assertion(&hash)?; + } + } - pc.add_assertion(&hash)?; - } + // 3) Generate in memory CAI jumbf block + // and write preliminary jumbf store to file + // source and dest the same so save_jumbf_to_file will use the same file since we have already cloned + data = self.to_jumbf_internal(reserve_size)?; + jumbf_size = data.len(); + save_jumbf_to_file(&data, output_path, Some(output_path))?; - // 3) Generate in memory CAI jumbf block - // and write preliminary jumbf store to file - // source and dest the same so save_jumbf_to_file will use the same file since we have already cloned - let mut data = self.to_jumbf_internal(reserve_size)?; - let jumbf_size = data.len(); - save_jumbf_to_file(&data, output_path, Some(output_path))?; + // generate actual hash values + let pc = self.provenance_claim_mut().ok_or(Error::ClaimEncoding)?; // reborrow to change mutability - // 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 !pc.update_manifest() { + let bmff_hashes = pc.bmff_hash_assertions(); - // 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() { - Vec::new() + if !bmff_hashes.is_empty() { + let mut bmff_hash = BmffHash::from_assertion(bmff_hashes[0])?; + bmff_hash.gen_hash(output_path)?; + pc.update_bmff_hash(bmff_hash)?; + } + } } else { - Store::generate_data_hashes(output_path, pc.alg(), &mut new_hash_ranges, true)? - }; + // 2) Get hash ranges if needed, do not generate for update manifests + let mut hash_ranges = object_locations(output_path)?; + let hashes: Vec<DataHash> = if pc.update_manifest() { + Vec::new() + } else { + Store::generate_data_hashes(output_path, pc.alg(), &mut hash_ranges, false)? + }; + + // add the placeholder data hashes to provenance claim so that the required space is reserved + for mut hash in hashes { + // add padding to account for possible cbor expansion of final DataHash + let padding: Vec<u8> = vec![0x0; 10]; + hash.add_padding(padding); + + pc.add_assertion(&hash)?; + } - // patch existing claim hash with updated data - for mut hash in updated_hashes { - hash.gen_hash(output_path)?; // generate - pc.update_data_hash(hash)?; + // 3) Generate in memory CAI jumbf block + // and write preliminary jumbf store to file + // source and dest the same so save_jumbf_to_file will use the same file since we have already cloned + data = self.to_jumbf_internal(reserve_size)?; + jumbf_size = data.len(); + save_jumbf_to_file(&data, output_path, Some(output_path))?; + + // 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)?; + + // 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() { + Vec::new() + } else { + Store::generate_data_hashes(output_path, pc.alg(), &mut new_hash_ranges, true)? + }; + + // patch existing claim hash with updated data + for mut hash in updated_hashes { + hash.gen_hash(output_path)?; // generate + pc.update_data_hash(hash)?; + } } // regenerate the jumbf because the cbor changed @@ -2445,4 +2577,19 @@ pub mod tests { let store = Store::load_from_asset(&ap, true, &mut report).expect("load_from_asset"); println!("store = {}", store); } + + #[test] + #[cfg(feature = "bmff")] + fn test_bmff() { + let ap = fixture_path("video1.mp4"); + let mut report = DetailedStatusTracker::new(); + let store = Store::load_from_asset(&ap, true, &mut report).expect("load_from_asset"); + + let errors = report_split_errors(report.get_log_mut()); + + println!("Error report for {}: {:?}", ap.as_display(), errors); + assert!(errors.is_empty()); + + println!("store = {}", store); + } } diff --git a/sdk/tests/fixtures/video1.mp4 b/sdk/tests/fixtures/video1.mp4 Binary files differ.