diff --git a/Cargo.lock b/Cargo.lock index 13bf8e16c..f8a1d84a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -724,7 +724,6 @@ dependencies = [ "byteorder", "byteordered", "bytes", - "c2pa", "c2pa-crypto", "c2pa-status-tracker", "chrono", diff --git a/cawg_identity/Cargo.toml b/cawg_identity/Cargo.toml index 8461c0b29..8255a16ee 100644 --- a/cawg_identity/Cargo.toml +++ b/cawg_identity/Cargo.toml @@ -27,7 +27,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] async-trait = "0.1.78" base64 = "0.22.1" -c2pa = { path = "../sdk", version = "0.43.0", features = ["openssl", "unstable_api"] } +c2pa = { path = "../sdk", version = "0.43.0", features = ["openssl"] } c2pa-crypto = { path = "../internal/crypto", version = "0.5.0" } c2pa-status-tracker = { path = "../internal/status-tracker", version = "0.3.0" } chrono = { version = "0.4.38", features = ["serde"] } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 81e4be0f7..5e98d6cdd 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -26,8 +26,7 @@ c2pa = { path = "../sdk", version = "0.43.0", features = [ "fetch_remote_manifests", "file_io", "add_thumbnails", - "pdf", - "unstable_api", + "pdf" ] } c2pa-crypto = { path = "../internal/crypto", version = "0.5.0" } clap = { version = "4.5.10", features = ["derive", "env"] } diff --git a/cli/sample/test.json b/cli/sample/test.json index 879b56761..d2f2b0d5f 100644 --- a/cli/sample/test.json +++ b/cli/sample/test.json @@ -1,9 +1,10 @@ { - "alg": "ps256", - "private_key": "ps256.pem", - "sign_cert": "ps256.pub", + "claim_version": 1, "ta_url": "http://timestamp.digicert.com", - "claim_generator": "TestApp", + "claim_generator_info":[{ + "name": "TestApp", + "version": "1.0.0" + }], "title": "My Title", "assertions": [ { diff --git a/cli/src/callback_signer.rs b/cli/src/callback_signer.rs index 96951f86d..d3baae270 100644 --- a/cli/src/callback_signer.rs +++ b/cli/src/callback_signer.rs @@ -107,8 +107,9 @@ impl CallbackSignerConfig { pub fn new(sign_config: &SignConfig, reserve_size: usize) -> anyhow::Result { let alg = sign_config .alg - .clone() - .and_then(|alg| alg.parse::().ok()) + .as_deref() + .map_or_else(|| "es256".to_string(), |alg| alg.to_lowercase()) + .parse::() .context("Invalid signing algorithm provided")?; let sign_cert_path = sign_config diff --git a/cli/src/info.rs b/cli/src/info.rs index 9a9160d4d..c0f85f65b 100644 --- a/cli/src/info.rs +++ b/cli/src/info.rs @@ -24,7 +24,7 @@ pub fn info(path: &Path) -> Result<()> { } } let ingredient = c2pa::Ingredient::from_file_with_options(path, &Options {})?; - println!("Information for {}", ingredient.title()); + println!("Information for {}", ingredient.title().unwrap_or_default()); let mut is_cloud_manifest = false; //println!("instanceID = {}", ingredient.instance_id()); if let Some(provenance) = ingredient.provenance() { diff --git a/cli/src/main.rs b/cli/src/main.rs index 9f973b533..da3c25fde 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -19,7 +19,7 @@ /// in that file. If a manifest definition JSON file is specified, /// the claim will be added to any existing claims. use std::{ - fs::{create_dir_all, remove_dir_all, File}, + fs::{create_dir_all, remove_dir_all, remove_file, File}, io::Write, path::{Path, PathBuf}, str::FromStr, @@ -469,7 +469,7 @@ fn main() -> Result<()> { // match args.output { // Some(output) => { // if output.exists() && !args.force { - // bail!("Output already exists, use -f/force to force write"); + // bail!("Output already exists; use -f/force to force write"); // } // if path != &output { // std::fs::copy(path, &output)?; @@ -517,7 +517,7 @@ fn main() -> Result<()> { } else { manifest.claim_generator_info.insert(1, tool_generator); } - println!("claim generator {:?}", manifest.claim_generator_info); + // set manifest base path before ingredients so ingredients can override it if let Some(base) = base_path.as_ref() { builder.base_path = Some(base.clone()); @@ -596,8 +596,12 @@ fn main() -> Result<()> { if ext_normal(&output) != ext_normal(&args.path) { bail!("Output type must match source type"); } - if output.exists() && !args.force { - bail!("Output already exists, use -f/force to force write"); + if output.exists() { + if args.force { + remove_file(&output)?; + } else { + bail!("Output already exists; use -f/force to force write"); + } } if output.file_name().is_none() { @@ -607,11 +611,16 @@ fn main() -> Result<()> { bail!("Missing extension output"); } - #[allow(deprecated)] // todo: remove when we can - builder + let manifest_data = builder .sign_file(signer.as_ref(), &args.path, &output) .context("embedding manifest")?; + if args.sidecar { + let sidecar = output.with_extension("c2pa"); + let mut file = File::create(&sidecar)?; + file.write_all(&manifest_data)?; + } + // generate a report on the output file let reader = Reader::from_file(&output).map_err(special_errs)?; if args.detailed { @@ -633,7 +642,7 @@ fn main() -> Result<()> { if args.force { remove_dir_all(&output)?; } else { - bail!("Output already exists, use -f/force to force write"); + bail!("Output already exists; use -f/force to force write"); } } create_dir_all(&output)?; @@ -708,8 +717,8 @@ pub mod tests { #[test] fn test_manifest_config() { const SOURCE_PATH: &str = "tests/fixtures/earth_apollo17.jpg"; - const OUTPUT_PATH: &str = "target/tmp/unit_out.jpg"; - create_dir_all("target/tmp").expect("create_dir"); + const OUTPUT_PATH: &str = "../target/tmp/unit_out.jpg"; + create_dir_all("../target/tmp").expect("create_dir"); std::fs::remove_file(OUTPUT_PATH).ok(); // remove output file if it exists let mut builder = Builder::from_json(CONFIG).expect("from_json"); @@ -719,7 +728,6 @@ pub mod tests { .signer() .expect("get_signer"); - #[allow(deprecated)] // todo: remove when we can let _result = builder .sign_file(signer.as_ref(), SOURCE_PATH, OUTPUT_PATH) .expect("embed"); diff --git a/cli/src/tree.rs b/cli/src/tree.rs index f9dd6aa5b..d0e58b206 100644 --- a/cli/src/tree.rs +++ b/cli/src/tree.rs @@ -30,23 +30,23 @@ fn populate_node( } for ingredient in manifest.ingredients().iter() { + let title = ingredient.title().unwrap_or("Untitled"); if let Some(label) = ingredient.active_manifest() { // create new node let data = if name_only { - format!("{}_{}", ingredient.title(), label) + format!("{}_{}", title, label) } else { - format!("Asset:{}, Manifest:{}", ingredient.title(), label) + format!("Asset:{}, Manifest:{}", title, label) }; let new_token = current_token.append(tree, data); populate_node(tree, reader, label, &new_token, name_only)?; } else { - let asset_name = ingredient.title(); let data = if name_only { - asset_name.to_string() + title.to_string() } else { - format!("Asset:{asset_name}") + format!("Asset:{title}") }; current_token.append(tree, data); } diff --git a/cli/tests/integration.rs b/cli/tests/integration.rs index 9d9d45027..04fd18f52 100644 --- a/cli/tests/integration.rs +++ b/cli/tests/integration.rs @@ -11,14 +11,17 @@ // specific language governing permissions and limitations under // each license. -use std::{error::Error, fs, path::PathBuf, process::Command}; +use std::{error::Error, fs, fs::create_dir_all, path::PathBuf, process::Command}; // Add methods on commands use assert_cmd::prelude::*; use httpmock::{prelude::*, Mock}; use predicate::str; use predicates::prelude::*; +use serde_json::Value; +const TEST_IMAGE: &str = "earth_apollo17.jpg"; +//const TEST_IMAGE: &str = "libpng-test.png"; // save for png testing const TEST_IMAGE_WITH_MANIFEST: &str = "C.jpg"; // save for manifest tests fn fixture_path(name: &str) -> PathBuf { @@ -28,6 +31,353 @@ fn fixture_path(name: &str) -> PathBuf { fs::canonicalize(path).expect("canonicalize") } +fn temp_path(name: &str) -> PathBuf { + let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")); + create_dir_all(&path).ok(); + path.join(name) +} +#[test] +fn tool_not_found() -> Result<(), Box> { + let mut cmd = Command::cargo_bin("c2patool")?; + cmd.arg("test/file/notfound.jpg"); + cmd.assert().failure().stderr(str::contains("os error")); + Ok(()) +} +#[test] +fn tool_not_found_info() -> Result<(), Box> { + let mut cmd = Command::cargo_bin("c2patool")?; + cmd.arg("test/file/notfound.jpg").arg("--info"); + cmd.assert() + .failure() + .stderr(str::contains("file not found")); + Ok(()) +} +#[test] +fn tool_jpeg_no_report() -> Result<(), Box> { + let mut cmd = Command::cargo_bin("c2patool")?; + cmd.arg(fixture_path(TEST_IMAGE)); + cmd.assert() + .failure() + .stderr(str::contains("No claim found")); + Ok(()) +} + +#[test] +// c2patool tests/fixtures/C.jpg --info +fn tool_info() -> Result<(), Box> { + Command::cargo_bin("c2patool")? + .arg(fixture_path(TEST_IMAGE_WITH_MANIFEST)) + .arg("--info") + .assert() + .success() + .stdout(str::contains( + "Provenance URI = self#jumbf=/c2pa/contentauth:urn:uuid:", + )) + .stdout(str::contains("Manifest store size = 51217")); + Ok(()) +} + +#[test] +fn tool_embed_jpeg_report() -> Result<(), Box> { + Command::cargo_bin("c2patool")? + .arg(fixture_path(TEST_IMAGE)) + .arg("-m") + .arg("sample/test.json") + .arg("-p") + .arg(fixture_path(TEST_IMAGE)) + .arg("-o") + .arg(temp_path("out.jpg")) + .arg("-f") + .assert() + .success() // should this be a failure? + .stdout(str::contains("My Title")); + Ok(()) +} +#[test] +fn tool_fs_output_report() -> Result<(), Box> { + let path = temp_path("output_dir"); + Command::cargo_bin("c2patool")? + .arg(fixture_path("verify.jpeg")) + .arg("-o") + .arg(&path) + .arg("-f") + .assert() + .success() + .stdout(str::contains(format!( + "Manifest report written to the directory {path:?}" + ))); + let manifest_json = path.join("manifest_store.json"); + let contents = fs::read_to_string(manifest_json)?; + let json: Value = serde_json::from_str(&contents)?; + assert_eq!( + json.as_object() + .unwrap() + .get("active_manifest") + .unwrap() + .as_str() + .unwrap(), + "adobe:urn:uuid:df1d2745-5beb-4d6c-bd99-3527e29c7df0", + ); + Ok(()) +} +#[test] +fn tool_fs_output_report_supports_detailed_flag() -> Result<(), Box> { + let path = temp_path("./output_detailed"); + Command::cargo_bin("c2patool")? + .arg(fixture_path("verify.jpeg")) + .arg("-o") + .arg(&path) + .arg("-f") + .arg("-d") + .assert() + .success() + .stdout(str::contains(format!( + "Manifest report written to the directory {path:?}" + ))); + let manifest_json = path.join("detailed.json"); + let contents = fs::read_to_string(manifest_json)?; + let json: Value = serde_json::from_str(&contents)?; + assert!(json + .as_object() + .unwrap() + .get("validation_results") + .is_some()); + Ok(()) +} +#[test] +fn tool_fs_output_fails_when_output_exists() -> Result<(), Box> { + let path = temp_path("./output_conflict"); + // Create conflict directory. + create_dir_all(&path)?; + Command::cargo_bin("c2patool")? + .arg(fixture_path("C.jpg")) + .arg("-o") + .arg(&path) + .assert() + .failure() + .stderr(str::contains( + "Error: Output already exists; use -f/force to force write", + )); + Ok(()) +} +#[test] +// c2patool tests/fixtures/C.jpg -fo target/tmp/manifest_test +fn tool_test_manifest_folder() -> Result<(), Box> { + let out_path = temp_path("manifest_test"); + // first export a c2pa file + Command::cargo_bin("c2patool")? + .arg(fixture_path(TEST_IMAGE_WITH_MANIFEST)) + .arg("-o") + .arg(&out_path) + .arg("-f") + .assert() + .success() + .stdout(str::contains("Manifest report written")); + // then read it back in + let json = + std::fs::read_to_string(out_path.join("manifest_store.json")).expect("read manifest"); + dbg!(&json); + assert!(json.contains("make_test_images")); + Ok(()) +} +#[test] +// c2patool tests/fixtures/C.jpg -ifo target/tmp/ingredient_test +fn tool_test_ingredient_folder() -> Result<(), Box> { + let out_path = temp_path("ingredient_test"); + // first export a c2pa file + Command::cargo_bin("c2patool")? + .arg(fixture_path(TEST_IMAGE_WITH_MANIFEST)) + .arg("-o") + .arg(&out_path) + .arg("--ingredient") + .arg("-f") + .assert() + .success() + .stdout(str::contains("Ingredient report written")); + // then read it back in + let json = std::fs::read_to_string(out_path.join("ingredient.json")).expect("read manifest"); + assert!(json.contains("manifest_data")); + Ok(()) +} +#[test] +// c2patool tests/fixtures/C.jpg -ifo target/tmp/ingredient_json +// c2patool tests/fixtures/earth_apollo17.jpg -m sample/test.json -p target/tmp/ingredient_json/ingredient.json -fo target/tmp/out_2.jpg +fn tool_test_manifest_ingredient_json() -> Result<(), Box> { + let out_path = temp_path("ingredient_json"); + // first export a c2pa file + Command::cargo_bin("c2patool")? + .arg(fixture_path(TEST_IMAGE_WITH_MANIFEST)) + .arg("-o") + .arg(&out_path) + .arg("--ingredient") + .arg("-f") + .assert() + .success() + .stdout(str::contains("Ingredient report written")); + let json_path = out_path.join("ingredient.json"); + let parent = json_path.to_string_lossy().to_string(); + Command::cargo_bin("c2patool")? + .arg(fixture_path(TEST_IMAGE)) + .arg("-p") + .arg(parent) + .arg("-m") + .arg("sample/test.json") + .arg("-o") + .arg(temp_path("out_2.jpg")) + .arg("-f") + .assert() + .success() + .stdout(str::contains("My Title")); + Ok(()) +} +#[test] +// c2patool tests/fixtures/earth_apollo17.jpg -m tests/fixtures/ingredient_test.json -fo target/tmp/ingredients.jpg +fn tool_embed_jpeg_with_ingredients_report() -> Result<(), Box> { + Command::cargo_bin("c2patool")? + .arg(fixture_path(TEST_IMAGE)) + .arg("-m") + .arg(fixture_path("ingredient_test.json")) + .arg("-o") + .arg(temp_path("ingredients.jpg")) + .arg("-f") + .assert() + .success() + .stdout(str::contains("ingredients.jpg")) + .stdout(str::contains("test ingredient")) + .stdout(str::contains("temporal")) + .stdout(str::contains("earth_apollo17.jpg")); + Ok(()) +} +#[test] +fn tool_extensions_do_not_match() -> Result<(), Box> { + let path = temp_path("./foo.png"); + Command::cargo_bin("c2patool")? + .arg(fixture_path("C.jpg")) + .arg("-m") + .arg(fixture_path("ingredient_test.json")) + .arg("-o") + .arg(&path) + .assert() + .failure() + .stderr(str::contains("Output type must match source type")); + Ok(()) +} +#[test] +fn tool_similar_extensions_match() -> Result<(), Box> { + let path = temp_path("./similar.JpEg"); + Command::cargo_bin("c2patool")? + .arg(fixture_path("C.jpg")) + .arg("-m") + .arg(fixture_path("ingredient_test.json")) + .arg("-o") + .arg(&path) + .arg("-f") + .assert() + .success() + .stdout(str::contains("similar.")); + Ok(()) +} +#[test] +fn tool_fail_if_thumbnail_missing() -> Result<(), Box> { + Command::cargo_bin("c2patool")? + .arg(fixture_path(TEST_IMAGE)) + .arg("-c") + .arg("{\"thumbnail\": {\"identifier\": \"thumb.jpg\",\"format\": \"image/jpeg\"}}") + .arg("-o") + .arg(temp_path("out_thumb.jpg")) + .arg("-f") + .assert() + .failure() + .stderr(str::contains("resource not found")); + Ok(()) +} +// #[test] +// fn test_succeed_using_example_signer() -> Result<(), Box> { +// let output = temp_path("./output_external.jpg"); +// // We are calling a cargo/bin here that successfully signs claim bytes. We are using +// // a cargo/bin because it works on all OSs, we like Rust, and our example external signing +// // code is compiled and verified during every test of this project. +// let mut successful_process = PathBuf::from(env!("CARGO_MANIFEST_DIR")); +// successful_process.push("target/debug/signer-path-success"); +// Command::cargo_bin("c2patool")? +// .arg(fixture_path("earth_apollo17.jpg")) +// .arg("--signer-path") +// .arg(&successful_process) +// .arg("--reserve-size") +// .arg("20248") +// .arg("--manifest") +// .arg("sample/test.json") +// .arg("-o") +// .arg(&output) +// .arg("-f") +// .assert() +// .success(); +// Ok(()) +// } +// #[test] +// fn test_fails_for_not_found_external_signer() -> Result<(), Box> { +// let output = temp_path("./output_external.jpg"); +// Command::cargo_bin("c2patool")? +// .arg(fixture_path("earth_apollo17.jpg")) +// .arg("--signer-path") +// .arg("./executable-not-found-test") +// .arg("--reserve-size") +// .arg("10248") +// .arg("--manifest") +// .arg("sample/test.json") +// .arg("-o") +// .arg(&output) +// .arg("-f") +// .assert() +// .stderr(str::contains("Failed to run command at")) +// .failure(); +// Ok(()) +// } +// #[test] +// fn test_fails_for_external_signer_failure() -> Result<(), Box> { +// let output = temp_path("./output_external.jpg"); +// let mut failing_process = PathBuf::from(env!("CARGO_MANIFEST_DIR")); +// failing_process.push("target/debug/signer-path-fail"); +// Command::cargo_bin("c2patool")? +// .arg(fixture_path("earth_apollo17.jpg")) +// .arg("--signer-path") +// .arg(&failing_process) +// .arg("--reserve-size") +// .arg("20248") +// .arg("--manifest") +// .arg("sample/test.json") +// .arg("-o") +// .arg(&output) +// .arg("-f") +// .assert() +// .stderr(str::contains("User supplied signer process failed")) +// // Ensures stderr from user executable is revealed to client. +// .stderr(str::contains("signer-path-fail-stderr")) +// .failure(); +// Ok(()) +// } +// #[test] +// fn test_fails_for_external_signer_success_without_stdout() -> Result<(), Box> { +// let output = temp_path("./output_external.jpg"); +// let mut failing_process = PathBuf::from(env!("CARGO_MANIFEST_DIR")); +// failing_process.push("target/debug/signer-path-no-stdout"); +// Command::cargo_bin("c2patool")? +// .arg(fixture_path("earth_apollo17.jpg")) +// .arg("--signer-path") +// .arg(&failing_process) +// .arg("--reserve-size") +// .arg("10248") +// .arg("--manifest") +// .arg("sample/test.json") +// .arg("-o") +// .arg(&output) +// .arg("-f") +// .assert() +// .stderr(str::contains("User supplied process succeeded, but the external process did not write signature bytes to stdout")) +// .failure(); +// Ok(()) +// } + #[test] // c2patool tests/fixtures/C.jpg trust --trust_anchors=tests/fixtures/trust/anchors.pem --trust_config=tests/fixtures/trust/store.cfg fn tool_load_trust_settings_from_file_trusted() -> Result<(), Box> { @@ -184,18 +534,3 @@ fn tool_tree() -> Result<(), Box> { .stdout(str::contains("Assertion:c2pa.actions")); Ok(()) } - -#[test] -// c2patool tests/fixtures/C.jpg --info -fn tool_info() -> Result<(), Box> { - Command::cargo_bin("c2patool")? - .arg(fixture_path(TEST_IMAGE_WITH_MANIFEST)) - .arg("--info") - .assert() - .success() - .stdout(str::contains( - "Provenance URI = self#jumbf=/c2pa/contentauth:urn:uuid:", - )) - .stdout(str::contains("Manifest store size = 51217")); - Ok(()) -} diff --git a/docs/release-notes.md b/docs/release-notes.md index 5aa0439bc..ba71cb260 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -27,19 +27,40 @@ The goals of this release are to provide a consistent, flexible, well-tested API ### Enabling - +These features are now standard and the `unstable_api` feature is no longer used. -To use the new API, enable the `unstable_api` feature; for example: +You can still use the deprecated API by enabling the `v1_api` feature; for example: ``` -c2pa = {version="0.39.0", features=["unstable_api"]} +c2pa = {version="0.39.0", features=["v1_api"]} ``` -When version 1.0 of the library is released, the new API will become the default, but you will still be able to use the deprecated API by enabling the `v1_api` feature; for example: +### API Changes for C2PA 2.1 + +The C2PA 2.1 claims are experimental at this point and not fully implemented yet. + +`Reader` has a new method: `validation_state()` which returns the a `ValidationState`. +The `ValidationState` can be `Invalid`, `Valid` or `Trusted`. +Use this method instead of checking for `validation_status()` = `None`. + +`Reader` also now has a `validation_results()` method that returns `ValidationResults`. +`ValidationResults` are a more complete form of `ValidationStatus` and will return `success`, `informational` and `failure` codes for the active manifest and ingredients. `ValidationStatus` will be deprecated in favor of `ValidationResults`. + +The `Manifest` `title` is optional and `format` is not supported in v2 claims, so these methods now return an `Option` and may not appear in serialized JSON. + +The `Ingredient` `title` and `format` are optional in v3 ingredients, so these methods now return an Option and may not appear in serialized JSON. + +`Ingredient` now supports a `validation_results` method and a `validation_results` field. + +An `AssetType` assertion is now supported. + +A `claim_version` field is now allowed in a manifest definition for `Builder` and, if set to `2` will generate v2 claims. + +In v2 claims, the first `action` must be `c2pa.created` or `c2pa.opened`. + +There are many more checks and status codes added for v2 claims. + -``` -c2pa = {version="0.39.0", features=["v1_api"]} -``` ## Language binding support diff --git a/docs/usage.md b/docs/usage.md index 525292fb1..0c039a785 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -42,31 +42,20 @@ The Rust library crate provides the following capabilities: * `no_interleaved_io` forces fully-synchronous I/O; otherwise, the library uses threaded I/O for some operations to improve performance. * `fetch_remote_manifests` enables the verification step to retrieve externally referenced manifest stores. External manifests are only fetched if there is no embedded manifest store and no locally adjacent .c2pa manifest store file of the same name. * `json_schema` is used by `make schema` to produce a JSON schema document that represents the `ManifestStore` data structures. -* `openssl_ffi_mutex` prevents multiple threads from accessing the C OpenSSL library simultaneously. (This library is not re-entrant.) In a multi-threaded process (such as Cargo's test runner), this can lead to unpredictable behavior. ### New API -### Enabling +The new API is now enabled by default. The `unstable_api` feature is no longer available. - - -The current release has a new API that replaces the previous methods of reading and writing C2PA data, which are still supported but will be deprecated. - -To use the new API, enable the `unstable_api` feature; for example: +To use the deprecated v1 API, enable the v1_api feature; for example: ``` -c2pa = {version="0.39.0", features=["unstable_api"]} -``` - -When version 1.0 of the library is released, the new API will become the default, but you will still be able to use the deprecated API by enabling the `v1_api` feature; for example: - -``` -c2pa = {version="0.39.0", features=["v1_api"]} +c2pa = {version="0.43.0", features=["v1_api"]} ``` ### Resource references -A resource reference is a superset of a `HashedUri`, which the C2PA specification refers to as both `hashed-uri-map` and `hashed-ext-uri-map`. In some cases either can be used. +A resource reference is a superset of a `HashedUri`, which the C2PA specification refers to as both `hashed-uri-map` and `hashed-ext-uri-map`. In some cases either can be used. A resource reference also adds local references to things like the file system or any abstracted storage. You can use the identifier field to distinguish from the URL field, but they are really the same. However, the specification will only allow for JUMBF and HTTP(S) references, so if the external identifier is not HTTP(S), it must be converted to a JUMBF reference before embedding into a manifest. @@ -114,4 +103,3 @@ The default operation of C2PA signing is to embed a C2PA manifest store into an ## Example code The [sdk/examples](https://github.com/contentauth/c2pa-rs/tree/main/sdk/examples) directory contains some minimal example code. The [client/client.rs](https://github.com/contentauth/c2pa-rs/blob/main/sdk/examples/client/client.rs) is the most instructive and provides and example of reading the contents of a manifest store, recursively displaying nested manifests. - diff --git a/export_schema/Cargo.toml b/export_schema/Cargo.toml index cd28e4ed0..398b35acd 100644 --- a/export_schema/Cargo.toml +++ b/export_schema/Cargo.toml @@ -12,6 +12,6 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(test)'] } [dependencies] anyhow = "1.0.40" -c2pa = { path = "../sdk", default-features = false, features = ["json_schema", "unstable_api"] } +c2pa = { path = "../sdk", default-features = false, features = ["json_schema"] } schemars = "0.8.21" serde_json = "1.0.117" diff --git a/export_schema/src/main.rs b/export_schema/src/main.rs index b1bdb25f9..4fa2fc711 100644 --- a/export_schema/src/main.rs +++ b/export_schema/src/main.rs @@ -1,7 +1,7 @@ use std::{fs, path::Path}; use anyhow::Result; -use c2pa::{settings::Settings, Builder, ManifestDefinition, ManifestStore}; +use c2pa::{settings::Settings, Builder, ManifestDefinition, Reader}; use schemars::{schema::RootSchema, schema_for}; fn write_schema(schema: &RootSchema, name: &str) { @@ -21,8 +21,8 @@ fn main() -> Result<()> { let manifest_definition = schema_for!(ManifestDefinition); write_schema(&manifest_definition, "ManifestDefinition"); - let manifest_store = schema_for!(ManifestStore); - write_schema(&manifest_store, "ManifestStore"); + let reader = schema_for!(Reader); + write_schema(&reader, "Reader"); let settings = schema_for!(Settings); write_schema(&settings, "Settings"); diff --git a/internal/crypto/src/time_stamp/provider.rs b/internal/crypto/src/time_stamp/provider.rs index 742e3448e..62eb30b16 100644 --- a/internal/crypto/src/time_stamp/provider.rs +++ b/internal/crypto/src/time_stamp/provider.rs @@ -49,6 +49,8 @@ pub trait TimeStampProvider { /// provided by [`Self::time_stamp_service_url()`], if any. /// /// [RFC 3161]: https://datatracker.ietf.org/doc/html/rfc3161 + /// + /// todo: THIS CODE IS NOT COMPATIBLE WITH C2PA 2.x sigTst2 #[allow(unused_variables)] // `message` not used on WASM fn send_time_stamp_request(&self, message: &[u8]) -> Option, TimeStampError>> { #[cfg(not(target_arch = "wasm32"))] diff --git a/internal/status-tracker/src/log.rs b/internal/status-tracker/src/log.rs index b77177c25..e82dd28fd 100644 --- a/internal/status-tracker/src/log.rs +++ b/internal/status-tracker/src/log.rs @@ -44,8 +44,7 @@ use crate::StatusTracker; /// file: Cow::Borrowed(file!()), /// function: Cow::Borrowed("test func"), /// line: log.line, -/// err_val: None, -/// validation_status: None, +/// ..Default::default() /// } /// ); /// # @@ -63,8 +62,7 @@ macro_rules! log_item { function: $function.into(), line: line!(), description: $description.into(), - err_val: None, - validation_status: None, + ..Default::default() } }}; } @@ -103,6 +101,27 @@ pub struct LogItem { /// C2PA validation status code pub validation_status: Option>, + + /// Ingredient URI (for ingredient-related logs) + pub ingredient_uri: Option>, +} + +impl Default for LogItem { + fn default() -> Self { + LogItem { + kind: LogKind::Success, + label: Cow::Borrowed(""), + description: Cow::Borrowed(""), + crate_name: env!("CARGO_PKG_NAME").into(), + crate_version: env!("CARGO_PKG_VERSION").into(), + file: Cow::Borrowed(""), + function: Cow::Borrowed(""), + line: 0, + err_val: None, + validation_status: None, + ingredient_uri: None, + } + } } impl LogItem { @@ -126,8 +145,8 @@ impl LogItem { /// file: Cow::Borrowed(file!()), /// function: Cow::Borrowed("test func"), /// line: 7, - /// err_val: None, /// validation_status: Some(Cow::Borrowed("claim.missing")), + /// ..Default::default() /// } /// ); /// ``` @@ -139,6 +158,23 @@ impl LogItem { } } + /// Add an ingredient URI. + /// + /// ## Example + /// + /// ``` + /// # use std::borrow::Cow; + /// # use c2pa_status_tracker::{log_item, LogKind, LogItem}; + /// let log = log_item!("test1", "test item 1", "test func") + /// .set_ingredient_uri("self#jumbf=/c2pa/contentauth:urn:uuid:bef41f24-13aa-4040-8efa-08e5e85c4a00/c2pa.assertions/c2pa.ingredient__1"); + /// ``` + pub fn set_ingredient_uri>(self, uri: S) -> Self { + LogItem { + ingredient_uri: Some(uri.into().into()), + ..self + } + } + /// Set the log item kind to [`LogKind::Success`] and add it to the /// [`StatusTracker`]. pub fn success(mut self, tracker: &mut impl StatusTracker) { @@ -164,7 +200,6 @@ impl LogItem { pub fn failure(mut self, tracker: &mut impl StatusTracker, err: E) -> Result<(), E> { self.kind = LogKind::Failure; self.err_val = Some(format!("{err:?}").into()); - tracker.add_error(self, err) } diff --git a/internal/status-tracker/src/status_tracker/detailed.rs b/internal/status-tracker/src/status_tracker/detailed.rs index 1ded4f61c..8cf3457c0 100644 --- a/internal/status-tracker/src/status_tracker/detailed.rs +++ b/internal/status-tracker/src/status_tracker/detailed.rs @@ -22,6 +22,7 @@ use crate::{LogItem, StatusTracker}; #[derive(Debug, Default)] pub struct DetailedStatusTracker { logged_items: Vec, + ingredient_uris: Vec, } impl DetailedStatusTracker { @@ -49,12 +50,26 @@ impl StatusTracker for DetailedStatusTracker { &self.logged_items } - fn add_non_error(&mut self, log_item: LogItem) { + fn add_non_error(&mut self, mut log_item: LogItem) { + if let Some(ingredient_uri) = self.ingredient_uris.last() { + log_item.ingredient_uri = Some(ingredient_uri.to_string().into()); + } self.logged_items.push(log_item); } - fn add_error(&mut self, log_item: LogItem, _err: E) -> Result<(), E> { + fn add_error(&mut self, mut log_item: LogItem, _err: E) -> Result<(), E> { + if let Some(ingredient_uri) = self.ingredient_uris.last() { + log_item.ingredient_uri = Some(ingredient_uri.to_string().into()); + } self.logged_items.push(log_item); Ok(()) } + + fn push_ingredient_uri>(&mut self, uri: S) { + self.ingredient_uris.push(uri.into()); + } + + fn pop_ingredient_uri(&mut self) -> Option { + self.ingredient_uris.pop() + } } diff --git a/internal/status-tracker/src/status_tracker/mod.rs b/internal/status-tracker/src/status_tracker/mod.rs index 7081fa9e7..b70659735 100644 --- a/internal/status-tracker/src/status_tracker/mod.rs +++ b/internal/status-tracker/src/status_tracker/mod.rs @@ -80,6 +80,16 @@ pub trait StatusTracker: Debug + Send { } }) } + + /// Keeps track of the current ingredient URI, if any. + /// + /// The current URI may be added to any log items that are created. + fn push_ingredient_uri>(&mut self, _uri: S) {} + + /// Removes the current ingredient URI, if any. + fn pop_ingredient_uri(&mut self) -> Option { + None + } } pub(crate) mod detailed; diff --git a/internal/status-tracker/src/tests/log.rs b/internal/status-tracker/src/tests/log.rs index e859ce2ef..c875369d4 100644 --- a/internal/status-tracker/src/tests/log.rs +++ b/internal/status-tracker/src/tests/log.rs @@ -32,6 +32,7 @@ fn r#macro() { line: log.line, err_val: None, validation_status: None, + ..Default::default() } ); @@ -56,6 +57,7 @@ fn macro_from_string() { line: log.line, err_val: None, validation_status: None, + ..Default::default() } ); @@ -82,6 +84,7 @@ fn success() { line: log_item.line, err_val: None, validation_status: None, + ingredient_uri: None, } ); } @@ -106,6 +109,7 @@ fn informational() { line: log_item.line, err_val: None, validation_status: None, + ..Default::default() } ); } @@ -132,6 +136,7 @@ fn failure() { line: log_item.line, err_val: Some(Cow::Borrowed("\"sample error message\"")), validation_status: None, + ..Default::default() } ); } @@ -156,7 +161,7 @@ fn failure_no_throw() { function: Cow::Borrowed("test func"), line: log_item.line, err_val: Some(Cow::Borrowed("\"sample error message\"")), - validation_status: None, + ..Default::default() } ); } @@ -179,6 +184,7 @@ fn validation_status() { line: log_item.line, err_val: None, validation_status: Some(Cow::Borrowed("claim.missing")), + ..Default::default() } ); } diff --git a/internal/status-tracker/src/validation_codes.rs b/internal/status-tracker/src/validation_codes.rs index 45ebe4046..ec4f847a7 100644 --- a/internal/status-tracker/src/validation_codes.rs +++ b/internal/status-tracker/src/validation_codes.rs @@ -17,6 +17,8 @@ //! //! [§15.2.1, “Standard Status Codes.”]: https://c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html#_standard_status_codes +use crate::log::LogKind; + // -- success codes -- /// The claim signature referenced in the ingredient's claim validated. @@ -81,6 +83,11 @@ pub const CLAIM_MULTIPLE: &str = "claim.multiple"; /// Any corresponding URL should point to a C2PA claim box. pub const HARD_BINDINGS_MISSING: &str = "claim.hardBindings.missing"; +// Multiple hard bindings are present in the claim. +/// +/// Any corresponding URL should point to a C2PA assertion. +pub const HARD_BINDINGS_MULTIPLE: &str = "assertion.multipleHardBindings"; + /// A required field is not present in the claim. /// /// Any corresponding URL should point to a C2PA claim box. @@ -297,15 +304,24 @@ pub const GENERAL_ERROR: &str = "general.error"; /// assert!(!is_success(SIGNING_CREDENTIAL_REVOKED)); /// ``` pub fn is_success(status_code: &str) -> bool { - matches!( - status_code, + matches!(log_kind(status_code), LogKind::Success) +} + +/// Returns the [`LogKind`] for a given status code. +// TODO: This needs to be expanded to include all status codes. +pub fn log_kind(status_code: &str) -> LogKind { + match status_code { CLAIM_SIGNATURE_VALIDATED - | SIGNING_CREDENTIAL_TRUSTED - | TIMESTAMP_TRUSTED - | ASSERTION_HASHEDURI_MATCH - | ASSERTION_DATAHASH_MATCH - | ASSERTION_BMFFHASH_MATCH - | ASSERTION_ACCESSIBLE - | ASSERTION_BOXHASH_MATCH - ) + | SIGNING_CREDENTIAL_TRUSTED + | TIMESTAMP_TRUSTED + | ASSERTION_HASHEDURI_MATCH + | ASSERTION_DATAHASH_MATCH + | ASSERTION_BMFFHASH_MATCH + | ASSERTION_ACCESSIBLE + | ASSERTION_BOXHASH_MATCH => LogKind::Success, + TIMESTAMP_UNTRUSTED | TIMESTAMP_OUTSIDE_VALIDITY | TIMESTAMP_MISMATCH => { + LogKind::Informational + } + _ => LogKind::Failure, + } } diff --git a/make_test_images/Cargo.toml b/make_test_images/Cargo.toml index ca4b88640..275a3ff32 100644 --- a/make_test_images/Cargo.toml +++ b/make_test_images/Cargo.toml @@ -16,7 +16,7 @@ required-features = ["default"] [dependencies] anyhow = "1.0.40" -c2pa = { path = "../sdk", default-features = false, features = ["unstable_api"] } +c2pa = { path = "../sdk" } env_logger = "0.11" log = "0.4.8" image = { version = "0.25.2", default-features = false, features = [ diff --git a/make_test_images/src/make_test_images.rs b/make_test_images/src/make_test_images.rs index 8eeef3840..2303cb741 100644 --- a/make_test_images/src/make_test_images.rs +++ b/make_test_images/src/make_test_images.rs @@ -22,7 +22,7 @@ use std::{ use anyhow::{Context, Result}; use c2pa::{ create_signer, - jumbf_io::{get_supported_types, load_jumbf_from_stream, save_jumbf_to_stream}, + jumbf_io::{load_jumbf_from_stream, save_jumbf_to_stream}, Builder, Error, Ingredient, Reader, Relationship, Signer, SigningAlg, }; use memchr::memmem; @@ -258,6 +258,7 @@ impl MakeTestImages { let format = extension_to_mime(extension).unwrap_or("image/jpeg"); let manifest_def = json!({ + "claim_version": 1, "vendor": "contentauth", "title": name, "format": &format, @@ -611,8 +612,6 @@ impl MakeTestImages { /// Runs a list of recipes pub fn run(&self) -> Result<()> { - let supported = get_supported_types(); - println!("Supported types: {:#?}", supported); if !self.output_dir.exists() { std::fs::create_dir_all(&self.output_dir).context("Can't create output folder")?; }; diff --git a/make_test_images/tests.json b/make_test_images/tests.json index bd01f3e09..1b63db787 100644 --- a/make_test_images/tests.json +++ b/make_test_images/tests.json @@ -23,7 +23,6 @@ { "op": "ogp", "parent": "CI", "output": "XCI" }, { "op": "sig", "parent": "CA", "output": "E-sig-CA" }, { "op": "uri", "parent": "CA", "output": "E-uri-CA" }, - { "op": "clm", "parent": "CAICAI", "output": "E-clm-CAICAI" }, { "op": "make", "ingredients": ["E-sig-CA"], "output": "CIE-sig-CA" }, { "op": "make", "ingredients": ["E-uri-CA"], "output": "CAE-uri-CA" }, { "op": "make", "ingredients": ["CAE-uri-CA"], "output": "CACAE-uri-CA" }, diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 14cdb0ae5..3a19a4cc7 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -26,7 +26,6 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["v1_api"] add_thumbnails = ["image"] file_io = ["openssl_sign"] serialize_thumbnails = [] @@ -37,7 +36,6 @@ openssl_sign = ["openssl"] json_schema = ["dep:schemars", "c2pa-crypto/json_schema"] pdf = ["dep:lopdf"] v1_api = [] -unstable_api = [] # The diagnostics feature is unsupported and might be removed. # It enables some low-overhead timing features used in our development cycle. @@ -57,11 +55,9 @@ required-features = ["file_io"] [[example]] name = "v2show" -required-features = ["unstable_api"] [[example]] name = "v2api" -required-features = ["unstable_api"] [lib] crate-type = ["lib"] @@ -159,9 +155,6 @@ web-sys = { version = "0.3.58", features = [ [dev-dependencies] anyhow = "1.0.40" -c2pa = { path = ".", default-features = false, features = [ - "unstable_api", -] } # allow integration tests to use the new API glob = "0.3.1" hex-literal = "0.4.1" jumbf = "0.4.0" diff --git a/sdk/examples/client/client.rs b/sdk/examples/client/client.rs index 2d4aa3f84..38a9ba960 100644 --- a/sdk/examples/client/client.rs +++ b/sdk/examples/client/client.rs @@ -22,7 +22,7 @@ use c2pa::{ }; use c2pa_crypto::raw_signature::SigningAlg; -const GENERATOR: &str = "test_app/0.1"; +const GENERATOR: &str = "test_app"; const INDENT_SPACE: usize = 2; // Example for reading the contents of a manifest store, recursively showing nested manifests @@ -34,8 +34,8 @@ fn show_manifest(reader: &Reader, manifest_label: &str, level: usize) -> Result< println!( "{}title: {} , format: {}, instance_id: {}", indent, - manifest.title().unwrap_or_default(), - manifest.format(), + manifest.title().unwrap_or("None"), + manifest.format().unwrap_or("None"), manifest.instance_id() ); @@ -66,7 +66,11 @@ fn show_manifest(reader: &Reader, manifest_label: &str, level: usize) -> Result< } for ingredient in manifest.ingredients().iter() { - println!("{}Ingredient title:{}", indent, ingredient.title()); + println!( + "{}Ingredient title:{}", + indent, + ingredient.title().unwrap_or("None") + ); if let Some(validation_status) = ingredient.validation_status() { for status in validation_status { println!( @@ -100,10 +104,16 @@ pub fn main() -> Result<()> { // if a filepath was provided on the command line, read it as a parent file let mut parent = Ingredient::from_file(source.as_path())?; parent.set_relationship(Relationship::ParentOf); + + // overwrite the destination file if it exists + if dest.exists() { + std::fs::remove_file(&dest)?; + } + // create an action assertion stating that we imported this file let actions = Actions::new().add_action( Action::new(c2pa_action::OPENED) - .set_parameter("identifier", parent.instance_id().to_owned())?, + .set_parameter("ingredients", [parent.instance_id().to_owned()])?, ); // build a creative work assertion @@ -126,8 +136,11 @@ pub fn main() -> Result<()> { // create a new Manifest let mut builder = Builder::new(); + builder.definition.claim_version = Some(2); + let mut generator = ClaimGeneratorInfo::new(GENERATOR); + generator.set_version("0.1"); builder - .set_claim_generator_info(ClaimGeneratorInfo::new(GENERATOR)) + .set_claim_generator_info(generator) .add_ingredient(parent) .add_assertion(Actions::LABEL, &actions)? .add_assertion(CreativeWork::LABEL, &creative_work)? diff --git a/sdk/examples/data_hash.rs b/sdk/examples/data_hash.rs index ab19359ec..c17fbc87a 100644 --- a/sdk/examples/data_hash.rs +++ b/sdk/examples/data_hash.rs @@ -50,7 +50,7 @@ fn builder_from_source>(source: S) -> Result { // create an action assertion stating that we imported this file let actions = Actions::new().add_action( Action::new(c2pa_action::PLACED) - .set_parameter("identifier", parent.instance_id().to_owned())?, + .set_parameter("ingredients", [parent.instance_id().to_owned()])?, ); // build a creative work assertion diff --git a/sdk/examples/v2api.rs b/sdk/examples/v2api.rs index d1811ceca..544fa8715 100644 --- a/sdk/examples/v2api.rs +++ b/sdk/examples/v2api.rs @@ -15,7 +15,10 @@ use std::io::{Cursor, Seek}; use anyhow::Result; -use c2pa::{settings::load_settings_from_str, Builder, CallbackSigner, Reader}; +use c2pa::{ + settings::load_settings_from_str, validation_results::ValidationState, Builder, CallbackSigner, + Reader, +}; use c2pa_crypto::raw_signature::SigningAlg; use serde_json::json; @@ -151,7 +154,7 @@ fn main() -> Result<()> { } println!("{}", reader.json()); - assert_eq!(reader.validation_status(), None); + assert_ne!(reader.validation_state(), ValidationState::Invalid); assert_eq!(reader.active_manifest().unwrap().title().unwrap(), title); Ok(()) diff --git a/sdk/src/assertion.rs b/sdk/src/assertion.rs index 3222a33da..66e15ec23 100644 --- a/sdk/src/assertion.rs +++ b/sdk/src/assertion.rs @@ -130,8 +130,9 @@ where /// Trait to handle default Cbor encoding/decoding of Assertions pub trait AssertionCbor: Serialize + DeserializeOwned + AssertionBase { fn to_cbor_assertion(&self) -> Result { - let data = - AssertionData::Cbor(serde_cbor::to_vec(self).map_err(|_err| Error::AssertionEncoding)?); + let data = AssertionData::Cbor( + serde_cbor::to_vec(self).map_err(|err| Error::AssertionEncoding(err.to_string()))?, + ); Ok(Assertion::new(self.label(), self.version(), data)) } @@ -157,7 +158,7 @@ pub trait AssertionCbor: Serialize + DeserializeOwned + AssertionBase { pub trait AssertionJson: Serialize + DeserializeOwned + AssertionBase { fn to_json_assertion(&self) -> Result { let data = AssertionData::Json( - serde_json::to_string(self).map_err(|_err| Error::AssertionEncoding)?, + serde_json::to_string(self).map_err(|err| Error::AssertionEncoding(err.to_string()))?, ); Ok(Assertion::new(self.label(), self.version(), data).set_content_type("application/json")) } @@ -251,8 +252,8 @@ impl Assertion { // } // Return version string of known assertion if available - pub(crate) fn get_ver(&self) -> Option { - self.version + pub(crate) fn get_ver(&self) -> usize { + self.version.unwrap_or(1) } // pub fn check_version(&self, max_version: usize) -> AssertionDecodeResult<()> { @@ -298,16 +299,12 @@ impl Assertion { /// Return the CAI label for this Assertion with version string if available pub(crate) fn label(&self) -> String { let base_label = self.label_root(); - match self.get_ver() { - Some(v) => { - if v > 1 { - // c2pa does not include v1 labels - format!("{base_label}.v{v}") - } else { - base_label - } - } - None => base_label, + let v = self.get_ver(); + if v > 1 { + // c2pa does not include v1 labels + format!("{base_label}.v{v}") + } else { + base_label } } @@ -556,7 +553,6 @@ impl AssertionDecodeError { } } - #[cfg(feature = "unstable_api")] pub(crate) fn from_err>( label: String, version: Option, @@ -616,6 +612,10 @@ pub enum AssertionDecodeErrorCause { #[error(transparent)] CborError(#[from] serde_cbor::Error), + + /// There was a problem decoding field. + #[error("the assertion had a mandatory field: {expected} that could not be decoded")] + FieldDecoding { expected: String }, } pub(crate) type AssertionDecodeResult = std::result::Result; @@ -641,8 +641,8 @@ pub mod tests { let a = Assertion::new(Actions::LABEL, Some(2), json); let a_no_ver = Assertion::new(Actions::LABEL, None, json2); - assert_eq!(a.get_ver().unwrap(), 2); - assert_eq!(a_no_ver.get_ver(), None); + assert_eq!(a.get_ver(), 2); + assert_eq!(a_no_ver.get_ver(), 1); assert_eq!(a.label(), format!("{}.{}", Actions::LABEL, "v2")); assert_eq!(a.label_root(), Actions::LABEL); assert_eq!(a_no_ver.label(), Actions::LABEL); diff --git a/sdk/src/assertions/actions.rs b/sdk/src/assertions/actions.rs index 2fe7901b6..cc9d2fc5b 100644 --- a/sdk/src/assertions/actions.rs +++ b/sdk/src/assertions/actions.rs @@ -64,9 +64,20 @@ pub mod c2pa_action { pub const UNKNOWN: &str = "c2pa.unknown"; } +pub static V2_DEPRECATED_ACTIONS: [&str; 7] = [ + "c2pa.copied", + "c2pa.formatted", + "c2pa.version_updated", + "c2pa.printed", + "c2pa.managed", + "c2pa.produced", + "c2pa.saved", +]; + /// We use this to allow SourceAgent to be either a string or a ClaimGeneratorInfo #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] #[serde(untagged)] +#[allow(clippy::large_enum_variant)] pub enum SoftwareAgent { String(String), ClaimGeneratorInfo(ClaimGeneratorInfo), diff --git a/sdk/src/assertions/asset_types.rs b/sdk/src/assertions/asset_types.rs new file mode 100644 index 000000000..8636f018d --- /dev/null +++ b/sdk/src/assertions/asset_types.rs @@ -0,0 +1,151 @@ +// Copyright 2022 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, +// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// or the MIT license (http://opensource.org/licenses/MIT), +// at your option. + +// Unless required by applicable law or agreed to in writing, +// this software is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +// implied. See the LICENSE-MIT and LICENSE-APACHE files for the +// specific language governing permissions and limitations under +// each license. + +#[cfg(feature = "json_schema")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{labels, AssetType, Metadata}; +use crate::{ + assertion::{Assertion, AssertionBase, AssertionCbor}, + error::Result, +}; + +pub enum AssetTypeEnum { + Classifier, + Cluster, + Dataset, + DatasetJax, + DatasetKeras, + DatasetMlNet, + DatasetMxNet, + DatasetOnnx, + DatasetOpenVino, + DatasetPyTorch, + DatasetTensoflow, + FormatNumpy, + FormatProtoBuf, + FormatPickle, + Generator, + GeneratorPrompt, + GeneratorSeed, + Model, + ModelJax, + ModelKeras, + ModelMlNet, + ModelMxNet, + ModelOnnx, + ModelOpenVino, + ModelOpenVinoParameter, + ModelOpenVinoTopology, + ModelPyTorch, + ModelTensorflow, + Regressor, + TensorflowHubModule, + TensorflowSaveModel, + Other(String), +} + +impl From for String { + fn from(val: AssetTypeEnum) -> String { + match val { + AssetTypeEnum::Classifier => "c2pa.types.classifier".into(), + AssetTypeEnum::Cluster => "c2pa.types.cluster".into(), + AssetTypeEnum::Dataset => "c2pa.types.dataset".into(), + AssetTypeEnum::DatasetJax => "c2pa.types.dataset.jax".into(), + AssetTypeEnum::DatasetKeras => "c2pa.types.dataset.keras".into(), + AssetTypeEnum::DatasetMlNet => "c2pa.types.dataset.ml_net".into(), + AssetTypeEnum::DatasetMxNet => "c2pa.types.dataset.mxnet".into(), + AssetTypeEnum::DatasetOnnx => "c2pa.types.dataset.onnx".into(), + AssetTypeEnum::DatasetOpenVino => "c2pa.types.dataset.openvino".into(), + AssetTypeEnum::DatasetPyTorch => "c2pa.types.dataset.pytorch".into(), + AssetTypeEnum::DatasetTensoflow => "c2pa.types.dataset.tensorflow".into(), + AssetTypeEnum::FormatNumpy => "c2pa.types.format.numpy".into(), + AssetTypeEnum::FormatProtoBuf => "c2pa.types.format.protobuf".into(), + AssetTypeEnum::FormatPickle => "c2pa.types.format.pickle".into(), + AssetTypeEnum::Generator => "c2pa.types.generator".into(), + AssetTypeEnum::GeneratorPrompt => "c2pa.types.generator.prompt".into(), + AssetTypeEnum::GeneratorSeed => "c2pa.types.generator.seed".into(), + AssetTypeEnum::Model => "c2pa.types.model".into(), + AssetTypeEnum::ModelJax => "c2pa.types.model.jax".into(), + AssetTypeEnum::ModelKeras => "c2pa.types.model.keras".into(), + AssetTypeEnum::ModelMlNet => "c2pa.types.model.ml_net".into(), + AssetTypeEnum::ModelMxNet => "c2pa.types.model.mxnet".into(), + AssetTypeEnum::ModelOnnx => "c2pa.types.model.onnx".into(), + AssetTypeEnum::ModelOpenVino => "c2pa.types.model.openvino".into(), + AssetTypeEnum::ModelOpenVinoParameter => "c2pa.types.model.openvino.parameter".into(), + AssetTypeEnum::ModelOpenVinoTopology => "c2pa.types.model.openvino.topology".into(), + AssetTypeEnum::ModelPyTorch => "c2pa.types.model.pytorch".into(), + AssetTypeEnum::ModelTensorflow => "c2pa.types.model.tensorflow".into(), + AssetTypeEnum::Regressor => "c2pa.types.regressor".into(), + AssetTypeEnum::TensorflowHubModule => "c2pa.types.tensorflow.hubmodule".into(), + AssetTypeEnum::TensorflowSaveModel => "c2pa.types.tensorflow.savedmodel".into(), + AssetTypeEnum::Other(v) => v, + } + } +} + +const ASSERTION_CREATION_VERSION: usize = 1; + +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +pub struct AssetTypes { + types: Vec, + metadata: Option, +} + +#[allow(dead_code)] +impl AssetTypes { + /// See . + pub const LABEL: &'static str = labels::ASSET_TYPE; + + pub fn new(at: AssetType) -> Self { + AssetTypes { + types: vec![at], + metadata: None, + } + } + + pub fn add_type(mut self, at: AssetType) -> Self { + self.types.push(at); + self + } + + pub fn types(&self) -> &Vec { + &self.types + } + + pub fn set_metadata(mut self, md: Metadata) -> Self { + self.metadata = Some(md); + self + } + + pub fn metadata(&self) -> Option<&Metadata> { + self.metadata.as_ref() + } +} + +impl AssertionCbor for AssetTypes {} + +impl AssertionBase for AssetTypes { + const LABEL: &'static str = Self::LABEL; + const VERSION: Option = Some(ASSERTION_CREATION_VERSION); + + fn to_assertion(&self) -> Result { + Self::to_cbor_assertion(self) + } + + fn from_assertion(assertion: &Assertion) -> Result { + Self::from_cbor_assertion(assertion) + } +} diff --git a/sdk/src/assertions/bmff_hash.rs b/sdk/src/assertions/bmff_hash.rs index f528c8673..ce23c23a6 100644 --- a/sdk/src/assertions/bmff_hash.rs +++ b/sdk/src/assertions/bmff_hash.rs @@ -1064,7 +1064,8 @@ impl BmffHash { mm.hashes = Some(VecByteBuf(proof_vec)); } - let mm_cbor = serde_cbor::to_vec(&mm).map_err(|_err| Error::AssertionEncoding)?; + let mm_cbor = + serde_cbor::to_vec(&mm).map_err(|err| Error::AssertionEncoding(err.to_string()))?; // generate the UUID box let mut uuid_box_data: Vec = Vec::with_capacity(mm_cbor.len() * 2); @@ -1157,8 +1158,8 @@ impl BmffHash { bmff_mm.hashes = Some(VecByteBuf(proof_vec)); } - let mm_cbor = - serde_cbor::to_vec(&bmff_mm).map_err(|_err| Error::AssertionEncoding)?; + let mm_cbor = serde_cbor::to_vec(&bmff_mm) + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; // generate the C2PA Merkle box with final hash let mut uuid_box_data: Vec = Vec::with_capacity(mm_cbor.len() * 2); @@ -1224,7 +1225,7 @@ impl AssertionBase for BmffHash { fn from_assertion(assertion: &Assertion) -> crate::error::Result { let mut bmff_hash = Self::from_cbor_assertion(assertion)?; - bmff_hash.set_bmff_version(assertion.get_ver().unwrap_or(1)); + bmff_hash.set_bmff_version(assertion.get_ver()); Ok(bmff_hash) } diff --git a/sdk/src/assertions/exif.rs b/sdk/src/assertions/exif.rs index c406de81a..e1884704e 100644 --- a/sdk/src/assertions/exif.rs +++ b/sdk/src/assertions/exif.rs @@ -132,7 +132,7 @@ pub mod tests { #![allow(clippy::unwrap_used)] use super::*; - use crate::Manifest; + use crate::builder::Builder; const SPEC_EXAMPLE: &str = r#"{ "@context" : { @@ -169,40 +169,37 @@ pub mod tests { #[test] fn exif_new() { - let mut manifest = Manifest::new("my_app".to_owned()); + let mut builder = Builder::new(); + let original = Exif::new() .insert("exif:GPSLatitude", "39,21.102N") .unwrap(); - manifest.add_assertion(&original).expect("adding assertion"); - println!("{manifest}"); - let exif: Exif = manifest - .find_assertion(Exif::LABEL) - .expect("find_assertion"); + builder + .add_assertion(Exif::LABEL, &original) + .expect("adding assertion"); + let exif: Exif = builder.find_assertion(Exif::LABEL).expect("find_assertion"); let latitude: String = exif.get("exif:GPSLatitude").unwrap(); assert_eq!(&latitude, "39,21.102N") } #[test] fn exif_from_json() { - let mut manifest = Manifest::new("my_app".to_owned()); + let mut builder = Builder::new(); let original = Exif::from_json_str(SPEC_EXAMPLE).expect("from_json"); - manifest.add_assertion(&original).expect("adding assertion"); - println!("{manifest}"); - let exif: Exif = manifest - .find_assertion(Exif::LABEL) - .expect("find_assertion"); + builder + .add_assertion(Exif::LABEL, &original) + .expect("adding assertion"); + let exif: Exif = builder.find_assertion(Exif::LABEL).expect("find_assertion"); let latitude: String = exif.get("exif:GPSLatitude").unwrap(); assert_eq!(&latitude, "39,21.102N") } #[test] - fn exif_to_assertoin() { + fn exif_to_assertion() { let original = Exif::from_json_str(SPEC_EXAMPLE).expect("from_json"); let assertion = original.to_assertion().expect("to_assertion"); assert_eq!(assertion.content_type(), "application/json"); - println!("{assertion:?}"); let result = Exif::from_assertion(&assertion).expect("from_assertion"); - println!("{result:?}"); let latitude: String = result.get("exif:GPSLatitude").unwrap(); assert_eq!(&latitude, "39,21.102N") } diff --git a/sdk/src/assertions/ingredient.rs b/sdk/src/assertions/ingredient.rs index f2171ed9f..fe9f1a3b4 100644 --- a/sdk/src/assertions/ingredient.rs +++ b/sdk/src/assertions/ingredient.rs @@ -13,18 +13,21 @@ #[cfg(feature = "json_schema")] use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; use super::AssetType; use crate::{ - assertion::{Assertion, AssertionBase, AssertionCbor}, + assertion::{Assertion, AssertionBase, AssertionDecodeError, AssertionDecodeErrorCause}, assertions::{labels, Metadata, ReviewRating}, + cbor_types::map_cbor_to_type, error::Result, hashed_uri::HashedUri, + validation_results::ValidationResults, validation_status::ValidationStatus, + Error, }; -const ASSERTION_CREATION_VERSION: usize = 2; +const ASSERTION_CREATION_VERSION: usize = 3; // Used to differentiate a parent from a component #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] @@ -40,33 +43,43 @@ pub enum Relationship { } /// An ingredient assertion -#[derive(Serialize, Deserialize, Debug, Default)] +#[derive(Debug, Default, PartialEq)] pub struct Ingredient { - #[serde(rename = "dc:title")] - pub title: String, - #[serde(rename = "dc:format")] - pub format: String, - #[serde(rename = "documentID", skip_serializing_if = "Option::is_none")] + pub title: Option, + pub format: Option, pub document_id: Option, - #[serde(rename = "instanceID", skip_serializing_if = "Option::is_none")] pub instance_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub c2pa_manifest: Option, - #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] pub validation_status: Option>, pub relationship: Relationship, - #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(rename = "informational_URI", skip_serializing_if = "Option::is_none")] pub informational_uri: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub data_types: Option>, + + pub validation_results: Option, + pub active_manifest: Option, + pub claim_signature: Option, + + pub version: usize, +} + +impl Serialize for Ingredient { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + match self.version { + 1 => self.serialize_v1(serializer), + 2 => self.serialize_v2(serializer), + 3 => self.serialize_v3(serializer), + v => Err(serde::ser::Error::custom(format!( + "Unsupported ingredient version: {v}" + ))), + } + } } impl Ingredient { @@ -77,10 +90,11 @@ impl Ingredient { pub fn new(title: &str, format: &str, instance_id: &str, document_id: Option<&str>) -> Self { Self { - title: title.to_owned(), - format: format.to_owned(), + title: Some(title.to_owned()), + format: Some(format.to_owned()), document_id: document_id.map(|id| id.to_owned()), instance_id: Some(instance_id.to_owned()), + version: 1, ..Default::default() } } @@ -91,19 +105,47 @@ impl Ingredient { S2: Into, { Self { - title: title.into(), - format: format.into(), + title: Some(title.into()), + format: Some(format.into()), + version: 2, ..Default::default() } } + pub fn new_v3(relationship: Relationship) -> Self { + Self { + relationship, + version: 3, + ..Default::default() + } + } + + fn is_v1_compatible(&self) -> bool { + self.title.is_some() + && self.format.is_some() + && self.instance_id.is_some() + && self.data.is_none() // V2 exclusive params + && self.data_types.is_none() + && self.description.is_none() + && self.informational_uri.is_none() + && self.validation_results.is_none() // V3 exclusive params + && self.active_manifest.is_none() + && self.claim_signature.is_none() + } + /// determines if an ingredient is a v2 ingredient - fn is_v2(&self) -> bool { - self.instance_id.is_none() - || self.data.is_some() - || self.description.is_some() - || self.informational_uri.is_some() - || self.data_types.is_some() + fn is_v2_compatible(&self) -> bool { + self.title.is_some() + && self.format.is_some() + && self.validation_results.is_none() // V3 exclusive params + && self.active_manifest.is_none() + && self.claim_signature.is_none() + } + + fn is_v3_compatible(&self) -> bool { + self.document_id.is_none() // V3 restricted fields + && self.validation_status.is_none() + && self.c2pa_manifest.is_none() } pub fn set_parent(mut self) -> Self { @@ -147,9 +189,290 @@ impl Ingredient { Some(validation_status) => validation_status.push(status), } } + + fn serialize_v1(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + /* Ingredient V1 fields + "dc:title": tstr, ; name of the ingredient + "dc:format": format-string, ; Media Type of the ingredient + ? "documentID": tstr, ; value of the ingredient's `xmpMM:DocumentID` + "instanceID": tstr, ; unique identifier, such as the value of the ingredient's `xmpMM:InstanceID` + "relationship": $relation-choice, ; The relationship of this ingredient to the asset it is an ingredient of. + ? "c2pa_manifest": $hashed-uri-map, ; hashed_uri reference to the C2PA Manifest of the ingredient + ? "thumbnail": $hashed-uri-map, ; hashed_uri reference to an ingredient thumbnail + ? "validationStatus": [1* $status-map] ; validation status of the ingredient + ? "metadata": $assertion-metadata-map ; additional information about the assertion + */ + + let mut ingredient_map_len = 4; + if self.document_id.is_some() { + ingredient_map_len += 1 + } + if self.c2pa_manifest.is_some() { + ingredient_map_len += 1 + } + if self.thumbnail.is_some() { + ingredient_map_len += 1 + } + if self.validation_status.is_some() { + ingredient_map_len += 1 + } + if self.metadata.is_some() { + ingredient_map_len += 1 + } + + let mut ingredient_map = serializer.serialize_struct("Ingredient", ingredient_map_len)?; + + // serialize mandatory fields + ingredient_map.serialize_field("dc:title", &self.title)?; + ingredient_map.serialize_field("dc:format", &self.format)?; + if let Some(instance_id) = &self.instance_id { + ingredient_map.serialize_field("instanceID", instance_id)?; + } else { + return Err(serde::ser::Error::custom("Ingredient_v1 miss instanceId")); + } + ingredient_map.serialize_field("relationship", &self.relationship)?; + + // serialize optional fields + if let Some(doc_id) = &self.document_id { + ingredient_map.serialize_field("documentID", doc_id)?; + } + if let Some(cm) = &self.c2pa_manifest { + ingredient_map.serialize_field("c2pa_manifest", cm)?; + } + if let Some(thumbnail) = &self.thumbnail { + ingredient_map.serialize_field("thumbnail", thumbnail)?; + } + if let Some(vs) = &self.validation_status { + ingredient_map.serialize_field("validationStatus", vs)?; + } + if let Some(md) = &self.metadata { + ingredient_map.serialize_field("metadata", md)?; + } + + ingredient_map.end() + } + + fn serialize_v2(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + /* Ingredient V2 fields + "dc:title": tstr, ; name of the ingredient + "dc:format": format-string, ; Media Type of the ingredient + "relationship": $relation-choice, ; The relationship of this ingredient to the asset it is an ingredient of. + ? "documentID": tstr, ; value of the ingredient's `xmpMM:DocumentID` + ? "instanceID": tstr, ; unique identifier, such as the value of the ingredient's `xmpMM:InstanceID` + ? "data" : $hashed-uri-map / $hashed-ext-uri-map, ; hashed_uri reference to a data box or a hashed_ext_uri to external data + ? "data_types": [1* $asset-type-map], ; additional information about the data's type to the ingredient V2 structure. + ? "c2pa_manifest": $hashed-uri-map, ; hashed_uri reference to the C2PA Manifest of the ingredient + ? "thumbnail": $hashed-uri-map, ; hashed_uri reference to a thumbnail in a data box + ? "validationStatus": [1* $status-map] ; validation status of the ingredient + ? "description": tstr .size (1..max-tstr-length) ; Additional description of the ingredient + ? "informational_URI": tstr .size (1..max-tstr-length) ; URI to an informational page about the ingredient or its data + ? "metadata": $assertion-metadata-map ; additional information about the assertion + */ + + let mut ingredient_map_len = 3; + if self.document_id.is_some() { + ingredient_map_len += 1 + } + if self.instance_id.is_some() { + ingredient_map_len += 1 + } + if self.data.is_some() { + ingredient_map_len += 1 + } + if self.data_types.is_some() { + ingredient_map_len += 1 + } + if self.c2pa_manifest.is_some() { + ingredient_map_len += 1 + } + if self.thumbnail.is_some() { + ingredient_map_len += 1 + } + if self.validation_status.is_some() { + ingredient_map_len += 1 + } + if self.description.is_some() { + ingredient_map_len += 1 + } + if self.informational_uri.is_some() { + ingredient_map_len += 1 + } + if self.metadata.is_some() { + ingredient_map_len += 1 + } + + let mut ingredient_map = serializer.serialize_struct("Ingredient", ingredient_map_len)?; + + // serialize mandatory fields + ingredient_map.serialize_field("dc:title", &self.title)?; + ingredient_map.serialize_field("dc:format", &self.format)?; + ingredient_map.serialize_field("relationship", &self.relationship)?; + + // serialize optional fields + if let Some(doc_id) = &self.document_id { + ingredient_map.serialize_field("documentID", doc_id)?; + } + if let Some(instance_id) = &self.instance_id { + ingredient_map.serialize_field("instanceID", instance_id)?; + } + if let Some(data) = &self.data { + ingredient_map.serialize_field("data", data)?; + } + if let Some(data_types) = &self.data_types { + ingredient_map.serialize_field("data_types", data_types)?; + } + if let Some(cm) = &self.c2pa_manifest { + ingredient_map.serialize_field("c2pa_manifest", cm)?; + } + if let Some(thumbnail) = &self.thumbnail { + ingredient_map.serialize_field("thumbnail", thumbnail)?; + } + if let Some(vs) = &self.validation_status { + ingredient_map.serialize_field("validationStatus", vs)?; + } + if let Some(desc) = &self.description { + ingredient_map.serialize_field("description", desc)?; + } + if let Some(info) = &self.informational_uri { + ingredient_map.serialize_field("informational_URI", info)?; + } + if let Some(md) = &self.metadata { + ingredient_map.serialize_field("metadata", md)?; + } + + ingredient_map.end() + } + + fn serialize_v3(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + /* Ingredient V3 fields + ? "dc:title": tstr, ; name of the ingredient + ? "dc:format": format-string, ; Media Type of the ingredient + "relationship": $relation-choice, ; The relationship of this ingredient to the asset it is an ingredient of. + ? "validationResults": $validation-results-map, ; Results from the claim generator performing full validation on the ingredient asset + ? "instanceID": tstr, ; unique identifier such as the value of the ingredient's `xmpMM:InstanceID` + ? "data" : $hashed-uri-map / $hashed-ext-uri-map, ; hashed_uri reference to a data box or a hashed_ext_uri to external data + ? "dataTypes": [1* $asset-type-map], ; additional information about the data's type to the ingredient V3 structure + ? "activeManifest": $hashed-uri-map, ; hashed_uri to the box corresponding to the active manifest of the ingredient + ? "claimSignature": $hashed-uri-map, ; hashed_uri to the Claim Signature box in the C2PA Manifest of the ingredient + ? "thumbnail": $hashed-uri-map, ; hashed_uri reference to a thumbnail in a data box + ? "description": tstr .size (1..max-tstr-length), ; Additional description of the ingredient + ? "informationalURI": tstr .size (1..max-tstr-length), ; URI to an informational page about the ingredient or its data + ? "metadata": $assertion-metadata-map ; additional information about the assertion + */ + + // check rules + if self.active_manifest.is_none() && self.validation_results.is_some() + || self.active_manifest.is_some() && self.validation_results.is_none() + { + return Err(serde::ser::Error::custom( + "Ingredient has incompatible fields", + )); + } + + let mut ingredient_map_len = 1; + if self.title.is_some() { + ingredient_map_len += 1 + } + if self.format.is_some() { + ingredient_map_len += 1 + } + if self.validation_results.is_some() { + ingredient_map_len += 1 + } + if self.instance_id.is_some() { + ingredient_map_len += 1 + } + if self.data.is_some() { + ingredient_map_len += 1 + } + if self.data_types.is_some() { + ingredient_map_len += 1 + } + if self.active_manifest.is_some() { + ingredient_map_len += 1 + } + if self.claim_signature.is_some() { + ingredient_map_len += 1 + } + if self.thumbnail.is_some() { + ingredient_map_len += 1 + } + if self.description.is_some() { + ingredient_map_len += 1 + } + if self.informational_uri.is_some() { + ingredient_map_len += 1 + } + if self.metadata.is_some() { + ingredient_map_len += 1 + } + + let mut ingredient_map = serializer.serialize_struct("Ingredient", ingredient_map_len)?; + + // serialize mandatory fields + ingredient_map.serialize_field("relationship", &self.relationship)?; + + // serialize optional fields + if let Some(title) = &self.title { + ingredient_map.serialize_field("dc:title", title)?; + } + if let Some(format) = &self.format { + ingredient_map.serialize_field("dc:format", format)?; + } + if let Some(vr) = &self.validation_results { + ingredient_map.serialize_field("validationResults", vr)?; + } + if let Some(instance_id) = &self.instance_id { + ingredient_map.serialize_field("instanceID", instance_id)?; + } + if let Some(data) = &self.data { + ingredient_map.serialize_field("data", data)?; + } + if let Some(data_types) = &self.data_types { + ingredient_map.serialize_field("dataTypes", data_types)?; + } + if let Some(am) = &self.active_manifest { + ingredient_map.serialize_field("activeManifest", am)?; + } + if let Some(cs) = &self.claim_signature { + ingredient_map.serialize_field("claimSignature", cs)?; + } + if let Some(thumbnail) = &self.thumbnail { + ingredient_map.serialize_field("thumbnail", thumbnail)?; + } + if let Some(desc) = &self.description { + ingredient_map.serialize_field("description", desc)?; + } + if let Some(info) = &self.informational_uri { + ingredient_map.serialize_field("informationalURI", info)?; + } + if let Some(md) = &self.metadata { + ingredient_map.serialize_field("metadata", md)?; + } + + ingredient_map.end() + } } -impl AssertionCbor for Ingredient {} +fn to_decoding_err(label: &str, version: usize, field: &str) -> Error { + Error::AssertionDecoding(AssertionDecodeError::from_err( + label.to_owned(), + Some(version), + "application/cbor".to_owned(), + AssertionDecodeErrorCause::FieldDecoding { + expected: field.to_owned(), + }, + )) +} impl AssertionBase for Ingredient { const LABEL: &'static str = Self::LABEL; @@ -157,19 +480,285 @@ impl AssertionBase for Ingredient { /// if we require v2 fields then use V2 fn version(&self) -> Option { - if self.is_v2() { - Some(2) + if self.version > 1 { + Some(self.version) } else { - Some(1) + None } } fn to_assertion(&self) -> Result { - Self::to_cbor_assertion(self) + let data = crate::assertion::AssertionData::Cbor( + serde_cbor::to_vec(self).map_err(|err| Error::AssertionEncoding(err.to_string()))?, + ); + Ok(Assertion::new(self.label(), self.version(), data)) } fn from_assertion(assertion: &Assertion) -> Result { - Self::from_cbor_assertion(assertion) + let ingredient_value: serde_cbor::Value = serde_cbor::from_slice(assertion.data()) + .map_err(|e| { + Error::AssertionDecoding(AssertionDecodeError::from_err( + assertion.label(), + Some(assertion.get_ver()), + "application/cbor".to_owned(), + e, + )) + })?; + + let version = assertion.get_ver(); + + static V1_FIELDS: [&str; 9] = [ + "dc:title", + "dc:format", + "documentID", + "instanceID", + "relationship", + "c2pa_manifest", + "thumbnail", + "validationStatus", + "metadata", + ]; + + static V2_FIELDS: [&str; 13] = [ + "dc:title", + "dc:format", + "relationship", + "documentID", + "instanceID", + "data", + "data_types", + "c2pa_manifest", + "thumbnail", + "validationStatus", + "description", + "informational_URI", + "metadata", + ]; + + static V3_FIELDS: [&str; 13] = [ + "dc:title", + "dc:format", + "relationship", + "validationResults", + "instanceID", + "data", + "dataTypes", + "activeManifest", + "claimSignature", + "thumbnail", + "description", + "informationalURI", + "metadata", + ]; + + // make sure decoded matches expected fields + let decoded = match version { + 1 => { + // make sure only V1 fields are present + if let serde_cbor::Value::Map(m) = &ingredient_value { + if !m.keys().all(|v| match v { + serde_cbor::Value::Text(t) => V1_FIELDS.contains(&t.as_str()), + _ => false, + }) { + return Err(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "invalid field found in Ingredient assertion", + )); + } + } else { + return Err(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "invalid field found in Ingredient assertion", + )); + } + + // add mandatory field + let title: String = map_cbor_to_type("dc:title", &ingredient_value).ok_or( + to_decoding_err(&assertion.label(), assertion.get_ver(), "dc:title"), + )?; + let format: String = map_cbor_to_type("dc:format", &ingredient_value).ok_or( + to_decoding_err(&assertion.label(), assertion.get_ver(), "dc:format"), + )?; + let instance_id: String = map_cbor_to_type("instanceID", &ingredient_value).ok_or( + to_decoding_err(&assertion.label(), assertion.get_ver(), "instanceID"), + )?; + let relationship: Relationship = + map_cbor_to_type("relationship", &ingredient_value).ok_or(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "relationship", + ))?; + + // add optional fields + let document_id: Option = map_cbor_to_type("documentID", &ingredient_value); + let c2pa_manifest: Option = + map_cbor_to_type("c2pa_manifest", &ingredient_value); + let thumbnail: Option = map_cbor_to_type("thumbnail", &ingredient_value); + let validation_status: Option> = + map_cbor_to_type("validationStatus", &ingredient_value); + let metadata: Option = map_cbor_to_type("metadata", &ingredient_value); + + Ingredient { + title: Some(title), + format: Some(format), + document_id, + instance_id: Some(instance_id), + c2pa_manifest, + validation_status, + relationship, + thumbnail, + metadata, + version, + ..Default::default() + } + } + 2 => { + // make sure only V2 fields are present + if let serde_cbor::Value::Map(m) = &ingredient_value { + if !m.keys().all(|v| match v { + serde_cbor::Value::Text(t) => V2_FIELDS.contains(&t.as_str()), + _ => false, + }) { + return Err(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "invalid field found in Ingredient assertion", + )); + } + } else { + return Err(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "invalid field found in Ingredient assertion", + )); + } + + // add mandatory field + let title: String = map_cbor_to_type("dc:title", &ingredient_value).ok_or( + to_decoding_err(&assertion.label(), assertion.get_ver(), "dc:title"), + )?; + let format: String = map_cbor_to_type("dc:format", &ingredient_value).ok_or( + to_decoding_err(&assertion.label(), assertion.get_ver(), "dc:format"), + )?; + let relationship: Relationship = + map_cbor_to_type("relationship", &ingredient_value).ok_or(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "relationship", + ))?; + + // add optional fields + let document_id: Option = map_cbor_to_type("documentID", &ingredient_value); + let instance_id: Option = map_cbor_to_type("instanceID", &ingredient_value); + let data: Option = map_cbor_to_type("data", &ingredient_value); + let data_types: Option> = + map_cbor_to_type("data_types", &ingredient_value); + let c2pa_manifest: Option = + map_cbor_to_type("c2pa_manifest", &ingredient_value); + let thumbnail: Option = map_cbor_to_type("thumbnail", &ingredient_value); + let validation_status: Option> = + map_cbor_to_type("validationStatus", &ingredient_value); + let description: Option = + map_cbor_to_type("description", &ingredient_value); + let informational_uri: Option = + map_cbor_to_type("informational_URI", &ingredient_value); + let metadata: Option = map_cbor_to_type("metadata", &ingredient_value); + + Ingredient { + title: Some(title), + format: Some(format), + document_id, + instance_id, + c2pa_manifest, + validation_status, + relationship, + thumbnail, + metadata, + data, + description, + informational_uri, + data_types, + version, + ..Default::default() + } + } + 3 => { + // make sure only V3 fields are present + if let serde_cbor::Value::Map(m) = &ingredient_value { + if !m.keys().all(|v| match v { + serde_cbor::Value::Text(t) => V3_FIELDS.contains(&t.as_str()), + _ => false, + }) { + return Err(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "invalid field found in Ingredient assertion", + )); + } + } else { + return Err(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "invalid field found in Ingredient assertion", + )); + } + + // add mandatory field + let relationship: Relationship = + map_cbor_to_type("relationship", &ingredient_value).ok_or(to_decoding_err( + &assertion.label(), + assertion.get_ver(), + "relationship", + ))?; + + // add optional fields + let title: Option = map_cbor_to_type("dc:title", &ingredient_value); + let format: Option = map_cbor_to_type("dc:format", &ingredient_value); + let validation_results: Option = + map_cbor_to_type("validationResults", &ingredient_value); + let instance_id: Option = map_cbor_to_type("instanceID", &ingredient_value); + let data: Option = map_cbor_to_type("data", &ingredient_value); + let data_types: Option> = + map_cbor_to_type("dataTypes", &ingredient_value); + let active_manifest: Option = + map_cbor_to_type("activeManifest", &ingredient_value); + let claim_signature: Option = + map_cbor_to_type("claimSignature", &ingredient_value); + let thumbnail: Option = map_cbor_to_type("thumbnail", &ingredient_value); + let description: Option = + map_cbor_to_type("description", &ingredient_value); + let informational_uri: Option = + map_cbor_to_type("informationalURI", &ingredient_value); + let metadata: Option = map_cbor_to_type("metadata", &ingredient_value); + + Ingredient { + title, + format, + instance_id, + validation_results, + relationship, + thumbnail, + metadata, + data, + description, + informational_uri, + data_types, + active_manifest, + claim_signature, + version, + ..Default::default() + } + } + _ => { + return Err(Error::VersionCompatibility( + "Ingredient version to new".into(), + )) + } + }; + + Ok(decoded) } } #[cfg(test)] @@ -179,7 +768,11 @@ pub mod tests { #![allow(clippy::unwrap_used)] use super::*; - use crate::assertion::AssertionData; + use crate::{ + assertion::AssertionData, + assertions::AssetTypeEnum, + validation_results::{IngredientDeltaValidationResult, StatusCodes}, + }; #[test] fn assertion_ingredient() { @@ -193,7 +786,7 @@ pub mod tests { let assertion = original.to_assertion().expect("build_assertion"); assert_eq!(assertion.mime_type(), "application/cbor"); assert_eq!(assertion.label(), Ingredient::LABEL); - let result = Ingredient::from_cbor_assertion(&assertion).expect("from_assertion"); + let result = Ingredient::from_assertion(&assertion).expect("from_assertion"); assert_eq!(original.title, result.title); assert_eq!(original.format, result.format); assert_eq!(original.document_id, result.document_id); @@ -280,7 +873,7 @@ pub mod tests { let assertion = original.to_assertion().expect("build_assertion"); assert_eq!(assertion.mime_type(), "application/cbor"); assert_eq!(assertion.label(), Ingredient::LABEL); - let restored = Ingredient::from_cbor_assertion(&assertion).expect("from_assertion"); + let restored = Ingredient::from_assertion(&assertion).expect("from_assertion"); assert_eq!(original.title, restored.title); assert_eq!(original.format, restored.format); assert_eq!(original.document_id, restored.document_id); @@ -305,4 +898,143 @@ pub mod tests { assert_eq!(reviews[0].explanation, "a 3rd party plugin was used"); assert_eq!(reviews[0].value, 1); } + + #[test] + fn test_serialization() { + let validation_status = vec![ValidationStatus::new("claimSignature.validated")]; + + let active_manifest_codes = StatusCodes::default() + .add_success_val(ValidationStatus::new("claimSignature.validated").set_url( + "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature", + )) + .add_success_val(ValidationStatus::new("claimSignature.trusted").set_url( + "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature", + )) + .add_informational_val( + ValidationStatus::new("signingCredential.ocsp.skipped").set_url( + "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature", + ), + ); + + let ingredient_deltas = IngredientDeltaValidationResult::new( + "self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.assertions/c2pa.ingredient.v3", + StatusCodes::default() + .add_failure_val(ValidationStatus::new("assertion.hashedURI.mismatch") + .set_url("self#jumbf=c2pa/urn:c2pa:F095F30E-6CD5-4BF7-8C44-CE8420CA9FB7/c2pa.assertions/c2pa.metadata")) + ); + + let validation_results = ValidationResults::default() + .add_active_manifest(active_manifest_codes) + .add_ingredient_delta(ingredient_deltas); + + let review_rating = ReviewRating::new("Content bindings validated", None, 5); + + let metadata = Metadata::new() + .set_date_time("2021-06-28T16:49:32.874Z".to_owned()) + .add_review(review_rating); + + let data_types = vec![AssetType::new( + AssetTypeEnum::GeneratorPrompt, + Some("1.0.0".into()), + )]; + + let mut all_vals = Ingredient { + title: Some("test_title".to_owned()), + format: Some("image/jpg".to_owned()), + document_id: Some("12345".to_owned()), + instance_id: Some("67890".to_owned()), + c2pa_manifest: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + validation_status: Some(validation_status.clone()), + relationship: Relationship::ParentOf, + thumbnail: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.thumbnail.ingredient_1.jpg".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + metadata: Some(metadata.clone()), + data: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.databoxes/c2pa.data".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + description: Some("Some ingredient description".to_owned()), + informational_uri: Some("https://tfhub.dev/deepmind/bigbigan-resnet50/1".to_owned()), + data_types: Some(data_types.clone()), + validation_results: Some(validation_results.clone()), + active_manifest: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + claim_signature: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + version: 1, + }; + + // Save as V1 + let v1 = all_vals.to_assertion().unwrap(); + + // Save as V2 + all_vals.version = 2; + let v2 = all_vals.to_assertion().unwrap(); + + // Save as V3 + all_vals.version = 3; + let v3 = all_vals.to_assertion().unwrap(); + + // test v1 + let v1_decoded = Ingredient::from_assertion(&v1).unwrap(); + let v1_expected = Ingredient { + title: Some("test_title".to_owned()), + format: Some("image/jpg".to_owned()), + document_id: Some("12345".to_owned()), + instance_id: Some("67890".to_owned()), + c2pa_manifest: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + validation_status: Some(validation_status.clone()), + relationship: Relationship::ParentOf, + thumbnail: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.thumbnail.ingredient_1.jpg".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + metadata: Some(metadata.clone()), + version: 1, + ..Default::default() + }; + assert_eq!(v1_decoded, v1_expected); + assert!(v1_decoded.is_v1_compatible()); + assert!(v1_decoded.is_v2_compatible()); + assert!(!v1_decoded.is_v3_compatible()); + + // test v2 + let v2_decoded = Ingredient::from_assertion(&v2).unwrap(); + let v2_expected = Ingredient { + title: Some("test_title".to_owned()), + format: Some("image/jpg".to_owned()), + document_id: Some("12345".to_owned()), + instance_id: Some("67890".to_owned()), + c2pa_manifest: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + validation_status: Some(validation_status.clone()), + relationship: Relationship::ParentOf, + thumbnail: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.thumbnail.ingredient_1.jpg".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + metadata: Some(metadata.clone()), + data: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.databoxes/c2pa.data".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + description: Some("Some ingredient description".to_owned()), + informational_uri: Some("https://tfhub.dev/deepmind/bigbigan-resnet50/1".to_owned()), + data_types: Some(data_types.clone()), + version: 2, + ..Default::default() + }; + assert_eq!(v2_decoded, v2_expected); + assert!(!v2_decoded.is_v1_compatible()); + assert!(v2_decoded.is_v2_compatible()); + assert!(!v2_decoded.is_v3_compatible()); + + // test v3 + let v3_decoded = Ingredient::from_assertion(&v3).unwrap(); + let v3_expected = Ingredient { + title: Some("test_title".to_owned()), + format: Some("image/jpg".to_owned()), + instance_id: Some("67890".to_owned()), + relationship: Relationship::ParentOf, + thumbnail: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.thumbnail.ingredient_1.jpg".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + metadata: Some(metadata), + data: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.databoxes/c2pa.data".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + description: Some("Some ingredient description".to_owned()), + informational_uri: Some("https://tfhub.dev/deepmind/bigbigan-resnet50/1".to_owned()), + data_types: Some(data_types), + validation_results: Some(validation_results), + active_manifest: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + claim_signature: Some(HashedUri::new("self#jumbf=c2pa/urn:c2pa:5E7B01FC-4932-4BAB-AB32-D4F12A8AA322/c2pa.signature".to_owned(), Some("sha256".to_owned()), &[1,2,3,4,5,6,7,8,9,0])), + version: 3, + ..Default::default() + }; + assert_eq!(v3_decoded, v3_expected); + assert!(!v3_decoded.is_v1_compatible()); + assert!(!v3_decoded.is_v2_compatible()); + assert!(v3_decoded.is_v3_compatible()); + } } diff --git a/sdk/src/assertions/labels.rs b/sdk/src/assertions/labels.rs index c4c5990cb..182a2e3da 100644 --- a/sdk/src/assertions/labels.rs +++ b/sdk/src/assertions/labels.rs @@ -13,7 +13,7 @@ #![deny(missing_docs)] -//! Labels for assertion types as defined in C2PA 1.0 Specification. +//! Labels for assertion types as defined in C2PA 1.0/2.x Specification. //! //! These constants do not include version suffixes. //! @@ -104,6 +104,11 @@ pub const INGREDIENT: &str = "c2pa.ingredient"; /// See . pub const DEPTHMAP: &str = "c2pa.depthmap"; +/// Label prefix for a asset type assertion. +/// +/// See . +pub const ASSET_TYPE: &str = "c2pa.asset-type"; + /// Label prefix for a GDepth depthmap assertion. /// /// See . diff --git a/sdk/src/assertions/metadata.rs b/sdk/src/assertions/metadata.rs index 95d84f157..75ab1e0cb 100644 --- a/sdk/src/assertions/metadata.rs +++ b/sdk/src/assertions/metadata.rs @@ -109,7 +109,7 @@ impl Metadata { } /// Sets the ISO 8601 date-time string when the assertion was created/generated. - pub fn set_date_time(&mut self, date_time: String) -> &mut Self { + pub fn set_date_time(mut self, date_time: String) -> Self { self.date_time = Some(DateT(date_time)); self } @@ -298,6 +298,15 @@ pub struct AssetType { pub version: Option, } +impl AssetType { + pub fn new>(asset_type: S, version: Option) -> Self { + AssetType { + asset_type: asset_type.into(), + version, + } + } +} + #[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] pub struct DataBox { #[serde(rename = "dc:format")] diff --git a/sdk/src/assertions/mod.rs b/sdk/src/assertions/mod.rs index 67865ae53..7809a10e6 100644 --- a/sdk/src/assertions/mod.rs +++ b/sdk/src/assertions/mod.rs @@ -14,8 +14,12 @@ //! Assertion helpers to build, validate, and parse assertions. mod actions; +pub(crate) use actions::V2_DEPRECATED_ACTIONS; pub use actions::{c2pa_action, Action, ActionTemplate, Actions, SoftwareAgent}; +mod asset_types; +pub use asset_types::{AssetTypeEnum, AssetTypes}; + mod bmff_hash; pub use bmff_hash::{BmffHash, BmffMerkleMap, DataMap, ExclusionsMap, SubsetMap}; diff --git a/sdk/src/assertions/user.rs b/sdk/src/assertions/user.rs index 98a1ab253..06cf2331c 100644 --- a/sdk/src/assertions/user.rs +++ b/sdk/src/assertions/user.rs @@ -43,8 +43,8 @@ impl AssertionBase for User { fn to_assertion(&self) -> Result { // validate that the string is valid json, but don't modify it - let _json_value: serde_json::Value = - serde_json::from_str(&self.data).map_err(|_err| Error::AssertionEncoding)?; + let _json_value: serde_json::Value = serde_json::from_str(&self.data) + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; //let data = AssertionData::AssertionJson(json_value.to_string()); let data = AssertionData::Json(self.data.to_owned()); Ok(Assertion::new(&self.label, None, data).set_content_type("application/json")) diff --git a/sdk/src/assertions/user_cbor.rs b/sdk/src/assertions/user_cbor.rs index 21bf163a2..79dd1e88e 100644 --- a/sdk/src/assertions/user_cbor.rs +++ b/sdk/src/assertions/user_cbor.rs @@ -43,8 +43,8 @@ impl AssertionBase for UserCbor { fn to_assertion(&self) -> Result { // validate cbor - let _value: serde_cbor::Value = - serde_cbor::from_slice(&self.cbor_data).map_err(|_err| Error::AssertionEncoding)?; + let _value: serde_cbor::Value = serde_cbor::from_slice(&self.cbor_data) + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; let data = AssertionData::Cbor(self.cbor_data.clone()); Ok(Assertion::new(&self.label, None, data)) } diff --git a/sdk/src/asset_handlers/gif_io.rs b/sdk/src/asset_handlers/gif_io.rs index d84e122e6..efc1a43bc 100644 --- a/sdk/src/asset_handlers/gif_io.rs +++ b/sdk/src/asset_handlers/gif_io.rs @@ -25,15 +25,16 @@ use tempfile::Builder; use crate::{ assertions::{BoxMap, C2PA_BOXHASH}, asset_io::{ - self, AssetBoxHash, AssetIO, AssetPatch, CAIReader, CAIWriter, ComposedManifestRef, - HashBlockObjectType, HashObjectPositions, RemoteRefEmbed, RemoteRefEmbedType, + self, AssetBoxHash, AssetIO, AssetPatch, CAIRead, CAIReadWrite, CAIReader, CAIWriter, + ComposedManifestRef, HashBlockObjectType, HashObjectPositions, RemoteRefEmbed, + RemoteRefEmbedType, }, error::Result, utils::{ io_utils::stream_len, xmp_inmemory_utils::{self, MIN_XMP}, }, - CAIRead, CAIReadWrite, Error, + Error, }; // https://www.w3.org/Graphics/GIF/spec-gif89a.txt diff --git a/sdk/src/asset_handlers/tiff_io.rs b/sdk/src/asset_handlers/tiff_io.rs index a8c29fd31..ba60370e3 100644 --- a/sdk/src/asset_handlers/tiff_io.rs +++ b/sdk/src/asset_handlers/tiff_io.rs @@ -1327,7 +1327,6 @@ where let first_ifd = &tiff_tree[page_0].data; let xmp_ifd_entry = first_ifd.get_tag(XMP_TAG)?; - // make sure the tag type is correct if IFDEntryType::from_u16(xmp_ifd_entry.entry_type)? != IFDEntryType::Byte { return None; diff --git a/sdk/src/builder.rs b/sdk/src/builder.rs index 55cb5eb26..157845a91 100644 --- a/sdk/src/builder.rs +++ b/sdk/src/builder.rs @@ -52,8 +52,11 @@ const ARCHIVE_VERSION: &str = "1"; #[cfg_attr(feature = "json_schema", derive(JsonSchema))] #[non_exhaustive] pub struct ManifestDefinition { + /// The version of the claim. Defaults to 1. + pub claim_version: Option, + /// Optional prefix added to the generated Manifest Label - /// This is typically Internet domain name for the vendor (i.e. `adobe`) + /// This is typically a reverse domain name. pub vendor: Option, /// Claim Generator Info is always required with at least one entry @@ -259,6 +262,10 @@ impl Builder { }) } + pub fn claim_version(&self) -> u8 { + self.definition.claim_version.unwrap_or(1) + } + /// Sets the [`ClaimGeneratorInfo`] for this [`Builder`]. // TODO: Add example of a good ClaimGeneratorInfo. pub fn set_claim_generator_info(&mut self, claim_generator_info: I) -> &mut Self @@ -603,8 +610,16 @@ impl Builder { .join(" "); let mut claim = match definition.label.as_ref() { - Some(label) => Claim::new_with_user_guid(&claim_generator, &label.to_string()), - None => Claim::new(&claim_generator, definition.vendor.as_deref()), + Some(label) => Claim::new_with_user_guid( + &claim_generator, + &label.to_string(), + self.claim_version().into(), + )?, + None => Claim::new( + &claim_generator, + definition.vendor.as_deref(), + self.claim_version().into(), + ), }; // add claim generator info to claim resolving icons @@ -636,9 +651,11 @@ impl Builder { if let Some(title) = definition.title.as_ref() { claim.set_title(Some(title.to_owned())); } - definition.format.clone_into(&mut claim.format); + claim.format = Some(definition.format.clone()); definition.instance_id.clone_into(&mut claim.instance_id); + let salt = DefaultSalt::default(); + if let Some(thumb_ref) = definition.thumbnail.as_ref() { // Setting the format to "none" will ensure that no claim thumbnail is added if thumb_ref.format != "none" { @@ -646,10 +663,13 @@ impl Builder { let mut stream = self.resources.open(thumb_ref)?; let mut data = Vec::new(); stream.read_to_end(&mut data)?; - claim.add_assertion(&Thumbnail::new( - &labels::add_thumbnail_format(labels::CLAIM_THUMBNAIL, &thumb_ref.format), - data, - ))?; + claim.add_assertion_with_salt( + &Thumbnail::new( + &labels::add_thumbnail_format(labels::CLAIM_THUMBNAIL, &thumb_ref.format), + data, + ), + &salt, + )?; } } @@ -665,8 +685,6 @@ impl Builder { ingredient_map.insert(ingredient.instance_id().to_string(), uri); } - let salt = DefaultSalt::default(); - // add any additional assertions for manifest_assertion in &definition.assertions { match manifest_assertion.label.as_str() { @@ -675,12 +693,6 @@ impl Builder { let mut actions: Actions = manifest_assertion.to_assertion()?; - let ingredients_key = match version { - None | Some(1) => "ingredient", - Some(2) => "ingredients", - _ => return Err(Error::AssertionUnsupportedVersion), - }; - let mut updates = Vec::new(); let mut index = 0; #[allow(clippy::explicit_counter_loop)] @@ -694,12 +706,22 @@ impl Builder { //updates.push((action_index, hash_url.clone())); uris.push(hash_url.clone()); } else { - return Err(Error::BadParam(format!( - "Action ingredientId not found: {id}" - ))); + dbg!(format!("Action ingredientId not found: {id}")); + // return Err(Error::BadParam(format!( + // "Action ingredientId not found: {id}" + // ))); } } - update = update.set_parameter(ingredients_key, uris)?; + match version { + Some(1) => { + // only for explicit version 1 (do we need to support this?) + update = update.set_parameter("ingredient", uris[0].clone())? + } + None | Some(2) => { + update = update.set_parameter("ingredients", uris)? + } + _ => return Err(Error::AssertionUnsupportedVersion), + }; updates.push((index, update)); } index += 1; @@ -802,8 +824,13 @@ impl Builder { crate::utils::thumbnail::make_thumbnail_from_stream(format, stream) { stream.rewind()?; + // Do not write this as a file when reading from files + let base_path = self.resources.take_base_path(); self.resources .add(self.definition.instance_id.clone(), image)?; + if let Some(path) = base_path { + self.resources.set_base_path(path) + } self.definition.thumbnail = Some(ResourceRef::new( format, self.definition.instance_id.clone(), @@ -1094,6 +1121,7 @@ mod tests { use crate::{ hash_stream_by_alg, utils::{test::write_jpeg_placeholder_stream, test_signer::test_signer}, + validation_results::ValidationState, Reader, }; @@ -1208,7 +1236,7 @@ mod tests { assert_eq!(definition.format, "image/tiff".to_string()); assert_eq!(definition.instance_id, "1234".to_string()); assert_eq!(definition.thumbnail, Some(thumbnail_ref)); - assert_eq!(definition.ingredients[0].title(), "Parent Test".to_string()); + assert_eq!(definition.ingredients[0].title(), Some("Parent Test")); assert_eq!( definition.assertions[0].label, "org.test.assertion".to_string() @@ -1240,7 +1268,7 @@ mod tests { definition.thumbnail.clone().unwrap().identifier.as_str(), "thumbnail.jpg" ); - assert_eq!(definition.ingredients[0].title(), "Test".to_string()); + assert_eq!(definition.ingredients[0].title(), Some("Test")); assert_eq!( definition.assertions[0].label, "org.test.assertion".to_string() @@ -1273,6 +1301,7 @@ mod tests { let mut dest = Cursor::new(Vec::new()); let mut builder = Builder::from_json(&manifest_json()).unwrap(); + builder.definition.claim_version = Some(1); builder .add_ingredient_from_stream(parent_json().to_string(), format, &mut source) .unwrap(); @@ -1317,7 +1346,7 @@ mod tests { let manifest_store = Reader::from_stream(format, &mut dest).expect("from_bytes"); println!("{}", manifest_store); - assert_eq!(manifest_store.validation_status(), None); + assert_ne!(manifest_store.validation_state(), ValidationState::Invalid); assert!(manifest_store.active_manifest().is_some()); let manifest = manifest_store.active_manifest().unwrap(); assert_eq!(manifest.title().unwrap(), "Test_Manifest"); @@ -1346,6 +1375,7 @@ mod tests { let manifest_store = Reader::from_file(&dest).expect("from_bytes"); println!("{}", manifest_store); + assert_ne!(manifest_store.validation_state(), ValidationState::Invalid); assert_eq!(manifest_store.validation_status(), None); assert_eq!( manifest_store.active_manifest().unwrap().title().unwrap(), @@ -1403,7 +1433,7 @@ mod tests { println!("{}", manifest_store); if format != "c2pa" { // c2pa files will not validate since they have no associated asset - assert_eq!(manifest_store.validation_status(), None); + assert_ne!(manifest_store.validation_state(), ValidationState::Invalid); } assert_eq!( manifest_store.active_manifest().unwrap().title().unwrap(), @@ -1482,13 +1512,12 @@ mod tests { // check to make sure we have a remote url and no manifest data dest.set_position(0); - let _err = c2pa::Reader::from_stream("image/jpeg", &mut dest).expect_err("from_bytes"); + let _err = Reader::from_stream("image/jpeg", &mut dest).expect_err("from_bytes"); // now validate the manifest against the written asset dest.set_position(0); - let reader = - c2pa::Reader::from_manifest_data_and_stream(&manifest_data, "image/jpeg", &mut dest) - .expect("from_bytes"); + let reader = Reader::from_manifest_data_and_stream(&manifest_data, "image/jpeg", &mut dest) + .expect("from_bytes"); println!("{}", reader.json()); assert_eq!(reader.validation_status(), None); @@ -1651,6 +1680,7 @@ mod tests { let reader = Reader::from_stream("image/jpeg", &mut dest).expect("from_bytes"); //println!("{}", reader); + assert_ne!(reader.validation_state(), ValidationState::Invalid); assert_eq!(reader.validation_status(), None); assert_eq!( reader @@ -1842,7 +1872,7 @@ mod tests { assert_eq!(m.ingredients().len(), 3); // Validate a prompt ingredient (with data field) let prompt = &m.ingredients()[1]; - assert_eq!(prompt.title(), "prompt"); + assert_eq!(prompt.title(), Some("prompt")); assert_eq!(prompt.relationship(), &Relationship::InputTo); assert!(prompt.data_ref().is_some()); assert_eq!(prompt.data_ref().unwrap().format, "text/plain"); @@ -1852,7 +1882,7 @@ mod tests { assert_eq!(prompt_data.into_inner(), b"pirate with bird on shoulder"); // Validate a custom AI model ingredient. - assert_eq!(m.ingredients()[2].title(), "Custom AI Model"); + assert_eq!(m.ingredients()[2].title(), Some("Custom AI Model")); assert_eq!(m.ingredients()[2].relationship(), &Relationship::InputTo); assert_eq!( m.ingredients()[2].data_types().unwrap()[0].asset_type, diff --git a/sdk/src/claim.rs b/sdk/src/claim.rs index 67f868c76..503efad58 100644 --- a/sdk/src/claim.rs +++ b/sdk/src/claim.rs @@ -23,7 +23,7 @@ use c2pa_crypto::{ }; use c2pa_status_tracker::{log_item, OneShotStatusTracker, StatusTracker}; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; use serde_json::{json, Map, Value}; use uuid::Uuid; @@ -34,21 +34,23 @@ use crate::{ }, assertions::{ self, - labels::{self, CLAIM}, - AssetType, BmffHash, BoxHash, DataBox, DataHash, Metadata, + labels::{ACTIONS, BMFF_HASH}, + Actions, AssetType, BmffHash, BoxHash, DataBox, DataHash, Metadata, V2_DEPRECATED_ACTIONS, }, asset_io::CAIRead, + cbor_types::map_cbor_to_type, cose_validator::{get_signing_info, get_signing_info_async, verify_cose, verify_cose_async}, error::{Error, Result}, hashed_uri::HashedUri, jumbf::{ self, boxes::{ - CAICBORAssertionBox, CAIJSONAssertionBox, CAIUUIDAssertionBox, JumbfEmbeddedFileBox, + BMFFBox, CAICBORAssertionBox, CAIJSONAssertionBox, CAISignatureBox, + CAIUUIDAssertionBox, JUMBFCBORContentBox, JumbfEmbeddedFileBox, }, labels::{ - box_name_from_uri, manifest_label_from_uri, to_absolute_uri, to_databox_uri, - ASSERTIONS, CREDENTIALS, DATABOX, DATABOXES, SIGNATURE, + box_name_from_uri, manifest_label_from_uri, manifest_label_to_parts, to_absolute_uri, + to_databox_uri, ASSERTIONS, CLAIM, CREDENTIALS, DATABOX, DATABOXES, SIGNATURE, }, }, jumbf_io::get_assetio_handler, @@ -59,12 +61,22 @@ use crate::{ }; const BUILD_HASH_ALG: &str = "sha256"; +const BUILD_VER_SUPPORT: usize = 2; /// JSON structure representing an Assertion reference in a Claim's "assertions" list use HashedUri as C2PAAssertion; const GH_FULL_VERSION_LIST: &str = "Sec-CH-UA-Full-Version-List"; const GH_UA: &str = "Sec-CH-UA"; +const C2PA_NAMESPACE_V2: &str = "urn:c2pa:"; +const C2PA_NAMESPACE_V1: &str = "urn:uuid"; + +static _V2_SPEC_DEPRECATED_ASSERTIONS: [&str; 4] = [ + "stds.iptc", + "stds.iptc.photo-metadata", + "stds.exif", + "c2pa.endorsement", +]; // Enum to encapsulate the data type of the source asset. This simplifies // having different implementations for functions as a single entry point can be @@ -79,6 +91,13 @@ pub enum ClaimAssetData<'a> { StreamFragments(&'a mut dyn CAIRead, &'a Vec, &'a str), } +#[derive(PartialEq, Debug, Eq, Clone)] +pub enum ClaimAssertionType { + V1, // V1 assertion + Gathered, // Machine generated assertion + Created, // User generated assertion +} + // helper struct to allow arbitrary order for assertions stored in jumbf. The instance is // stored separate from the Assertion to allow for late binding to the label. Also, // we can load assertions in any order and know the position without re-parsing label. We also @@ -90,6 +109,7 @@ pub struct ClaimAssertion { hash_val: Vec, hash_alg: String, salt: Option>, + typ: ClaimAssertionType, } impl ClaimAssertion { @@ -99,6 +119,7 @@ impl ClaimAssertion { hashval: &[u8], alg: &str, salt: Option>, + typ: ClaimAssertionType, ) -> ClaimAssertion { ClaimAssertion { assertion, @@ -106,6 +127,7 @@ impl ClaimAssertion { hash_val: hashval.to_vec(), hash_alg: alg.to_string(), salt, + typ, } } @@ -118,7 +140,7 @@ impl ClaimAssertion { pub fn label(&self) -> String { let al_ref = self.assertion.label(); if self.instance > 0 { - if get_thumbnail_type(&al_ref) == labels::INGREDIENT_THUMBNAIL { + if get_thumbnail_type(&al_ref) == assertions::labels::INGREDIENT_THUMBNAIL { format!( "{}__{}.{}", get_thumbnail_type(&al_ref), @@ -165,14 +187,37 @@ impl ClaimAssertion { pub fn is_same_type(&self, input_assertion: &Assertion) -> bool { Assertion::assertions_eq(&self.assertion, input_assertion) } + + pub fn assertion_type(&self) -> ClaimAssertionType { + self.typ.clone() + } } impl fmt::Debug for ClaimAssertion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{:?}, instance: {}", self.assertion, self.instance) + write!( + f, + "{:?}, instance: {}, type: {:?}", + self.assertion, self.instance, self.typ + ) } } +// Claim field names +const CLAIM_GENERATOR_F: &str = "claim_generator"; +const CLAIM_GENERATOR_INFO_F: &str = "claim_generator_info"; +const SIGNATURE_F: &str = "signature"; +const ASSERTIONS_F: &str = "assertions"; +const DC_FORMAT_F: &str = "dc:format"; +const INSTANCE_ID_F: &str = "instanceID"; +const DC_TITLE_F: &str = "dc:title"; +const REDACTED_ASSERTIONS_F: &str = "redacted_assertions"; +const ALG_F: &str = "alg"; +const ALG_SOFT_F: &str = "alg_soft"; +const METADATA_F: &str = "metadata"; +const CREATED_ASSERTIONS_F: &str = "created_assertions"; +const GATHERED_ASSERTIONS_F: &str = "gathered_assertions"; + /// A `Claim` gathers together all the `Assertion`s about an asset /// from an actor at a given time, and may also include one or more /// hashes of the asset itself, and a reference to the previous `Claim`. @@ -181,83 +226,68 @@ impl fmt::Debug for ClaimAssertion { /// assigned a label (`c2pa.claim.v1`) and being either embedded into the /// asset or in the cloud. The claim is cryptographically hashed and /// that hash is signed to produce the claim signature. -#[derive(Deserialize, Serialize, Debug, Default, Clone)] +#[derive(Debug, Default, Clone)] pub struct Claim { // external manifest - #[serde(skip_deserializing, skip_serializing)] remote_manifest: RemoteManifest, // root of CAI store - #[serde(skip_deserializing, skip_serializing)] update_manifest: bool, - #[serde(skip_serializing_if = "Option::is_none", rename = "dc:title")] pub title: Option, // title for this claim, generally the name of the containing asset - #[serde(rename = "dc:format")] - pub format: String, // mime format of document containing this claim + pub format: Option, // mime format of document containing this claim - #[serde(rename = "instanceID")] pub instance_id: String, // instance Id of document containing this claim // Internal list of ingredients - #[serde(skip_deserializing, skip_serializing)] ingredients_store: HashMap>, - #[serde(skip_deserializing, skip_serializing)] signature_val: Vec, // the signature of the loaded/saved claim // root of CAI store - #[serde(skip_deserializing, skip_serializing)] #[allow(dead_code)] root: String, // internal scratch objects - #[serde(skip_deserializing, skip_serializing)] label: String, // label of claim // Internal list of assertions for claim. // These are serialized manually based on need. - #[serde(skip_deserializing, skip_serializing)] assertion_store: Vec, // Internal list of verifiable credentials for claim. // These are serialized manually based on need. - #[serde(skip_deserializing, skip_serializing)] - vc_store: Vec<(HashedUri, AssertionData)>, + vc_store: Vec<(HashedUri, AssertionData)>, // V1 feature - claim_generator: String, // generator of this claim + claim_generator: Option, // generator of this claim pub(crate) claim_generator_info: Option>, /* detailed generator info of this claim */ - signature: String, // link to signature box - assertions: Vec, // list of assertion hashed URIs + signature: String, // link to signature box + assertions: Vec, // list of assertion or created_assertions (V1 assertions or V2 created and gathered combined) hashed URIs. + created_assertions: Vec, // list of assertion or created_assertions (V1) hashed URIs. + gathered_assertions: Option>, // list of gather_assertions (>= V2) // original JSON bytes of claim; only present when reading from asset - #[serde(skip_deserializing, skip_serializing)] original_bytes: Option>, // original JUMBF box order need to recalculate JUMBF box hash - #[serde(skip_deserializing, skip_serializing)] original_box_order: Option>, - #[serde(skip_serializing_if = "Option::is_none")] redacted_assertions: Option>, // list of redacted assertions - #[serde(skip_serializing_if = "Option::is_none")] alg: Option, // hashing algorithm (default to Sha256) - #[serde(skip_serializing_if = "Option::is_none")] alg_soft: Option, // hashing algorithm for soft bindings - #[serde(skip_serializing_if = "Option::is_none")] claim_generator_hints: Option>, - #[serde(skip_serializing_if = "Option::is_none")] metadata: Option>, - #[serde(skip_deserializing, skip_serializing)] data_boxes: Vec<(HashedUri, DataBox)>, /* list of the data boxes and their hashed URIs found for this manifest */ + + claim_version: usize, } /// Enum to define how assertions are are stored when output to json @@ -293,27 +323,61 @@ pub struct JsonOrderedAssertionData { mime_type: String, } -impl Claim { - /// Label prefix for a claim assertion. - /// - /// See . - pub const LABEL: &'static str = assertions::labels::CLAIM; +impl Serialize for Claim { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + if self.claim_version > 1 { + self.serialize_v2(serializer) + } else { + self.serialize_v1(serializer) + } + } +} +impl Claim { /// Create a new claim. /// vendor: name used to label the claim (unique instance number is automatically calculated) /// claim_generator: User agent see c2pa spec for format - pub fn new>(claim_generator: S, vendor: Option<&str>) -> Self { + /// claim_version: the Claim version to generate + pub fn new>( + claim_generator: S, + vendor: Option<&str>, + claim_version: usize, + ) -> Self { let urn = Uuid::new_v4(); let l = match vendor { - Some(v) => format!( - "{}:{}", - v.to_lowercase(), - urn.urn().encode_lower(&mut Uuid::encode_buffer()) - ), - None => urn - .urn() - .encode_lower(&mut Uuid::encode_buffer()) - .to_string(), + Some(v) => { + if claim_version == 1 { + format!( + "{}:{}:{}", + v.to_lowercase(), + C2PA_NAMESPACE_V1, + urn.hyphenated().encode_lower(&mut Uuid::encode_buffer()) + ) + } else { + format!( + "{}:{}:{}", + C2PA_NAMESPACE_V2, + urn.hyphenated().encode_lower(&mut Uuid::encode_buffer()), + v.to_lowercase() + ) + } + } + None => { + if claim_version == 1 { + urn.urn() + .encode_lower(&mut Uuid::encode_buffer()) + .to_string() + } else { + format!( + "{}:{}", + C2PA_NAMESPACE_V2, + urn.hyphenated().encode_lower(&mut Uuid::encode_buffer()) + ) + } + } }; Claim { @@ -324,7 +388,7 @@ impl Claim { label: l, signature: "".to_string(), - claim_generator: claim_generator.into(), + claim_generator: Some(claim_generator.into()), claim_generator_info: None, assertion_store: Vec::new(), vc_store: Vec::new(), @@ -337,28 +401,55 @@ impl Claim { claim_generator_hints: None, title: None, - format: "".to_string(), + format: Some("".to_string()), instance_id: "".to_string(), update_manifest: false, data_boxes: Vec::new(), metadata: None, + claim_version, + created_assertions: Vec::new(), + gathered_assertions: None, } } /// Create a new claim with a user supplied GUID. /// user_guid: is user supplied guid conforming the C2PA spec for manifest names /// claim_generator: User agent see c2pa spec for format - pub fn new_with_user_guid>(claim_generator: S, user_guid: S) -> Self { - Claim { + /// claim_version: the Claim version to generate + pub fn new_with_user_guid>( + claim_generator: S, + user_guid: S, + claim_version: usize, + ) -> Result { + let ug: String = user_guid.into(); + let uuid = + Uuid::try_parse(&ug).map_err(|_e| Error::BadParam("invalid Claim GUID".into()))?; + match uuid.get_version() { + Some(uuid::Version::Random) => (), + _ => return Err(Error::BadParam("invalid Claim GUID".into())), + } + let label = if claim_version == 1 { + uuid.urn() + .encode_lower(&mut Uuid::encode_buffer()) + .to_string() + } else { + format!( + "{}:{}", + C2PA_NAMESPACE_V2, + uuid.hyphenated().encode_lower(&mut Uuid::encode_buffer()) + ) + }; + + Ok(Claim { remote_manifest: RemoteManifest::NoRemote, root: jumbf::labels::MANIFEST_STORE.to_string(), signature_val: Vec::new(), ingredients_store: HashMap::new(), - label: user_guid.into(), // todo figure out how to validate this + label, signature: "".to_string(), - claim_generator: claim_generator.into(), + claim_generator: Some(claim_generator.into()), claim_generator_info: None, assertion_store: Vec::new(), vc_store: Vec::new(), @@ -371,15 +462,377 @@ impl Claim { claim_generator_hints: None, title: None, - format: "".to_string(), + format: None, instance_id: "".to_string(), update_manifest: false, data_boxes: Vec::new(), metadata: None, + claim_version, + created_assertions: Vec::new(), + gathered_assertions: None, + }) + } + + // Deserializer that maps V1/V2 Claim object into our internal Claim representation. Note: Our Claim + // structure is not the Claim from the spec but an amalgamation that allows us to represent any version + pub fn from_value(claim_value: serde_cbor::Value, label: &str, data: &[u8]) -> Result { + // populate claim from the map + // parse possible fields to figure out which version of the claim is possible. + let claim_version = if map_cbor_to_type::>("assertions", &claim_value) + .is_some() + && map_cbor_to_type::>("created_assertions", &claim_value).is_none() + { + 1 + } else if map_cbor_to_type::>("created_assertions", &claim_value).is_some() + && map_cbor_to_type::>("assertions", &claim_value).is_none() + { + 2 + } else { + return Err(Error::ClaimDecoding); + }; + + if claim_version == 1 { + /* Claim V1 fields + "claim_generator": tstr, + "claim_generator_info": [1* generator-info-map], + "signature": jumbf-uri-type, + "assertions": [1* $hashed-uri-map], + "dc:format": tstr, ; + "instanceID": tstr .size (1..max-tstr-length), + ? "dc:title": tstr .size (1..max-tstr-length), + ? "redacted_assertions": [1* jumbf-uri-type], + ? "alg": tstr .size (1..max-tstr-length), + ? "alg_soft": tstr .size (1..max-tstr-length), + ? "metadata": $assertion-metadata-map, + */ + + static V1_FIELDS: [&str; 11] = [ + CLAIM_GENERATOR_F, + CLAIM_GENERATOR_INFO_F, + SIGNATURE_F, + ASSERTIONS_F, + DC_FORMAT_F, + INSTANCE_ID_F, + DC_TITLE_F, + REDACTED_ASSERTIONS_F, + ALG_F, + ALG_SOFT_F, + METADATA_F, + ]; + + // make sure only V1 fields are present + if let serde_cbor::Value::Map(m) = &claim_value { + if !m.keys().all(|v| match v { + serde_cbor::Value::Text(t) => V1_FIELDS.contains(&t.as_str()), + _ => false, + }) { + return Err(Error::ClaimDecoding); + } + } else { + return Err(Error::ClaimDecoding); + } + + let claim_generator: String = + map_cbor_to_type(CLAIM_GENERATOR_F, &claim_value).ok_or(Error::ClaimDecoding)?; + let claim_generator_info: Vec = + map_cbor_to_type(CLAIM_GENERATOR_INFO_F, &claim_value).unwrap_or_default(); + + let signature: String = + map_cbor_to_type(SIGNATURE_F, &claim_value).ok_or(Error::ClaimDecoding)?; + let assertions: Vec = + map_cbor_to_type(ASSERTIONS_F, &claim_value).ok_or(Error::ClaimDecoding)?; + let format: String = + map_cbor_to_type(DC_FORMAT_F, &claim_value).ok_or(Error::ClaimDecoding)?; + let instance_id = + map_cbor_to_type(INSTANCE_ID_F, &claim_value).ok_or(Error::ClaimDecoding)?; + + // optional V1 fields + let title: Option = map_cbor_to_type(DC_TITLE_F, &claim_value); + let redacted_assertions: Option> = + map_cbor_to_type(REDACTED_ASSERTIONS_F, &claim_value); + let alg: Option = map_cbor_to_type(ALG_F, &claim_value); + let alg_soft: Option = map_cbor_to_type(ALG_SOFT_F, &claim_value); + let metadata: Option> = map_cbor_to_type(METADATA_F, &claim_value); + + Ok(Claim { + remote_manifest: RemoteManifest::NoRemote, + update_manifest: false, + title, + format: Some(format), + instance_id, + ingredients_store: HashMap::new(), + signature_val: Vec::new(), + root: jumbf::labels::MANIFEST_STORE.to_string(), + label: label.to_string(), + assertion_store: Vec::new(), + vc_store: Vec::new(), + claim_generator: Some(claim_generator), + claim_generator_info: Some(claim_generator_info), + signature, + assertions, + original_bytes: Some(data.to_owned()), + original_box_order: None, + redacted_assertions, + alg, + alg_soft, + claim_generator_hints: None, + metadata, + data_boxes: Vec::new(), + claim_version, + created_assertions: Vec::new(), + gathered_assertions: None, + }) + } else { + /* Claim V2 fields + "instanceID": tstr .size (1..max-tstr-length), + "claim_generator_info": $generator-info-map, + "signature": jumbf-uri-type, + "created_assertions": [1* $hashed-uri-map], + ? "gathered_assertions": [1* $hashed-uri-map], + ? "dc:title": tstr .size (1..max-tstr-length), + ? "redacted_assertions": [1* jumbf-uri-type], + ? "alg": tstr .size (1..max-tstr-length), + ? "alg_soft": tstr .size (1..max-tstr-length), + ? "metadata": $assertion-metadata-map, + */ + + static V2_FIELDS: [&str; 10] = [ + INSTANCE_ID_F, + CLAIM_GENERATOR_INFO_F, + SIGNATURE_F, + CREATED_ASSERTIONS_F, + GATHERED_ASSERTIONS_F, + DC_TITLE_F, + REDACTED_ASSERTIONS_F, + ALG_F, + ALG_SOFT_F, + METADATA_F, + ]; + + // make sure only V2 fields are present + if let serde_cbor::Value::Map(m) = &claim_value { + if !m.keys().all(|v| match v { + serde_cbor::Value::Text(t) => V2_FIELDS.contains(&t.as_str()), + _ => false, + }) { + return Err(Error::ClaimDecoding); + } + } else { + return Err(Error::ClaimDecoding); + } + + let instance_id = + map_cbor_to_type(INSTANCE_ID_F, &claim_value).ok_or(Error::ClaimDecoding)?; + let claim_generator_info: ClaimGeneratorInfo = + map_cbor_to_type(CLAIM_GENERATOR_INFO_F, &claim_value) + .ok_or(Error::ClaimDecoding)?; + let signature: String = + map_cbor_to_type(SIGNATURE_F, &claim_value).ok_or(Error::ClaimDecoding)?; + let created_assertions: Vec = + map_cbor_to_type(CREATED_ASSERTIONS_F, &claim_value).ok_or(Error::ClaimDecoding)?; + + // optional V2 fields + let gathered_assertions: Option> = + map_cbor_to_type(GATHERED_ASSERTIONS_F, &claim_value); + let title: Option = map_cbor_to_type(DC_TITLE_F, &claim_value); + let redacted_assertions: Option> = + map_cbor_to_type(REDACTED_ASSERTIONS_F, &claim_value); + let alg: Option = map_cbor_to_type(ALG_F, &claim_value); + let alg_soft: Option = map_cbor_to_type(ALG_SOFT_F, &claim_value); + let metadata: Option> = map_cbor_to_type(METADATA_F, &claim_value); + + // create merged list of created and gathered assertions for processing compatibility + let mut assertions = created_assertions.clone(); + if let Some(ga) = &gathered_assertions { + assertions.append(&mut ga.clone()); + } + + Ok(Claim { + remote_manifest: RemoteManifest::NoRemote, + update_manifest: false, + title, + format: None, + instance_id, + ingredients_store: HashMap::new(), + signature_val: Vec::new(), + root: jumbf::labels::MANIFEST_STORE.to_string(), + label: label.to_string(), + assertion_store: Vec::new(), + vc_store: Vec::new(), + claim_generator: None, + claim_generator_info: Some([claim_generator_info].to_vec()), + signature, + assertions, + original_bytes: Some(data.to_owned()), + original_box_order: None, + redacted_assertions, + alg, + alg_soft, + claim_generator_hints: None, + metadata, + data_boxes: Vec::new(), + claim_version, + created_assertions, + gathered_assertions, + }) } } + fn serialize_v1(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + /* Claim V1 fields + "claim_generator": tstr, + "claim_generator_info": [1* generator-info-map], + "signature": jumbf-uri-type, + "assertions": [1* $hashed-uri-map], + "dc:format": tstr, ; + "instanceID": tstr .size (1..max-tstr-length), + ? "dc:title": tstr .size (1..max-tstr-length), + ? "redacted_assertions": [1* jumbf-uri-type], + ? "alg": tstr .size (1..max-tstr-length), + ? "alg_soft": tstr .size (1..max-tstr-length), + ? "metadata": $assertion-metadata-map, + */ + let mut claim_map_len = 6; + if self.title().is_some() { + claim_map_len += 1 + } + if self.redactions().is_some() { + claim_map_len += 1 + } + if self.alg.is_some() { + claim_map_len += 1 + } + if self.alg_soft.is_some() { + claim_map_len += 1 + } + if self.metadata().is_some() { + claim_map_len += 1 + } + + let mut claim_map = serializer.serialize_struct("Claim", claim_map_len)?; + + // serialize mandatory fields + if let Some(cg) = self.claim_generator() { + claim_map.serialize_field(CLAIM_GENERATOR_F, cg)?; + } // todo: what if there is no claim_generator? + + if let Some(cgi) = self.claim_generator_info() { + claim_map.serialize_field(CLAIM_GENERATOR_INFO_F, cgi)?; + } else { + let v: Vec = Vec::new(); + claim_map.serialize_field(CLAIM_GENERATOR_INFO_F, &v)?; + } + + claim_map.serialize_field(SIGNATURE_F, &self.signature)?; + claim_map.serialize_field(ASSERTIONS_F, self.assertions())?; + if let Some(format) = self.format() { + claim_map.serialize_field(DC_FORMAT_F, format)?; + } //todo: what if there is no format? + claim_map.serialize_field(INSTANCE_ID_F, self.instance_id())?; + + // serialize optional fields + if let Some(title) = self.title() { + claim_map.serialize_field(DC_TITLE_F, title)?; + } + if let Some(ra) = self.redactions() { + claim_map.serialize_field(REDACTED_ASSERTIONS_F, ra)?; + } + claim_map.serialize_field(ALG_F, self.alg())?; + if let Some(soft) = self.alg_soft() { + claim_map.serialize_field(ALG_SOFT_F, soft)?; + } + if let Some(md) = self.metadata() { + claim_map.serialize_field(METADATA_F, md)?; + } + + claim_map.end() + } + + fn serialize_v2(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + /* Claim V2 fields + "instanceID": tstr .size (1..max-tstr-length), + "claim_generator_info": $generator-info-map, + "signature": jumbf-uri-type, + "created_assertions": [1* $hashed-uri-map], + ? "gathered_assertions": [1* $hashed-uri-map], + ? "dc:title": tstr .size (1..max-tstr-length), + ? "redacted_assertions": [1* jumbf-uri-type], + ? "alg": tstr .size (1..max-tstr-length), + ? "alg_soft": tstr .size (1..max-tstr-length), + ? "metadata": $assertion-metadata-map, + */ + + let mut claim_map_len = 4; + + if self.gathered_assertions.is_some() { + claim_map_len += 1 + } + if self.title.is_some() { + claim_map_len += 1 + } + if self.redacted_assertions.is_some() { + claim_map_len += 1 + } + if self.alg.is_some() { + claim_map_len += 1 + } + if self.alg_soft.is_some() { + claim_map_len += 1 + } + if self.metadata.is_some() { + claim_map_len += 1 + } + + let mut claim_map = serializer.serialize_struct("Claim", claim_map_len)?; + + // serialize mandatory fields + claim_map.serialize_field(INSTANCE_ID_F, self.instance_id())?; + + if let Some(cgi) = self.claim_generator_info() { + if !cgi.is_empty() { + claim_map.serialize_field(CLAIM_GENERATOR_INFO_F, &cgi[0])?; + } else { + return Err(serde::ser::Error::custom( + "claim_generator_info is mandatory", + )); + } + } else { + return Err(serde::ser::Error::custom( + "claim_generator_info is mandatory", + )); + } + + claim_map.serialize_field(SIGNATURE_F, &self.signature)?; + claim_map.serialize_field(CREATED_ASSERTIONS_F, &self.created_assertions)?; + + // serialize optional fields + if let Some(ga) = &self.gathered_assertions { + claim_map.serialize_field(GATHERED_ASSERTIONS_F, ga)?; + } + if let Some(title) = self.title() { + claim_map.serialize_field(DC_TITLE_F, title)?; + } + if let Some(ra) = self.redactions() { + claim_map.serialize_field(REDACTED_ASSERTIONS_F, ra)?; + } + claim_map.serialize_field(ALG_F, self.alg())?; + if let Some(soft) = self.alg_soft() { + claim_map.serialize_field(ALG_SOFT_F, soft)?; + } + if let Some(md) = self.metadata() { + claim_map.serialize_field(METADATA_F, md)?; + } + + claim_map.end() + } + /// Build a claim and verify its integrity. pub fn build(&mut self) -> Result<()> { // A claim must have a signature box. @@ -387,12 +840,30 @@ impl Claim { self.add_signature_box_link(); } + // make sure we have a claim_generator_info for v2 + if self.claim_version > 1 { + match &self.claim_generator_info { + Some(cgi) => { + if cgi.len() > 1 { + return Err(Error::VersionCompatibility( + "only 1 claim_generator_info allowed".into(), + )); + } + } + None => { + return Err(Error::VersionCompatibility( + "claim_generator_info is mandatory".into(), + )) + } + } + } + Ok(()) } - /// return version this claim supports - pub fn build_version() -> &'static str { - Self::LABEL + /// return max version this Claim supports + pub fn build_version_support() -> String { + format!("{}.v{}", CLAIM, BUILD_VER_SUPPORT) } /// Return the JUMBF label for this claim. @@ -400,6 +871,18 @@ impl Claim { &self.label } + // Return vendor if part of manifest label + pub fn vendor(&self) -> Option { + let mp = manifest_label_to_parts(&self.uri())?; + mp.vendor + } + + // Return version if V2 claim and if part of manifest label + pub fn claim_instance_version(&self) -> Option { + let mp = manifest_label_to_parts(&self.uri())?; + mp.version + } + /// Return the JUMBF URI for this claim. pub fn uri(&self) -> String { jumbf::labels::to_manifest_uri(&self.label) @@ -432,13 +915,13 @@ impl Claim { } /// get claim generator - pub fn claim_generator(&self) -> &str { - &self.claim_generator + pub fn claim_generator(&self) -> Option<&str> { + self.claim_generator.as_deref() } /// get format - pub fn format(&self) -> &str { - &self.format + pub fn format(&self) -> Option<&str> { + self.format.as_deref() } /// get instance_id @@ -491,6 +974,11 @@ impl Claim { self.update_manifest } + // get version of the Claim + pub fn version(&self) -> usize { + self.claim_version + } + pub fn set_remote_manifest + AsRef>( &mut self, remote_url: S, @@ -588,6 +1076,21 @@ impl Claim { self.claim_generator_hints.as_ref() } + pub fn calc_sig_box_hash(claim: &Claim, alg: &str) -> Result> { + let mut hash_bytes = Vec::with_capacity(2048); + + // create a signature and add placeholder data to the CAI store. + let mut sigb = CAISignatureBox::new(); + let signed_data = claim.signature_val().clone(); + + let sigc = JUMBFCBORContentBox::new(signed_data); + sigb.add_signature(Box::new(sigc)); + + sigb.write_box_payload(&mut hash_bytes)?; + + Ok(hash_by_alg(alg, &hash_bytes, None)) + } + pub fn calc_assertion_box_hash( label: &str, assertion: &Assertion, @@ -652,13 +1155,70 @@ impl Claim { &mut self, assertion_builder: &impl AssertionBase, salt_generator: &impl SaltGenerator, + ) -> Result { + self.add_assertion_with_salt_impl(assertion_builder, salt_generator, self.version() > 1) + } + + fn compatibility_checks(&self, assertion: &Assertion) -> Result<()> { + let assertion_version = assertion.get_ver(); + let assertion_label = assertion.label(); + + if assertion_label == ACTIONS { + // check for actions V1 + if assertion_version < 1 { + return Err(Error::VersionCompatibility( + "action assertion version too low".into(), + )); + } + + // check for deprecated actions + let ac = Actions::from_assertion(assertion)?; + for action in ac.actions() { + if V2_DEPRECATED_ACTIONS.contains(&action.action()) { + return Err(Error::VersionCompatibility( + "action assertion has been deprecated".into(), + )); + } + } + } + + // version 1 BMFF hash is deprecated + if assertion_label == BMFF_HASH && assertion_version < 2 { + return Err(Error::VersionCompatibility( + "BMFF hash assertion version too low".into(), + )); + } + + /* + // only allow deprecated assertions in created_assertion list + if V2_SPEC_DEPRECATED_ASSERTIONS.contains(&assertion.label().as_str()) { + return Err(Error::VersionCompatibility( + "C2PA deprecated assertion should be added to gather_assertions".into(), + )); + } + */ + + Ok(()) + } + + fn add_assertion_with_salt_impl( + &mut self, + assertion_builder: &impl AssertionBase, + salt_generator: &impl SaltGenerator, + add_as_created_assertion: bool, ) -> Result { // make sure the assertion is valid let assertion = assertion_builder.to_assertion()?; + let assertion_label = assertion.label(); // Update label if there are multiple instances of // the same claim type. - let as_label = self.make_assertion_instance_label(assertion.label().as_ref()); + let as_label = self.make_assertion_instance_label(assertion_label.as_ref()); + + // check for deprecated assertions when using Claims > V1 + if self.version() > 1 { + self.compatibility_checks(&assertion)? + } // Get salted hash of the assertion's contents. let salt = salt_generator.generate_salt(); @@ -674,13 +1234,93 @@ impl Claim { // Add to assertion store. let (_l, instance) = Claim::assertion_label_from_link(&as_label); - let ca = ClaimAssertion::new(assertion, instance, &hash, self.alg(), salt); + let ca = ClaimAssertion::new( + assertion.clone(), + instance, + &hash, + self.alg(), + salt, + ClaimAssertionType::V1, + ); + + if add_as_created_assertion { + // enforce actions assertion generation rules during creation + if assertion_label == ACTIONS { + let ac = Actions::from_assertion(&assertion)?; + + // The first actions assertion must start with c2pa.created or c2pa.opened + if !self + .created_assertions + .iter() + .any(|a| a.url().contains(ACTIONS)) + { + if let Some(first_action) = ac.actions().first() { + if first_action.action() != "c2pa.created" + && first_action.action() != "c2pa.opened" + { + return Err(Error::AssertionEncoding( + "first action must be c2pa.created or c2pa.opened".to_string(), + )); // todo: placeholder until we have 2.x error codes + } + } else { + // must have an action + return Err(Error::AssertionEncoding( + "actions assertion must have an action".to_string(), + )); // todo: placeholder until we have 2.x error codes + } + } else { + // any other added actions cannot be created or opened + let current_action = Actions::from_assertion(&assertion)?; + if current_action + .actions() + .iter() + .any(|a| a.action() == "c2pa.created" || a.action() == "c2pa.opened") + { + return Err(Error::AssertionEncoding( + "only the first actions assertion can have c2pa.created or c2pa.opened" + .to_string(), + )); // todo: placeholder until we have 2.x error codes + } + } + } + + // add to created assertions list + self.created_assertions.push(c2pa_assertion.clone()); + } + self.assertion_store.push(ca); self.assertions.push(c2pa_assertion.clone()); Ok(c2pa_assertion) } + /// Add a gathered assertion to this claim and verify with a salted assertion store + pub fn add_gathered_assertion_with_salt( + &mut self, + assertion_builder: &impl AssertionBase, + salt_generator: &impl SaltGenerator, + ) -> Result { + if self.claim_version < 2 { + return Err(Error::VersionCompatibility( + "Claim version >= 2 required for gathered assertions".into(), + )); + } + + match self.add_assertion_with_salt_impl(assertion_builder, salt_generator, false) { + Ok(a) => { + match &mut self.gathered_assertions { + Some(ga) => ga.push(a.clone()), + None => { + let new_ga = [a.clone()]; + self.gathered_assertions = Some(new_ga.to_vec()); + } + } + Ok(a) + } + Err(e) => Err(e), + } + } + // Add a new DataBox and return the HashedURI reference pub fn add_databox( &mut self, @@ -696,7 +1336,8 @@ impl Claim { }; // serialize to cbor - let db_cbor = serde_cbor::to_vec(&new_db).map_err(|_err| Error::AssertionEncoding)?; + let db_cbor = + serde_cbor::to_vec(&new_db).map_err(|err| Error::AssertionEncoding(err.to_string()))?; // get the index for the new assertion let mut index = 0; @@ -747,8 +1388,8 @@ impl Claim { let mut uri = C2PAAssertion::new(link, Some(self.alg().to_string()), &hash); uri.add_salt(salt); - let db: DataBox = - serde_cbor::from_slice(databox_cbor).map_err(|_err| Error::AssertionEncoding)?; + let db: DataBox = serde_cbor::from_slice(databox_cbor) + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; // add data box to data box store self.data_boxes.push((uri, db)); @@ -808,6 +1449,13 @@ impl Claim { /// ``` // the "id" value will be used as the label in the vcstore pub fn add_verifiable_credential(&mut self, vc_json: &str) -> Result { + if self.claim_version > 1 { + // VC store is not supported post version 1 + return Err(Error::VersionCompatibility( + "verifiable credentials not supported".into(), + )); + } + let id = Claim::vc_id(vc_json)?; let credential = AssertionData::Json(vc_json.to_string()); @@ -922,6 +1570,16 @@ impl Claim { // Replace existing hash with newly-calculated hash. f.update_hash(target_hash.to_vec()); + // fix up ClaimV2 URI reference too + if let Some(f) = self + .created_assertions + .iter_mut() + .find(|f| f.url().contains(&target_label) && vec_compare(&f.hash(), &original_hash)) + { + // Replace existing hash with newly-calculated hash. + f.update_hash(target_hash.to_vec()); + }; + // clear original since content has changed self.clear_data(); @@ -970,7 +1628,7 @@ impl Claim { fn redact_assertion(&mut self, assertion_uri: &str) -> Result<()> { // cannot redact action assertions per the spec let (label, _instance) = Claim::assertion_label_from_link(assertion_uri); - if label == labels::ACTIONS { + if label == assertions::labels::ACTIONS { return Err(Error::AssertionInvalidRedaction); } @@ -1289,6 +1947,17 @@ impl Claim { .failure(validation_log, Error::ClaimMissingHardBinding)?; } + // must have exactly one hard binding for normal manifests + if claim.hash_assertions().len() != 1 && !claim.update_manifest() { + log_item!( + claim.uri(), + "claim has multiple data bindings", + "verify_internal" + ) + .validation_status(validation_status::HARD_BINDINGS_MULTIPLE) + .failure(validation_log, Error::ClaimMultipleHardBinding)?; + } + // update manifests cannot have data hashes if !claim.hash_assertions().is_empty() && claim.update_manifest() { log_item!( @@ -1534,7 +2203,7 @@ impl Claim { /// are resolved at commit time. pub fn ingredient_assertions(&self) -> Vec<&Assertion> { let dummy_data = AssertionData::Cbor(Vec::new()); - let dummy_ingredient = Assertion::new(labels::INGREDIENT, None, dummy_data); + let dummy_ingredient = Assertion::new(assertions::labels::INGREDIENT, None, dummy_data); self.assertions_by_type(&dummy_ingredient) } @@ -1562,6 +2231,14 @@ impl Claim { mut ingredient: Vec, redactions_opt: Option>, ) -> Result<()> { + // make sure the ingredient is version compatible + if ingredient.iter().any(|x| x.claim_version > self.version()) { + return Err(Error::VersionCompatibility(format!( + "ingredient claim version is newer than claim version {}", + self.version() + ))); + } + // redact assertion from incoming ingredients if let Some(redactions) = &redactions_opt { for redaction in redactions { @@ -1615,11 +2292,24 @@ impl Claim { /// Return reference to the assertions list. /// /// This list matches item-for-item with the `Assertion`s - /// stored in the assertion store. + /// stored in the assertion store. For Claim version > 1 + /// this list includes both created and gathered. Use + /// gathered_assertions() or created_assertions() for specific + /// lists when Claim version is 2 or greater, pub fn assertions(&self) -> &Vec { &self.assertions } + /// Returns list of created assertions for Claim V2 + pub fn created_assertions(&self) -> &Vec { + &self.created_assertions + } + + /// Returns list is Claim V2 gathered assertions if available + pub fn gathered_assertions(&self) -> Option<&Vec> { + self.gathered_assertions.as_ref() + } + /// Returns the cbor binary value of the claim data. /// If this claim was read from a file, returns the exact byte /// sequence that was read from the file. If this claim was @@ -1638,12 +2328,10 @@ impl Claim { /// Create claim from binary data (not including assertions). pub fn from_data(label: &str, data: &[u8]) -> Result { - let mut claim: Claim = serde_cbor::from_slice(data).map_err(|_err| Error::ClaimDecoding)?; - - claim.label = label.to_string(); - claim.original_bytes = Some(data.to_owned()); + let claim_value: serde_cbor::Value = + serde_cbor::from_slice(data).map_err(|_err| Error::ClaimDecoding)?; - Ok(claim) + Claim::from_value(claim_value, label, data) } /// Generate a JSON representation of the Claim @@ -1682,11 +2370,11 @@ impl Claim { let mut to = serde_json::Serializer::new(buf); serde_transcode::transcode(&mut from, &mut to) - .map_err(|_err| Error::AssertionEncoding)?; + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; let buf2 = to.into_inner(); let decoded: Value = serde_json::from_slice(&buf2) - .map_err(|_err| Error::AssertionEncoding)?; + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; json_map.insert(label, decoded); } @@ -1752,7 +2440,7 @@ impl Claim { match claim_assertion.assertion.decode_data() { AssertionData::Json(x) => { let d: Value = serde_json::from_str(x) - .map_err(|_err| Error::AssertionEncoding)?; + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; let j = JsonOrderedAssertionData { label: claim_assertion.label().to_owned(), @@ -1772,11 +2460,11 @@ impl Claim { let mut to = serde_json::Serializer::new(buf); serde_transcode::transcode(&mut from, &mut to) - .map_err(|_err| Error::AssertionEncoding)?; + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; let buf2 = to.into_inner(); let d: Value = serde_json::from_slice(&buf2) - .map_err(|_err| Error::AssertionEncoding)?; + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; let j = JsonOrderedAssertionData { label: claim_assertion.label().to_owned(), @@ -1859,6 +2547,19 @@ impl Claim { } } + pub fn box_name_label_instance(box_name: &str) -> (String, usize) { + if let Some((l, v)) = box_name.rsplit_once(".") { + if v.len() == 2 && v.as_bytes()[0] == b'v' { + if let Some(i_str) = v.get(1..) { + if let Ok(i) = i_str.parse::() { + return (l.into(), i); + } + } + } + } + (box_name.into(), 0) + } + /// Return the label for this assertion given its link pub fn assertion_label_from_link(assertion_link: &str) -> (String, usize) { let v = jumbf::labels::to_normalized_uri(assertion_link); @@ -1866,7 +2567,7 @@ impl Claim { let v2: Vec<&str> = v.split('/').collect(); if let Some(s) = v2.last() { // treat ingredient thumbnails differently ingredient.thumbnail - if get_thumbnail_type(s) == labels::INGREDIENT_THUMBNAIL { + if get_thumbnail_type(s) == assertions::labels::INGREDIENT_THUMBNAIL { let instance = get_thumbnail_instance(s).unwrap_or(0); let label = match get_thumbnail_image_type(s).as_str() { "none" => get_thumbnail_type(s), @@ -1895,7 +2596,7 @@ impl Claim { pub fn label_with_instance(label: &str, instance: usize) -> String { if instance == 0 { label.to_string() - } else if get_thumbnail_type(label) == labels::INGREDIENT_THUMBNAIL { + } else if get_thumbnail_type(label) == assertions::labels::INGREDIENT_THUMBNAIL { let tn_type = get_thumbnail_image_type(label); format!("{}__{}.{}", get_thumbnail_type(label), instance, tn_type) } else { @@ -1986,12 +2687,14 @@ impl Claim { } // Create a JUMBF URI from a claim label. - pub(crate) fn to_claim_uri(manifest_label: &str) -> String { - format!( - "{}/{}", - jumbf::labels::to_manifest_uri(manifest_label), - Self::LABEL - ) + pub(crate) fn to_claim_uri(&self) -> String { + let uri = format!("{}/{}", jumbf::labels::to_manifest_uri(self.label()), CLAIM); + + if self.claim_version > 1 { + format!("{}.v{}", uri, self.claim_version) + } else { + uri + } } } diff --git a/sdk/src/claim_generator_info.rs b/sdk/src/claim_generator_info.rs index b525db0a2..3d08445c0 100644 --- a/sdk/src/claim_generator_info.rs +++ b/sdk/src/claim_generator_info.rs @@ -34,6 +34,9 @@ pub struct ClaimGeneratorInfo { /// hashed URI to the icon (either embedded or remote) #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, + /// A human readable string of the OS the claim generator is running on + #[serde(skip_serializing_if = "Option::is_none")] + pub operating_system: Option, // Any other values that are not part of the standard #[serde(flatten)] other: HashMap, @@ -45,6 +48,7 @@ impl Default for ClaimGeneratorInfo { name: crate::NAME.to_string(), version: Some(env!("CARGO_PKG_VERSION").to_string()), icon: None, + operating_system: None, other: HashMap::new(), } } @@ -56,6 +60,7 @@ impl ClaimGeneratorInfo { name: name.into(), version: None, icon: None, + operating_system: None, // todo: decide if we want to fill in this value other: HashMap::new(), } } diff --git a/sdk/src/cose_sign.rs b/sdk/src/cose_sign.rs index d9a1820ee..7e53de81e 100644 --- a/sdk/src/cose_sign.rs +++ b/sdk/src/cose_sign.rs @@ -54,13 +54,18 @@ use crate::{ pub fn sign_claim(claim_bytes: &[u8], signer: &dyn Signer, box_size: usize) -> Result> { // Must be a valid claim. let label = "dummy_label"; - let _claim = Claim::from_data(label, claim_bytes)?; + let claim = Claim::from_data(label, claim_bytes)?; + + let tss = if claim.version() > 1 { + TimeStampStorage::V2_sigTst2_CTT + } else { + TimeStampStorage::V1_sigTst + }; - // TEMPORARY: assume time stamp V1 until we plumb this through further let signed_bytes = if _sync { - cose_sign(signer, claim_bytes, box_size, TimeStampStorage::V1_sigTst) + cose_sign(signer, claim_bytes, box_size, tss) } else { - cose_sign_async(signer, claim_bytes, box_size, TimeStampStorage::V1_sigTst).await + cose_sign_async(signer, claim_bytes, box_size, tss).await }; match signed_bytes { @@ -270,7 +275,7 @@ mod tests { #[test] #[cfg_attr(not(any(target_arch = "wasm32", feature = "openssl_sign")), ignore)] fn test_sign_claim() { - let mut claim = Claim::new("extern_sign_test", Some("contentauth")); + let mut claim = Claim::new("extern_sign_test", Some("contentauth"), 1); claim.build().unwrap(); let claim_bytes = claim.data().unwrap(); @@ -290,7 +295,7 @@ mod tests { use crate::{cose_sign::sign_claim_async, AsyncSigner}; - let mut claim = Claim::new("extern_sign_test", Some("contentauth")); + let mut claim = Claim::new("extern_sign_test", Some("contentauth"), 1); claim.build().unwrap(); let claim_bytes = claim.data().unwrap(); @@ -340,7 +345,7 @@ mod tests { #[test] fn test_bogus_signer() { - let mut claim = Claim::new("bogus_sign_test", Some("contentauth")); + let mut claim = Claim::new("bogus_sign_test", Some("contentauth"), 1); claim.build().unwrap(); let claim_bytes = claim.data().unwrap(); diff --git a/sdk/src/cose_validator.rs b/sdk/src/cose_validator.rs index a020f702c..044d5334b 100644 --- a/sdk/src/cose_validator.rs +++ b/sdk/src/cose_validator.rs @@ -172,7 +172,7 @@ pub mod tests { fn test_no_timestamp() { let mut validation_log = DetailedStatusTracker::default(); - let mut claim = crate::claim::Claim::new("extern_sign_test", Some("contentauth")); + let mut claim = crate::claim::Claim::new("extern_sign_test", Some("contentauth"), 1); claim.build().unwrap(); let claim_bytes = claim.data().unwrap(); @@ -200,7 +200,7 @@ pub mod tests { let mut validation_log = DetailedStatusTracker::default(); - let mut claim = crate::claim::Claim::new("ocsp_sign_test", Some("contentauth")); + let mut claim = crate::claim::Claim::new("ocsp_sign_test", Some("contentauth"), 1); claim.build().unwrap(); let claim_bytes = claim.data().unwrap(); diff --git a/sdk/src/error.rs b/sdk/src/error.rs index 97373c123..0541df46a 100644 --- a/sdk/src/error.rs +++ b/sdk/src/error.rs @@ -35,7 +35,7 @@ pub enum Error { /// The attempt to serialize the assertion (typically to JSON or CBOR) failed. #[error("unable to encode assertion data")] - AssertionEncoding, + AssertionEncoding(String), #[error(transparent)] AssertionDecoding(#[from] crate::assertion::AssertionDecodeError), @@ -84,6 +84,9 @@ pub enum Error { #[error("claim missing hard binding")] ClaimMissingHardBinding, + #[error("claim contains multiple hard bindings")] + ClaimMultipleHardBinding, + #[error("claim contains self redactions")] ClaimSelfRedact, @@ -291,6 +294,9 @@ pub enum Error { #[error("prerelease content detected")] PrereleaseError, + #[error("capability is not supported by this version: {0}")] + VersionCompatibility(String), + #[error("insufficient memory space for operation")] InsufficientMemory, diff --git a/sdk/src/ingredient.rs b/sdk/src/ingredient.rs index 093d53bf0..947a6d8e0 100644 --- a/sdk/src/ingredient.rs +++ b/sdk/src/ingredient.rs @@ -44,7 +44,8 @@ use crate::{ resource_store::{skip_serializing_resources, ResourceRef, ResourceStore}, store::Store, utils::xmp_inmemory_utils::XmpInfo, - validation_status::{self, status_for_store, ValidationStatus}, + validation_results::ValidationResults, + validation_status::{self, validation_results_for_store, ValidationStatus}, }; #[derive(Debug, Default, Deserialize, Serialize)] @@ -52,11 +53,12 @@ use crate::{ /// An `Ingredient` is any external asset that has been used in the creation of an asset. pub struct Ingredient { /// A human-readable title, generally source filename. - title: String, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, /// The format of the source file as a MIME type. - #[serde(default = "default_format")] - format: String, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, /// Document ID from `xmpMM:DocumentID` in XMP metadata. #[serde(skip_serializing_if = "Option::is_none")] @@ -98,10 +100,14 @@ pub struct Ingredient { #[serde(skip_serializing_if = "Option::is_none")] active_manifest: Option, - /// Validation results. + /// Validation status (Ingredient v1 & v2) #[serde(skip_serializing_if = "Option::is_none")] validation_status: Option>, + /// Validation results (Ingredient.V3) + #[serde(skip_serializing_if = "Option::is_none")] + validation_results: Option, + /// A reference to the actual data of the ingredient. #[serde(skip_serializing_if = "Option::is_none")] data: Option, @@ -139,10 +145,6 @@ fn default_instance_id() -> String { format!("xmp:iid:{}", Uuid::new_v4()) } -fn default_format() -> String { - "application/octet-stream".to_owned() -} - fn default_relationship() -> Relationship { Relationship::default() } @@ -167,8 +169,8 @@ impl Ingredient { S: Into, { Self { - title: title.into(), - format: format.into(), + title: Some(title.into()), + format: Some(format.into()), instance_id: Some(instance_id.into()), ..Default::default() } @@ -193,8 +195,8 @@ impl Ingredient { S2: Into, { Self { - title: title.into(), - format: format.into(), + title: Some(title.into()), + format: Some(format.into()), ..Default::default() } } @@ -210,13 +212,13 @@ impl Ingredient { } /// Returns a user-displayable title for this ingredient. - pub fn title(&self) -> &str { - self.title.as_str() + pub fn title(&self) -> Option<&str> { + self.title.as_deref() } /// Returns a MIME content_type for this asset associated with this ingredient. - pub fn format(&self) -> &str { - self.format.as_str() + pub fn format(&self) -> Option<&str> { + self.format.as_deref() } /// Returns a document identifier if one exists. @@ -276,6 +278,11 @@ impl Ingredient { self.validation_status.as_deref() } + /// Returns a reference to the [`ValidationResults`]s if they exist. + pub fn validation_results(&self) -> Option<&ValidationResults> { + self.validation_results.as_ref() + } + /// Returns a reference to [`Metadata`] if it exists. pub fn metadata(&self) -> Option<&Metadata> { self.metadata.as_ref() @@ -327,7 +334,7 @@ impl Ingredient { /// Sets a human-readable title for this ingredient. pub fn set_title>(&mut self, title: S) -> &mut Self { - self.title = title.into(); + self.title = Some(title.into()); self } @@ -538,8 +545,8 @@ impl Ingredient { match std::fs::File::open(path).map_err(Error::IoError) { Ok(mut file) => Self::from_stream_info(&mut file, &format, &title), Err(_) => Self { - title, - format, + title: Some(title), + format: Some(format), ..Default::default() }, } @@ -578,14 +585,18 @@ impl Ingredient { manifest_bytes: Option>, validation_log: &impl StatusTracker, ) -> Result<()> { + let active_manifest = self.active_manifest.as_deref().unwrap_or_default(); match result { Ok(store) => { - // generate ValidationStatus from ValidationItems filtering for only errors - let statuses = status_for_store(&store, validation_log); + // generate validation results from the store + let validation_results = validation_results_for_store(&store, validation_log); if let Some(claim) = store.provenance_claim() { // if the parent claim is valid and has a thumbnail, use it - if statuses.is_empty() { + if validation_results + .active_manifest() + .is_some_and(|m| m.failure().is_empty()) + { if let Some(hashed_uri) = claim .assertions() .iter() @@ -628,46 +639,43 @@ impl Ingredient { self.set_manifest_data(bytes)?; } - self.validation_status = if statuses.is_empty() { - None - } else { - Some(statuses) - }; + self.validation_status = validation_results.validation_errors(); + self.validation_results = Some(validation_results); + Ok(()) } Err(Error::JumbfNotFound) | Err(Error::ProvenanceMissing) | Err(Error::UnsupportedType) => Ok(()), // no claims but valid file Err(Error::BadParam(desc)) if desc == *"unrecognized file type" => Ok(()), - Err(Error::RemoteManifestUrl(url)) => { - let status = ValidationStatus::new(validation_status::MANIFEST_INACCESSIBLE) - .set_url(url) - .set_explanation("Remote manifest not fetched".to_string()); - self.validation_status = Some(vec![status]); - Ok(()) - } - Err(Error::RemoteManifestFetch(url)) => { - let status = ValidationStatus::new(validation_status::MANIFEST_INACCESSIBLE) - .set_url(url) - .set_explanation("Unable to fetch remote manifest".to_string()); + Err(Error::RemoteManifestUrl(url)) | Err(Error::RemoteManifestFetch(url)) => { + let status = + ValidationStatus::new_failure(validation_status::MANIFEST_INACCESSIBLE) + .set_url(url) + .set_explanation("Remote manifest not fetched".to_string()); + let mut validation_results = ValidationResults::default(); + validation_results.add_status(active_manifest, status.clone()); + self.validation_results = Some(validation_results); self.validation_status = Some(vec![status]); Ok(()) } Err(e) => { // we can ignore the error here because it should have a log entry corresponding to it debug!("ingredient {:?}", e); + + let mut results = ValidationResults::default(); // convert any other error to a validation status let statuses: Vec = validation_log .logged_items() .iter() - .filter_map(ValidationStatus::from_validation_item) - .filter(|s| !validation_status::is_success(s.code())) + .filter_map(ValidationStatus::from_log_item) .collect(); - self.validation_status = if statuses.is_empty() { - None - } else { - Some(statuses) - }; + + for status in statuses { + results.add_status(active_manifest, status.clone()); + } + self.validation_status = results.validation_errors(); + self.validation_results = Some(results); Ok(()) } } @@ -722,7 +730,7 @@ impl Ingredient { let mut ingredient = Self::from_file_info(path); if !path.exists() { - return Err(Error::FileNotFound(ingredient.title)); + return Err(Error::FileNotFound(ingredient.title.unwrap_or_default())); } // configure for writing to folders if that option is set @@ -732,7 +740,7 @@ impl Ingredient { // if options includes a title, use it if let Some(opt_title) = options.title(path) { - ingredient.title = opt_title; + ingredient.title = Some(opt_title); } // optionally generate a hash so we know if the file has changed @@ -806,7 +814,6 @@ impl Ingredient { /// Sets thumbnail if not defined and a valid claim thumbnail is found or add_thumbnails is enabled. /// Instance_id, document_id, and provenance will be overridden if found in the stream. /// Format will be overridden only if it is the default (application/octet-stream). - #[cfg(feature = "unstable_api")] #[async_generic()] pub(crate) fn with_stream>( mut self, @@ -831,8 +838,8 @@ impl Ingredient { }; // only override format if it is the default - if self.format == "application/octet-stream" { - self.format = format.to_string(); + if self.format.is_none() { + self.format = Some(format.to_string()); }; // ensure we have an instance Id for v1 ingredients @@ -1002,30 +1009,42 @@ impl Ingredient { url: ingredient_uri.to_owned(), })?; let ingredient_assertion = assertions::Ingredient::from_assertion(assertion)?; - let mut validation_status = match ingredient_assertion.validation_status.as_ref() { Some(status) => status.clone(), None => Vec::new(), }; - let active_manifest = ingredient_assertion + let mut active_manifest = ingredient_assertion .c2pa_manifest .and_then(|hash_url| manifest_label_from_uri(&hash_url.url())); + // use either the active_manifest or c2pa_manifest field + if active_manifest.is_none() { + active_manifest = ingredient_assertion + .active_manifest + .and_then(|hash_url| manifest_label_from_uri(&hash_url.url())); + } + debug!( - "Adding Ingredient {} {:?}", + "Adding Ingredient {:?} {:?}", ingredient_assertion.title, &active_manifest ); - // todo: find a better way to do this if we keep this code - let mut ingredient = Ingredient::new( - &ingredient_assertion.title, - &ingredient_assertion.format, - &ingredient_assertion - .instance_id - .unwrap_or_else(default_instance_id), - ); - ingredient.document_id = ingredient_assertion.document_id; + let mut ingredient = Ingredient { + title: ingredient_assertion.title, + format: ingredient_assertion.format, + instance_id: ingredient_assertion.instance_id, + document_id: ingredient_assertion.document_id, + relationship: ingredient_assertion.relationship, + active_manifest, + validation_results: ingredient_assertion.validation_results, + metadata: ingredient_assertion.metadata, + description: ingredient_assertion.description, + informational_uri: ingredient_assertion.informational_uri, + data_types: ingredient_assertion.data_types, + ..Default::default() + }; + ingredient.resources.set_label(claim_label); // set the label for relative paths #[cfg(feature = "file_io")] @@ -1069,8 +1088,10 @@ impl Ingredient { None => { error!("failed to get {} from {}", hashed_uri.url(), ingredient_uri); validation_status.push( - ValidationStatus::new(validation_status::ASSERTION_MISSING.to_string()) - .set_url(hashed_uri.url()), + ValidationStatus::new_failure( + validation_status::ASSERTION_MISSING.to_string(), + ) + .set_url(hashed_uri.url()), ); } } @@ -1095,15 +1116,9 @@ impl Ingredient { ingredient.set_data_ref(data_ref)?; } - ingredient.relationship = ingredient_assertion.relationship; - ingredient.active_manifest = active_manifest; if !validation_status.is_empty() { ingredient.validation_status = Some(validation_status) } - ingredient.metadata = ingredient_assertion.metadata; - ingredient.description = ingredient_assertion.description; - ingredient.informational_uri = ingredient_assertion.informational_uri; - ingredient.data_types = ingredient_assertion.data_types; Ok(ingredient) } @@ -1163,8 +1178,14 @@ impl Ingredient { .iter() .find(|c| c.label() == manifest_label) { - let hash = - ingredient_store.get_manifest_box_hash(ingredient_active_claim); // get C2PA 1.2 JUMBF box hash + let hash = ingredient_store + .get_manifest_box_hashes(ingredient_active_claim) + .manifest_box_hash; // get C2PA 1.2 JUMBF box hash + + // todo: must use this when making v3 + let _sig_hash = ingredient_store + .get_manifest_box_hashes(ingredient_active_claim) + .signature_box_hash; // needed for v3 ingredients let uri = jumbf::labels::to_manifest_uri(&manifest_label); @@ -1269,17 +1290,40 @@ impl Ingredient { } }; - let mut ingredient_assertion = assertions::Ingredient::new_v2(&self.title, &self.format); + let mut ingredient_assertion = match claim.version() { + 1 => { + // don't make v1 ingredients anymore, they will always be at least v2 + assertions::Ingredient::new_v2( + self.title().unwrap_or_default(), + self.format().unwrap_or_default(), + ) + } + 2 => { + let mut assertion = assertions::Ingredient::new_v3(self.relationship.clone()); + assertion.title = self.title.clone(); + assertion.format = self.format.clone(); + assertion + } + _ => return Err(Error::UnsupportedType), // todo: better error + }; ingredient_assertion.instance_id = instance_id; - self.document_id - .clone_into(&mut ingredient_assertion.document_id); - ingredient_assertion.c2pa_manifest = c2pa_manifest; + match claim.version() { + 1 => { + ingredient_assertion.document_id = self.document_id.clone(); + ingredient_assertion.c2pa_manifest = c2pa_manifest; + ingredient_assertion + .validation_status + .clone_from(&self.validation_status); + } + 2 => { + ingredient_assertion.active_manifest = c2pa_manifest; + ingredient_assertion.validation_results = self.validation_results.clone(); + } + _ => {} + } ingredient_assertion.relationship = self.relationship.clone(); ingredient_assertion.thumbnail = thumbnail; ingredient_assertion.metadata.clone_from(&self.metadata); - ingredient_assertion - .validation_status - .clone_from(&self.validation_status); ingredient_assertion.data = data; ingredient_assertion .description @@ -1485,8 +1529,8 @@ mod tests { .set_data_ref(ResourceRef::new("format", "id")) .expect("set_data_ref") .add_validation_status(ValidationStatus::new("status_code")); - assert_eq!(ingredient.title(), "title2"); - assert_eq!(ingredient.format(), "format"); + assert_eq!(ingredient.title(), Some("title2")); + assert_eq!(ingredient.format(), Some("format")); assert_eq!(ingredient.instance_id(), "instance_id"); assert_eq!(ingredient.document_id(), Some("document_id")); assert_eq!(ingredient.provenance(), Some("provenance")); @@ -1528,8 +1572,8 @@ mod tests { ingredient.set_title(title); println!("ingredient = {ingredient}"); - assert_eq!(&ingredient.title, title); - assert_eq!(ingredient.format(), format); + assert_eq!(ingredient.title(), Some(title)); + assert_eq!(ingredient.format(), Some(format)); assert!(ingredient.manifest_data().is_some()); assert_eq!(ingredient.metadata(), None); #[cfg(target_arch = "wasm32")] @@ -1551,8 +1595,8 @@ mod tests { ingredient.set_title(title); println!("ingredient = {ingredient}"); - assert_eq!(&ingredient.title, title); - assert_eq!(ingredient.format(), format); + assert_eq!(ingredient.title(), Some(title)); + assert_eq!(ingredient.format(), Some(format)); assert!(ingredient.manifest_data().is_some()); assert_eq!(ingredient.metadata(), None); assert_eq!(ingredient.validation_status(), None); @@ -1571,8 +1615,8 @@ mod tests { ingredient.set_title(title); println!("ingredient = {ingredient}"); - assert_eq!(&ingredient.title, title); - assert_eq!(ingredient.format(), format); + assert_eq!(ingredient.title(), Some(title)); + assert_eq!(ingredient.format(), Some(format)); #[cfg(feature = "add_thumbnails")] assert!(ingredient.thumbnail().is_some()); assert!(ingredient.manifest_data().is_some()); @@ -1597,8 +1641,8 @@ mod tests { .await .expect("from_memory_async"); // println!("ingredient = {ingredient}"); - assert_eq!(&ingredient.title, "untitled"); - assert_eq!(ingredient.format(), format); + assert_eq!(ingredient.title(), Some("untitled")); + assert_eq!(ingredient.format(), Some(format)); assert!(ingredient.provenance().is_some()); assert!(ingredient.provenance().unwrap().starts_with("https:")); assert!(ingredient.manifest_data().is_some()); @@ -1677,12 +1721,15 @@ mod tests_file_io { println!( " {} instance_id: {}, thumb size: {}, manifest_data size: {}", - ingredient.title(), + ingredient.title().unwrap_or_default(), ingredient.instance_id(), thumb_size, manifest_data_size, ); - ingredient.title().len() + ingredient.instance_id().len() + thumb_size + manifest_data_size + ingredient.title().unwrap_or_default().len() + + ingredient.instance_id().len() + + thumb_size + + manifest_data_size } // check for correct thumbnail generation with or without add_thumbnails feature @@ -1705,10 +1752,10 @@ mod tests_file_io { stats(&ingredient); println!("ingredient = {ingredient}"); - assert_eq!(ingredient.title(), "Purple Square.psd"); - assert_eq!(ingredient.format(), "image/vnd.adobe.photoshop"); - assert_eq!(ingredient.thumbnail(), None); // should always be none - assert_eq!(ingredient.manifest_data(), None); + assert_eq!(ingredient.title(), Some("Purple Square.psd")); + assert_eq!(ingredient.format(), Some("image/vnd.adobe.photoshop")); + assert!(ingredient.thumbnail().is_none()); // should always be none + assert!(ingredient.manifest_data().is_none()); } #[test] @@ -1719,8 +1766,8 @@ mod tests_file_io { stats(&ingredient); println!("ingredient = {ingredient}"); - assert_eq!(&ingredient.title, MANIFEST_JPEG); - assert_eq!(ingredient.format(), "image/jpeg"); + assert_eq!(ingredient.title(), Some(MANIFEST_JPEG)); + assert_eq!(ingredient.format(), Some("image/jpeg")); assert!(ingredient.thumbnail_ref().is_some()); // we don't generate this thumbnail assert!(ingredient .thumbnail_ref() @@ -1739,8 +1786,8 @@ mod tests_file_io { stats(&ingredient); println!("ingredient = {ingredient}"); - assert_eq!(&ingredient.title, NO_MANIFEST_JPEG); - assert_eq!(ingredient.format(), "image/jpeg"); + assert_eq!(ingredient.title(), Some(NO_MANIFEST_JPEG)); + assert_eq!(ingredient.format(), Some("image/jpeg")); test_thumbnail(&ingredient, "image/jpeg"); assert_eq!(ingredient.provenance(), None); assert_eq!(ingredient.manifest_data(), None); @@ -1776,8 +1823,8 @@ mod tests_file_io { let ingredient = Ingredient::from_file_with_options(ap, &MyOptions {}).expect("from_file"); stats(&ingredient); - assert_eq!(ingredient.title(), "MyTitle"); - assert_eq!(ingredient.format(), "image/jpeg"); + assert_eq!(ingredient.title(), Some("MyTitle")); + assert_eq!(ingredient.format(), Some("image/jpeg")); assert_eq!(ingredient.hash(), Some("1234568abcdef")); assert_eq!(ingredient.thumbnail_ref().unwrap().format, "image/foo"); // always generated assert_eq!(ingredient.manifest_data(), None); @@ -1792,7 +1839,7 @@ mod tests_file_io { stats(&ingredient); println!("ingredient = {ingredient}"); - assert_eq!(ingredient.title(), "libpng-test.png"); + assert_eq!(ingredient.title(), Some("libpng-test.png")); test_thumbnail(&ingredient, "image/png"); assert_eq!(ingredient.provenance(), None); assert_eq!(ingredient.manifest_data, None); @@ -1806,8 +1853,8 @@ mod tests_file_io { stats(&ingredient); println!("ingredient = {ingredient}"); - assert_eq!(ingredient.title(), BAD_SIGNATURE_JPEG); - assert_eq!(ingredient.format(), "image/jpeg"); + assert_eq!(ingredient.title(), Some(BAD_SIGNATURE_JPEG)); + assert_eq!(ingredient.format(), Some("image/jpeg")); test_thumbnail(&ingredient, "image/jpeg"); assert!(ingredient.manifest_data().is_some()); assert!(ingredient.validation_status().is_some()); @@ -1826,8 +1873,8 @@ mod tests_file_io { stats(&ingredient); println!("ingredient = {ingredient}"); - assert_eq!(ingredient.title(), PRERELEASE_JPEG); - assert_eq!(ingredient.format(), "image/jpeg"); + assert_eq!(ingredient.title(), Some(PRERELEASE_JPEG)); + assert_eq!(ingredient.format(), Some("image/jpeg")); test_thumbnail(&ingredient, "image/jpeg"); assert!(ingredient.provenance().is_some()); assert_eq!(ingredient.manifest_data(), None); @@ -1937,8 +1984,8 @@ mod tests_file_io { println!("ingredient = {ingredient}"); - assert_eq!(ingredient.title(), "prompt"); - assert_eq!(ingredient.format(), "text/plain"); + assert_eq!(ingredient.title(), Some("prompt")); + assert_eq!(ingredient.format(), Some("text/plain")); assert_eq!(ingredient.instance_id(), ""); assert_eq!(ingredient.data_ref().unwrap().identifier, "prompt_id"); assert_eq!(ingredient.data_ref().unwrap().format, "text/plain"); diff --git a/sdk/src/jumbf/boxes.rs b/sdk/src/jumbf/boxes.rs index 70a2fe136..46ba6b295 100644 --- a/sdk/src/jumbf/boxes.rs +++ b/sdk/src/jumbf/boxes.rs @@ -964,9 +964,15 @@ impl BMFFBox for CAIClaimBox { } impl CAIClaimBox { - pub fn new() -> Self { + pub fn new(version: usize) -> Self { + let v = if version > 1 { + format!("{}.v{}", labels::CLAIM, version) + } else { + labels::CLAIM.to_string() + }; + CAIClaimBox { - claim_box: JUMBFSuperBox::new(labels::CLAIM, Some(CAI_CLAIM_UUID)), + claim_box: JUMBFSuperBox::new(&v, Some(CAI_CLAIM_UUID)), } } @@ -979,7 +985,7 @@ impl CAIClaimBox { impl Default for CAIClaimBox { fn default() -> Self { - Self::new() + Self::new(1) } } @@ -2482,7 +2488,7 @@ pub mod tests { // ANCHOR: Claim Box #[test] fn cai_claim_box() { - let mut cb = CAIClaimBox::new(); + let mut cb = CAIClaimBox::new(1); let claim_json = String::from( "{ @@ -2596,7 +2602,7 @@ pub mod tests { cai_store.add_box(Box::new(a_store)); // create a claim & add it to the cai store - let mut cb = CAIClaimBox::new(); + let mut cb = CAIClaimBox::new(1); let claim_json = String::from( "{ \"recorder\" : \"Photoshop\", @@ -2664,7 +2670,7 @@ pub mod tests { cai_store.add_box(Box::new(a_store)); // create a claim & add it to the cai store - let mut cb = CAIClaimBox::new(); + let mut cb = CAIClaimBox::new(1); let claim_json = String::from( "{ \"recorder\" : \"Photoshop\", diff --git a/sdk/src/jumbf/labels.rs b/sdk/src/jumbf/labels.rs index 4af28319b..6bc17d697 100644 --- a/sdk/src/jumbf/labels.rs +++ b/sdk/src/jumbf/labels.rs @@ -178,6 +178,75 @@ pub(crate) fn box_name_from_uri(uri: &str) -> Option { parts.last().map(|b| b.to_string()) } +// Struct deconstructed manifest label +pub(crate) struct ManifestParts { + pub guid: String, + pub is_v1: bool, + pub vendor: Option, + pub version: Option, +} + +// Given a JUMBF URI, return the manifest parts contained within it. +pub(crate) fn manifest_label_to_parts(uri: &str) -> Option { + if let Some(manifest) = manifest_label_from_uri(uri) { + let parts: Vec<&str> = manifest.split(":").collect(); + if parts.len() < 3 { + return None; + } + + let guid; + let mut vendor = None; + let mut version = None; + let is_v1; + + if parts[0] == "urn" || parts[1] == "urn" { + if parts[0] == "urn" { + is_v1 = parts[1] == "uuid"; + + guid = parts[2].to_owned(); + + if !is_v1 { + if parts.len() > 5 { + return None; + } + + if parts.len() > 3 && !parts[3].is_empty() { + vendor = Some(parts[3].to_owned()); + } + + if parts.len() > 4 && !parts[4].is_empty() { + version = Some(parts[4].to_owned()); + } + } + + return Some(ManifestParts { + guid, + is_v1, + vendor, + version, + }); + } else if parts[2] == "uuid" { + // this must be a 1.x path to begin with a vendor + if parts.len() != 4 { + return None; + } + + is_v1 = true; + vendor = Some(parts[0].to_owned()); + guid = parts[3].to_owned(); + + return Some(ManifestParts { + guid, + is_v1, + vendor, + version, + }); + } + } + } + + None +} #[cfg(test)] pub mod tests { #![allow(clippy::unwrap_used)] @@ -271,4 +340,56 @@ pub mod tests { assertion_label_from_uri(&assertion_relative) ); } + + #[test] + fn test_manifest_parts() { + let l1 = to_manifest_uri("urn:c2pa:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + let l2 = to_manifest_uri("urn:c2pa:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4:acme"); + let l3 = to_manifest_uri("urn:c2pa:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4:acme:2_1"); + let l4 = to_manifest_uri("urn:c2pa:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4::2_1"); + let l5 = to_manifest_uri("urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + let l6 = to_manifest_uri("acme:urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + let l7 = to_manifest_uri("urn:c2pa:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4:acme:2_1:extra"); + let l8 = to_manifest_uri("acme:urn:uuid:F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4:2_1"); + + let l1_mp = manifest_label_to_parts(&l1).unwrap(); + assert_eq!(l1_mp.guid, "F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + assert!(!l1_mp.is_v1); + assert_eq!(l1_mp.vendor, None); + assert_eq!(l1_mp.version, None); + + let l2_mp = manifest_label_to_parts(&l2).unwrap(); + assert_eq!(l2_mp.guid, "F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + assert!(!l2_mp.is_v1); + assert_eq!(l2_mp.vendor, Some("acme".to_owned())); + assert_eq!(l2_mp.version, None); + + let l3_mp = manifest_label_to_parts(&l3).unwrap(); + assert_eq!(l3_mp.guid, "F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + assert!(!l3_mp.is_v1); + assert_eq!(l3_mp.vendor, Some("acme".to_owned())); + assert_eq!(l3_mp.version, Some("2_1".to_owned())); + + let l4_mp = manifest_label_to_parts(&l4).unwrap(); + assert_eq!(l4_mp.guid, "F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + assert!(!l4_mp.is_v1); + assert_eq!(l4_mp.vendor, None); + assert_eq!(l4_mp.version, Some("2_1".to_owned())); + + let l5_mp = manifest_label_to_parts(&l5).unwrap(); + assert_eq!(l5_mp.guid, "F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + assert!(l5_mp.is_v1); + assert_eq!(l5_mp.vendor, None); + assert_eq!(l5_mp.version, None); + + let l6_mp = manifest_label_to_parts(&l6).unwrap(); + assert_eq!(l6_mp.guid, "F9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4"); + assert!(l6_mp.is_v1); + assert_eq!(l6_mp.vendor, Some("acme".to_owned())); + assert_eq!(l6_mp.version, None); + + assert!(manifest_label_to_parts(&l7).is_none()); + + assert!(manifest_label_to_parts(&l8).is_none()); + } } diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index 986620756..1eebbabeb 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -22,20 +22,10 @@ //! Some functionality requires you to enable specific crate features, //! as noted in the documentation. //! -//! The library has a new experimental Builder/Reader API that will eventually replace -//! the existing methods of reading and writing C2PA data. +//! The library has a new Builder/Reader API //! The new API focuses on stream support and can do more with fewer methods. -//! It will be supported in all language bindings and build environments. -//! To use the new API, you must enable the `unstable_api` feature, for example: -//! -//! ```text -//! c2pa = {version="0.32.0", features=["unstable_api"]} -//! ``` //! //! # Example: Reading a ManifestStore -//! -//! This example requires the `unstable_api` feature to be enabled. -//! //! ``` //! # use c2pa::Result; //! use c2pa::{assertions::Actions, Reader}; @@ -57,8 +47,6 @@ //! //! # Example: Adding a Manifest to a file //! -//! This example requires the `unstable_api` feature to be enabled. -//! //! ``` //! # use c2pa::Result; //! use std::path::PathBuf; @@ -108,6 +96,7 @@ pub mod cose_sign; pub mod create_signer; pub mod jumbf_io; pub mod settings; +pub mod validation_results; pub mod validation_status; #[cfg(target_arch = "wasm32")] pub mod wasm; @@ -116,7 +105,6 @@ pub mod wasm; pub use assertions::Relationship; #[cfg(feature = "v1_api")] pub use asset_io::{CAIRead, CAIReadWrite}; -#[cfg(feature = "unstable_api")] pub use builder::{Builder, ManifestDefinition}; pub use c2pa_crypto::raw_signature::SigningAlg; pub use callback_signer::{CallbackFunc, CallbackSigner}; @@ -133,19 +121,17 @@ pub use manifest::{Manifest, SignatureInfo}; pub use manifest_assertion::{ManifestAssertion, ManifestAssertionKind}; #[cfg(feature = "v1_api")] pub use manifest_store::ManifestStore; -#[cfg(feature = "v1_api")] pub use manifest_store_report::ManifestStoreReport; -#[cfg(feature = "unstable_api")] -pub use reader::{Reader, ValidationState}; +pub use reader::Reader; pub use resource_store::{ResourceRef, ResourceStore}; pub use signer::{AsyncSigner, RemoteSigner, Signer}; pub use utils::mime::format_from_path; +pub use validation_results::{ValidationResults, ValidationState}; // Internal modules pub(crate) mod assertion; pub(crate) mod asset_handlers; pub(crate) mod asset_io; -#[cfg(feature = "unstable_api")] pub(crate) mod builder; pub(crate) mod callback_signer; pub(crate) mod claim; diff --git a/sdk/src/manifest.rs b/sdk/src/manifest.rs index 800b6e956..2b21cb1ec 100644 --- a/sdk/src/manifest.rs +++ b/sdk/src/manifest.rs @@ -11,13 +11,17 @@ // specific language governing permissions and limitations under // each license. -use std::{borrow::Cow, collections::HashMap, io::Cursor, slice::Iter}; +use std::{borrow::Cow, slice::Iter}; +#[cfg(feature = "v1_api")] +use std::{collections::HashMap, io::Cursor}; #[cfg(feature = "file_io")] use std::{fs::create_dir_all, path::Path}; use async_generic::async_generic; use c2pa_crypto::raw_signature::SigningAlg; -use log::{debug, error}; +use log::debug; +#[cfg(feature = "v1_api")] +use log::error; #[cfg(feature = "json_schema")] use schemars::JsonSchema; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -26,22 +30,24 @@ use uuid::Uuid; use crate::{ assertion::{AssertionBase, AssertionData}, - assertions::{ - labels, Actions, CreativeWork, DataHash, Exif, Metadata, SoftwareAgent, Thumbnail, User, - UserCbor, - }, - asset_io::{CAIRead, CAIReadWrite}, - claim::{Claim, RemoteManifest}, + assertions::{labels, Actions, Metadata, SoftwareAgent, Thumbnail}, + claim::RemoteManifest, error::{Error, Result}, hashed_uri::HashedUri, ingredient::Ingredient, jumbf::labels::{assertion_label_from_uri, to_absolute_uri, to_assertion_uri}, manifest_assertion::ManifestAssertion, resource_store::{mime_from_uri, skip_serializing_resources, ResourceRef, ResourceStore}, - salt::DefaultSalt, store::Store, - AsyncSigner, ClaimGeneratorInfo, HashRange, ManifestAssertionKind, ManifestPatchCallback, - RemoteSigner, Signer, + ClaimGeneratorInfo, ManifestAssertionKind, +}; +#[cfg(feature = "v1_api")] +use crate::{ + assertions::{CreativeWork, DataHash, Exif, User, UserCbor}, + asset_io::{CAIRead, CAIReadWrite}, + claim::Claim, + salt::DefaultSalt, + AsyncSigner, HashRange, ManifestPatchCallback, RemoteSigner, Signer, }; /// A Manifest represents all the information in a c2pa manifest @@ -55,8 +61,8 @@ pub struct Manifest { /// A User Agent formatted string identifying the software/hardware/system produced this claim /// Spaces are not allowed in names, versions can be specified with product/1.0 syntax - #[serde(default = "default_claim_generator")] - pub claim_generator: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub claim_generator: Option, /// A list of claim generator info data identifying the software/hardware/system produced this claim #[serde(skip_serializing_if = "Option::is_none")] @@ -71,16 +77,15 @@ pub struct Manifest { title: Option, /// The format of the source file as a MIME type. - #[serde(default = "default_format")] - format: String, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, /// Instance ID from `xmpMM:InstanceID` in XMP metadata. #[serde(default = "default_instance_id")] instance_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - claim_generator_hints: Option>, - + //#[serde(skip_serializing_if = "Option::is_none")] + // claim_generator_hints: Option>, #[serde(skip_serializing_if = "Option::is_none")] thumbnail: Option, @@ -121,10 +126,6 @@ pub struct Manifest { resources: ResourceStore, } -fn default_claim_generator() -> String { - format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) -} - fn default_instance_id() -> String { format!("xmp:iid:{}", Uuid::new_v4()) } @@ -141,17 +142,24 @@ impl Manifest { /// Create a new Manifest /// requires a claim_generator string (User Agent)) pub fn new>(claim_generator: S) -> Self { + // treat an empty string as None + let claim_generator = claim_generator.into(); + let claim_generator = if claim_generator.is_empty() { + None + } else { + Some(claim_generator) + }; Self { - claim_generator: claim_generator.into(), - format: default_format(), + claim_generator, + format: Some(default_format()), instance_id: default_instance_id(), ..Default::default() } } /// Returns a User Agent formatted string identifying the software/hardware/system produced this claim - pub fn claim_generator(&self) -> &str { - self.claim_generator.as_str() + pub fn claim_generator(&self) -> Option<&str> { + self.claim_generator.as_deref() } /// returns the manifest label for this Manifest, as referenced in a ManifestStore @@ -160,8 +168,8 @@ impl Manifest { } /// Returns a MIME content_type for the asset associated with this manifest. - pub fn format(&self) -> &str { - &self.format + pub fn format(&self) -> Option<&str> { + self.format.as_deref() } /// Returns the instance identifier. @@ -223,6 +231,7 @@ impl Manifest { } } + #[cfg(feature = "v1_api")] /// Sets the vendor prefix to be used when generating manifest labels /// Optional prefix added to the generated Manifest Label /// This is typically a lower case Internet domain name for the vendor (i.e. `adobe`) @@ -231,6 +240,7 @@ impl Manifest { self } + #[cfg(feature = "v1_api")] /// Sets the label for this manifest /// A label will be generated if this is not called /// This is needed if embedding a URL that references the manifest label @@ -239,30 +249,35 @@ impl Manifest { self } + #[cfg(feature = "v1_api")] /// Sets a human readable name for the product that created this manifest pub fn set_claim_generator>(&mut self, generator: S) -> &mut Self { - self.claim_generator = generator.into(); + self.claim_generator = Some(generator.into()); self } + #[cfg(feature = "v1_api")] /// Sets a human-readable title for this ingredient. pub fn set_format>(&mut self, format: S) -> &mut Self { - self.format = format.into(); + self.format = Some(format.into()); self } + #[cfg(feature = "v1_api")] /// Sets a human-readable title for this ingredient. pub fn set_instance_id>(&mut self, instance_id: S) -> &mut Self { self.instance_id = instance_id.into(); self } + #[cfg(feature = "v1_api")] /// Sets a human-readable title for this ingredient. pub fn set_title>(&mut self, title: S) -> &mut Self { self.title = Some(title.into()); self } + #[cfg(feature = "v1_api")] /// Sets the thumbnail from a ResourceRef. pub fn set_thumbnail_ref(&mut self, thumbnail: ResourceRef) -> Result<&mut Self> { // verify the resource referenced exists @@ -273,6 +288,7 @@ impl Manifest { Ok(self) } + #[cfg(feature = "v1_api")] /// Sets the thumbnail format and image data. pub fn set_thumbnail, B: Into>>( &mut self, @@ -290,6 +306,7 @@ impl Manifest { Ok(self) } + #[cfg(feature = "v1_api")] /// If set, the embed calls will create a sidecar .c2pa manifest file next to the output file /// No change will be made to the output file pub fn set_sidecar_manifest(&mut self) -> &mut Self { @@ -297,6 +314,7 @@ impl Manifest { self } + #[cfg(feature = "v1_api")] /// If set, the embed calls will put the remote url into the output file xmp provenance /// and create a c2pa manifest file next to the output file pub fn set_remote_manifest>(&mut self, remote_url: S) -> &mut Self { @@ -304,6 +322,7 @@ impl Manifest { self } + #[cfg(feature = "v1_api")] /// If set, the embed calls will put the remote url into the output file xmp provenance /// and will embed the manifest into the output file pub fn set_embedded_manifest_with_remote_ref>( @@ -323,6 +342,7 @@ impl Manifest { self.ingredients.iter().find(|i| i.is_parent()) } + #[cfg(feature = "v1_api")] /// Sets the parent ingredient, assuring it is first and setting the is_parent flag pub fn set_parent(&mut self, mut ingredient: Ingredient) -> Result<&mut Self> { // there should only be one parent so return an error if we already have one @@ -342,6 +362,7 @@ impl Manifest { self } + #[cfg(feature = "v1_api")] /// Adds assertion using given label and any serde serializable /// The data for predefined assertions must be in correct format /// @@ -367,6 +388,7 @@ impl Manifest { Ok(self) } + #[cfg(feature = "v1_api")] /// TO DO: Add docs pub fn add_cbor_assertion, T: Serialize>( &mut self, @@ -378,6 +400,7 @@ impl Manifest { Ok(self) } + #[cfg(feature = "v1_api")] /// Adds ManifestAssertions from existing assertions /// The data for standard assertions must be in correct format /// @@ -449,6 +472,7 @@ impl Manifest { /// Redacts an assertion from the parent [Ingredient] of this manifest using the provided /// assertion label. + #[cfg(feature = "v1_api")] pub fn add_redaction>(&mut self, label: S) -> Result<&mut Self> { // todo: any way to verify if this assertion exists in the parent claim here? match self.redactions.as_mut() { @@ -459,8 +483,10 @@ impl Manifest { } /// Add verifiable credentials + #[cfg(feature = "v1_api")] pub fn add_verifiable_credential(&mut self, data: &T) -> Result<&mut Self> { - let value = serde_json::to_value(data).map_err(|_err| Error::AssertionEncoding)?; + let value = + serde_json::to_value(data).map_err(|err| Error::AssertionEncoding(err.to_string()))?; match self.credentials.as_mut() { Some(credentials) => credentials.push(value), None => self.credentials = Some([value].to_vec()), @@ -497,6 +523,7 @@ impl Manifest { } /// Creates a Manifest from a JSON string formatted as a Manifest + #[cfg(feature = "v1_api")] pub fn from_json(json: &str) -> Result { serde_json::from_slice(json.as_bytes()).map_err(Error::JsonError) } @@ -529,10 +556,14 @@ impl Manifest { label: manifest_label.to_owned(), })?; - // extract vendor from claim label - let claim_generator = claim.claim_generator().to_owned(); - - let mut manifest = Manifest::new(claim_generator); + let mut manifest = Manifest { + claim_generator: claim.claim_generator().map(|s| s.to_owned()), + title: claim.title().map(|s| s.to_owned()), + format: claim.format().map(|s| s.to_owned()), + instance_id: claim.instance_id().to_owned(), + label: Some(claim.label().to_owned()), + ..Default::default() + }; #[cfg(feature = "file_io")] if let Some(base_path) = resource_path { @@ -557,9 +588,7 @@ impl Manifest { } } - manifest.set_label(claim.label()); manifest.resources.set_label(claim.label()); // default manifest for relative urls - manifest.claim_generator_hints = claim.get_claim_generator_hint_map().cloned(); // get credentials converting from AssertionData to Value let credentials: Vec = claim @@ -581,12 +610,6 @@ impl Manifest { .collect() }); - if let Some(title) = claim.title() { - manifest.set_title(title); - } - manifest.set_format(claim.format()); - manifest.set_instance_id(claim.instance_id()); - manifest.assertion_references = claim .assertions() .iter() @@ -727,17 +750,18 @@ impl Manifest { /// the information in the claim should reflect the state of the asset it is embedded in /// this method can be used to ensure that data is correct /// it will extract filename,format and xmp info and generate a thumbnail + #[cfg(feature = "v1_api")] #[cfg(feature = "file_io")] pub fn set_asset_from_path>(&mut self, path: P) -> Result<()> { // Gather the information we need from the target path let ingredient = Ingredient::from_file_info(path.as_ref()); - self.set_format(ingredient.format()); + self.set_format(ingredient.format().unwrap_or_default()); self.set_instance_id(ingredient.instance_id()); // if there is already an asset title preserve it - if self.title().is_none() { - self.set_title(ingredient.title()); + if self.title().is_none() && ingredient.title().is_some() { + self.set_title(ingredient.title().unwrap_or_default()); } // if a thumbnail is not already defined, create one here @@ -755,19 +779,20 @@ impl Manifest { Ok(()) } + #[cfg(feature = "v1_api")] // Convert a Manifest into a Claim pub(crate) fn to_claim(&self) -> Result { // add library identifier to claim_generator let generator = format!( "{} {}/{}", - &self.claim_generator, + self.claim_generator().unwrap_or_default(), crate::NAME, crate::VERSION ); let mut claim = match self.label() { - Some(label) => Claim::new_with_user_guid(&generator, &label.to_string()), - None => Claim::new(&generator, self.vendor.as_deref()), + Some(label) => Claim::new_with_user_guid(&generator, &label.to_string(), 1)?, + None => Claim::new(&generator, self.vendor.as_deref(), 1), }; if let Some(info_vec) = self.claim_generator_info.as_ref() { @@ -796,9 +821,11 @@ impl Manifest { } if let Some(title) = self.title() { - claim.set_title(Some(title.to_owned())); + claim.set_title(Some(title.to_string())); + } + if let Some(format) = self.format() { + claim.format = Some(format.to_string()); } - self.format().clone_into(&mut claim.format); self.instance_id().clone_into(&mut claim.instance_id); if let Some(thumb_ref) = self.thumbnail_ref() { @@ -973,11 +1000,15 @@ impl Manifest { ), ManifestAssertionKind::Binary => { // todo: Support binary kinds - return Err(Error::AssertionEncoding); + return Err(Error::AssertionEncoding( + "Binary assertions not supported".to_string(), + )); } ManifestAssertionKind::Uri => { // todo: Support binary kinds - return Err(Error::AssertionEncoding); + return Err(Error::AssertionEncoding( + "Uri assertions not supported".to_string(), + )); } }, }?; @@ -986,6 +1017,7 @@ impl Manifest { Ok(claim) } + #[cfg(feature = "v1_api")] // Convert a Manifest into a Store pub(crate) fn to_store(&self) -> Result { let claim = self.to_claim()?; @@ -998,6 +1030,7 @@ impl Manifest { // factor out this code to set up the destination path with a file // so we can use set_asset_from_path to initialize the right fields in Manifest #[cfg(feature = "file_io")] + #[cfg(feature = "v1_api")] fn embed_prep>(&mut self, source_path: P, dest_path: P) -> Result

{ let mut copied = false; @@ -1054,6 +1087,7 @@ impl Manifest { /// ``` #[cfg(feature = "file_io")] #[deprecated(since = "0.35.0", note = "use Builder.sign_file instead")] + #[cfg(feature = "v1_api")] pub fn embed>( &mut self, source_path: P, @@ -1074,6 +1108,7 @@ impl Manifest { /// returns the bytes of the manifest that was embedded #[allow(deprecated)] #[deprecated(since = "0.35.0", note = "use Builder.sign with Cursor instead")] + #[cfg(feature = "v1_api")] #[async_generic(async_signature( &mut self, format: &str, @@ -1104,6 +1139,7 @@ impl Manifest { /// /// Returns the bytes of the new asset #[deprecated(since = "0.35.0", note = "obsolete test")] + #[cfg(feature = "v1_api")] pub fn embed_stream( &mut self, format: &str, @@ -1123,6 +1159,7 @@ impl Manifest { /// /// Returns the bytes of c2pa_manifest that was embedded. #[allow(deprecated)] + #[cfg(feature = "v1_api")] #[async_generic(async_signature( &mut self, format: &str, @@ -1173,6 +1210,7 @@ impl Manifest { since = "0.35.0", note = "use Builder.sign with memory Cursor and direct_cose_handling signer instead" )] + #[cfg(feature = "v1_api")] pub async fn embed_from_memory_remote_signed( &mut self, format: &str, @@ -1212,6 +1250,7 @@ impl Manifest { /// Embed a signed manifest into the target file using a supplied [`AsyncSigner`]. #[cfg(feature = "file_io")] #[deprecated(since = "0.35.0", note = "use Builder.sign_file_async instead")] + #[cfg(feature = "v1_api")] pub async fn embed_async_signed>( &mut self, source_path: P, @@ -1234,6 +1273,7 @@ impl Manifest { since = "0.35.0", note = "use Builder.sign_file with cose_handling enabled signer." )] + #[cfg(feature = "v1_api")] pub async fn embed_remote_signed>( &mut self, source_path: P, @@ -1253,6 +1293,7 @@ impl Manifest { /// Embed a signed manifest into fragmented BMFF content (i.e. DASH) assets using a supplied signer. #[cfg(feature = "file_io")] #[deprecated(since = "0.35.0", note = "use Builder.sign_fragmented_files.")] + #[cfg(feature = "v1_api")] pub fn embed_to_bmff_fragmented>( &mut self, asset_path: P, @@ -1279,6 +1320,7 @@ impl Manifest { /// This should only be used for special cases, such as converting an embedded manifest /// to a cloud manifest #[cfg(feature = "file_io")] + #[cfg(feature = "v1_api")] pub fn remove_manifest>(asset_path: P) -> Result<()> { use crate::jumbf_io::remove_jumbf_from_file; remove_jumbf_from_file(asset_path.as_ref()) @@ -1295,6 +1337,7 @@ impl Manifest { since = "0.35.0", note = "use Builder.sign_data_hashed_placeholder instead" )] + #[cfg(feature = "v1_api")] pub fn data_hash_placeholder(&mut self, reserve_size: usize, format: &str) -> Result> { let dh: Result = self.find_assertion(DataHash::LABEL); if dh.is_err() { @@ -1321,6 +1364,7 @@ impl Manifest { since = "0.35.0", note = "use Builder.sign_data_hashed_embeddable instead" )] + #[cfg(feature = "v1_api")] #[async_generic(async_signature( &mut self, dh: &DataHash, @@ -1359,6 +1403,7 @@ impl Manifest { since = "0.35.0", note = "use Builder.sign_data_hashed_embeddable instead" )] + #[cfg(feature = "v1_api")] pub async fn data_hash_embeddable_manifest_remote( &mut self, dh: &DataHash, @@ -1382,6 +1427,7 @@ impl Manifest { since = "0.35.0", note = "use Builder.sign_box_hashed_embeddable instead" )] + #[cfg(feature = "v1_api")] #[async_generic(async_signature( &mut self, signer: &dyn AsyncSigner, @@ -1407,6 +1453,7 @@ impl Manifest { /// Formats a signed manifest for embedding in the given format /// /// For instance, this would return one or JPEG App11 segments containing the manifest + #[cfg(feature = "v1_api")] pub fn composed_manifest(manifest_bytes: &[u8], format: &str) -> Result> { Store::get_composed_manifest(manifest_bytes, format) } @@ -1417,6 +1464,7 @@ impl Manifest { /// been signed. Use embed_placed_manifest to insert into the asset /// referenced by input_stream #[deprecated(since = "0.35.0", note = "use Builder.sign with dynamic assertions.")] + #[cfg(feature = "v1_api")] pub fn get_placed_manifest( &mut self, reserve_size: usize, @@ -1437,6 +1485,7 @@ impl Manifest { /// traits to make any modifications to assertions. The callbacks are processed before /// the manifest is signed. #[deprecated(since = "0.38.0", note = "use Builder.sign with dynamic assertions.")] + #[cfg(feature = "v1_api")] pub fn embed_placed_manifest( manifest_bytes: &[u8], format: &str, @@ -1498,6 +1547,7 @@ impl SignatureInfo { } #[cfg(test)] +#[cfg(feature = "v1_api")] // todo: convert/move some of these to builder pub(crate) mod tests { #![allow(clippy::expect_used)] #![allow(clippy::unwrap_used)] @@ -1521,7 +1571,7 @@ pub(crate) mod tests { ingredient::Ingredient, reader::Reader, store::Store, - utils::test::{temp_remote_signer, TEST_VC}, + utils::test::{static_test_uuid, temp_remote_signer, TEST_VC}, utils::test_signer::{async_test_signer, test_signer}, Manifest, Result, }; @@ -1609,7 +1659,7 @@ pub(crate) mod tests { .embed(&source_path, &test_output, signer.as_ref()) .expect("embed"); - assert_eq!(manifest.format(), "image/jpeg"); + assert_eq!(manifest.format(), Some("image/jpeg")); assert_eq!(manifest.title(), Some("wc_embed_test.jpg")); if cfg!(feature = "add_thumbnails") { assert!(manifest.thumbnail().is_some()); @@ -1938,11 +1988,11 @@ pub(crate) mod tests { fn test_embed_user_label() { let temp_dir = tempdir().expect("temp dir"); let output = temp_fixture_path(&temp_dir, TEST_SMALL_JPEG); - + let my_guid = static_test_uuid(); let signer = test_signer(SigningAlg::Ps256); let mut manifest = test_manifest(); - manifest.set_label("MyLabel"); + manifest.set_label(my_guid); manifest .embed(&output, &output, signer.as_ref()) .expect("embed"); @@ -1967,7 +2017,7 @@ pub(crate) mod tests { let signer = test_signer(SigningAlg::Ps256); let mut manifest = test_manifest(); - manifest.set_label("MyLabel"); + manifest.set_label(static_test_uuid()); manifest.set_remote_manifest(url); let c2pa_data = manifest .embed(&output, &output, signer.as_ref()) @@ -2437,7 +2487,7 @@ pub(crate) mod tests { b"pirate with bird on shoulder" ); // Validate a custom AI model ingredient. - assert_eq!(m.ingredients()[2].title(), "Custom AI Model"); + assert_eq!(m.ingredients()[2].title(), Some("Custom AI Model")); assert_eq!(m.ingredients()[2].relationship(), &Relationship::InputTo); assert_eq!( m.ingredients()[2].data_types().unwrap()[0].asset_type, @@ -2500,7 +2550,7 @@ pub(crate) mod tests { b"pirate with bird on shoulder" ); // Validate a custom AI model ingredient. - assert_eq!(m.ingredients()[2].title(), "Custom AI Model"); + assert_eq!(m.ingredients()[2].title(), Some("Custom AI Model")); assert_eq!(m.ingredients()[2].relationship(), &Relationship::InputTo); assert_eq!( m.ingredients()[2].data_types().unwrap()[0].asset_type, diff --git a/sdk/src/manifest_assertion.rs b/sdk/src/manifest_assertion.rs index 30aa453e1..bf2a9a1b3 100644 --- a/sdk/src/manifest_assertion.rs +++ b/sdk/src/manifest_assertion.rs @@ -137,7 +137,7 @@ impl ManifestAssertion { ) -> Result { Ok(Self::new( label.into(), - serde_json::to_value(data).map_err(|_err| Error::AssertionEncoding)?, + serde_json::to_value(data).map_err(|err| Error::AssertionEncoding(err.to_string()))?, )) } @@ -146,7 +146,8 @@ impl ManifestAssertion { Ok(Self { label: label.into(), data: ManifestData::Binary( - serde_cbor::to_vec(data).map_err(|_err| Error::AssertionEncoding)?, + serde_cbor::to_vec(data) + .map_err(|err| Error::AssertionEncoding(err.to_string()))?, ), instance: None, kind: Some(ManifestAssertionKind::Cbor), @@ -172,7 +173,7 @@ impl ManifestAssertion { pub fn from_assertion(data: &T) -> Result { Ok(Self::new( data.label().to_owned(), - serde_json::to_value(data).map_err(|_err| Error::AssertionEncoding)?, + serde_json::to_value(data).map_err(|err| Error::AssertionEncoding(err.to_string()))?, )) } diff --git a/sdk/src/manifest_store.rs b/sdk/src/manifest_store.rs index bcdb8d525..bf31269eb 100644 --- a/sdk/src/manifest_store.rs +++ b/sdk/src/manifest_store.rs @@ -29,7 +29,8 @@ use crate::{ claim::ClaimAssetData, jumbf::labels::{manifest_label_from_uri, to_absolute_uri, to_relative_uri}, store::Store, - validation_status::{status_for_store, ValidationStatus}, + validation_results::ValidationResults, + validation_status::{validation_results_for_store, ValidationStatus}, Error, Manifest, Result, }; @@ -40,11 +41,17 @@ pub struct ManifestStore { #[serde(skip_serializing_if = "Option::is_none")] /// A label for the active (most recent) manifest in the store active_manifest: Option, + /// A HashMap of Manifests manifests: HashMap, #[serde(skip_serializing_if = "Option::is_none")] /// ValidationStatus generated when loading the ManifestStore from an asset validation_status: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + // ValidationStatus generated when loading the ManifestStore from an asset + validation_results: Option, + #[serde(skip)] /// The internal store representing the manifest store store: Store, @@ -58,6 +65,7 @@ impl ManifestStore { manifests: HashMap::::new(), validation_status: None, store: Store::new(), + validation_results: None, } } @@ -76,7 +84,6 @@ impl ManifestStore { } /// Returns a reference to manifest HashMap - #[cfg(feature = "v1_api")] pub fn manifests(&self) -> &HashMap { &self.manifests } @@ -134,7 +141,12 @@ impl ManifestStore { self.validation_status.as_deref() } + pub fn validation_results(&self) -> Option<&ValidationResults> { + self.validation_results.as_ref() + } + /// creates a ManifestStore from a Store with validation + #[allow(dead_code)] // async not used without v1 feature #[async_generic] pub(crate) fn from_store(store: Store, validation_log: &impl StatusTracker) -> ManifestStore { if _sync { @@ -171,7 +183,7 @@ impl ManifestStore { validation_log: &impl StatusTracker, #[cfg(feature = "file_io")] resource_path: Option<&Path>, ) -> ManifestStore { - let mut statuses = status_for_store(&store, validation_log); + let mut validation_results = validation_results_for_store(&store, validation_log); let mut manifest_store = ManifestStore::new(); manifest_store.active_manifest = store.provenance_label(); @@ -192,14 +204,13 @@ impl ManifestStore { .insert(manifest_label.to_owned(), manifest); } Err(e) => { - statuses.push(ValidationStatus::from_error(&e)); + validation_results.add_status(manifest_label, ValidationStatus::from_error(&e)); } }; } - if !statuses.is_empty() { - manifest_store.validation_status = Some(statuses); - } + manifest_store.validation_status = validation_results.validation_errors(); + manifest_store.validation_results = Some(validation_results); manifest_store } @@ -209,7 +220,7 @@ impl ManifestStore { validation_log: &impl StatusTracker, #[cfg(feature = "file_io")] resource_path: Option<&Path>, ) -> ManifestStore { - let mut statuses = status_for_store(&store, validation_log); + let mut validation_results = validation_results_for_store(&store, validation_log); let mut manifest_store = ManifestStore::new(); manifest_store.active_manifest = store.provenance_label(); @@ -230,14 +241,13 @@ impl ManifestStore { .insert(manifest_label.to_owned(), manifest); } Err(e) => { - statuses.push(ValidationStatus::from_error(&e)); + validation_results.add_status(manifest_label, ValidationStatus::from_error(&e)); } }; } - if !statuses.is_empty() { - manifest_store.validation_status = Some(statuses); - } + manifest_store.validation_status = validation_results.validation_errors(); + manifest_store.validation_results = Some(validation_results); manifest_store } @@ -249,6 +259,7 @@ impl ManifestStore { /// Creates a new Manifest Store from a Manifest #[allow(dead_code)] #[deprecated(since = "0.38.0", note = "Please use Reader::from_json() instead")] + #[cfg(feature = "v1_api")] pub fn from_manifest(manifest: &Manifest) -> Result { use c2pa_status_tracker::OneShotStatusTracker; let store = manifest.to_store()?; @@ -263,6 +274,7 @@ impl ManifestStore { /// Generate a Store from a format string and bytes. #[cfg(feature = "v1_api")] #[deprecated(since = "0.38.0", note = "Please use Reader::from_stream() instead")] + #[cfg(feature = "v1_api")] #[async_generic] pub fn from_bytes(format: &str, image_bytes: &[u8], verify: bool) -> Result { let mut validation_log = DetailedStatusTracker::default(); @@ -386,6 +398,7 @@ impl ManifestStore { since = "0.38.0", note = "Please use Reader::from_fragment_async() instead" )] + #[cfg(feature = "v1_api")] pub async fn from_fragment_bytes_async( format: &str, init_bytes: &[u8], @@ -466,6 +479,7 @@ impl ManifestStore { since = "0.38.0", note = "Please use Reader::from_manifest_data_and_stream_async() instead" )] + #[cfg(feature = "v1_api")] pub async fn from_manifest_and_asset_bytes_async( manifest_bytes: &[u8], format: &str, @@ -508,6 +522,7 @@ impl ManifestStore { since = "0.38.0", note = "Please use Reader::from_manifest_data_and_stream() instead" )] + #[cfg(feature = "v1_api")] pub fn from_manifest_and_asset_bytes( manifest_bytes: &[u8], format: &str, @@ -608,8 +623,8 @@ mod tests { let manifest = manifest_store.get_active().unwrap(); assert!(!manifest.ingredients().is_empty()); // make sure we have two different ingredients - assert_eq!(manifest.ingredients()[0].format(), "image/jpeg"); - assert_eq!(manifest.ingredients()[1].format(), "image/png"); + assert_eq!(manifest.ingredients()[0].format(), Some("image/jpeg")); + assert_eq!(manifest.ingredients()[1].format(), Some("image/png")); let full_report = manifest_store.to_string(); assert!(!full_report.is_empty()); diff --git a/sdk/src/manifest_store_report.rs b/sdk/src/manifest_store_report.rs index 0c0c00b1c..118972948 100644 --- a/sdk/src/manifest_store_report.rs +++ b/sdk/src/manifest_store_report.rs @@ -12,8 +12,8 @@ // each license. use std::collections::HashMap; -#[cfg(feature = "file_io")] #[cfg(feature = "v1_api")] +#[cfg(feature = "file_io")] use std::path::Path; use atree::{Arena, Token}; @@ -25,8 +25,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - assertion::AssertionData, claim::Claim, store::Store, validation_status::ValidationStatus, - Result, + assertion::AssertionData, claim::Claim, store::Store, validation_results::ValidationResults, + validation_status::ValidationStatus, Result, }; /// Low level JSON based representation of Manifest Store - used for debugging @@ -38,6 +38,7 @@ pub struct ManifestStoreReport { manifests: HashMap, #[serde(skip_serializing_if = "Option::is_none")] validation_status: Option>, + pub(crate) validation_results: Option, } impl ManifestStoreReport { @@ -52,6 +53,7 @@ impl ManifestStoreReport { active_manifest: store.provenance_label(), manifests, validation_status: None, + validation_results: None, }) } @@ -129,8 +131,9 @@ impl ManifestStoreReport { store.get_provenance_cert_chain() } - #[cfg(feature = "v1_api")] /// Creates a ManifestStoreReport from an existing Store and a validation log + #[cfg(feature = "file_io")] + #[cfg(feature = "v1_api")] pub(crate) fn from_store_with_log( store: &Store, validation_log: &impl StatusTracker, @@ -144,6 +147,7 @@ impl ManifestStoreReport { statuses.push( ValidationStatus::new(status.to_string()) .set_url(item.label.to_string()) + .set_kind(item.kind.clone()) .set_explanation(item.description.to_string()), ); } @@ -172,6 +176,7 @@ impl ManifestStoreReport { } #[cfg(feature = "file_io")] + #[cfg(feature = "v1_api")] pub fn from_fragments>( path: P, fragments: &Vec, @@ -229,13 +234,20 @@ impl ManifestStoreReport { // is this an ingredient if let Some(ref c2pa_manifest) = &ingredient_assertion.c2pa_manifest { let label = Store::manifest_label_from_path(&c2pa_manifest.url()); + if let Some(hash) = c2pa_manifest.hash().get(0..5) { if let Some(ingredient_claim) = store.get_claim(&label) { // create new node + let title = if let Some(title) = &ingredient_assertion.title { + title.to_owned() + } else { + "No title".into() + }; + let data = if name_only { - format!("{}_{}", ingredient_assertion.title, Hexlify(hash)) + format!("{}_{}", title, Hexlify(hash)) } else { - format!("Asset:{}, Manifest:{}", ingredient_assertion.title, label) + format!("Asset:{}, Manifest:{}", title, label) }; let new_token = current_token.append(tree, data); @@ -254,9 +266,13 @@ impl ManifestStoreReport { )); } } else { - let asset_name = &ingredient_assertion.title; + let asset_name = if let Some(title) = &ingredient_assertion.title { + title.to_owned() + } else { + "No title".into() + }; let data = if name_only { - asset_name.to_string() + asset_name } else { format!("Asset:{asset_name}") }; diff --git a/sdk/src/openssl/openssl_trust_handler.rs b/sdk/src/openssl/openssl_trust_handler.rs new file mode 100644 index 000000000..5c734399a --- /dev/null +++ b/sdk/src/openssl/openssl_trust_handler.rs @@ -0,0 +1,240 @@ +// 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 c2pa_crypto::{cose::CertificateAcceptancePolicy, openssl::OpenSslMutex}; +use openssl::x509::verify::X509VerifyFlags; + +use crate::Error; + +fn certs_der_to_x509(ders: &[Vec]) -> crate::Result> { + // IMPORTANT: ffi_mutex::acquire() should have been called by calling fn. Please + // don't make this pub or pub(crate) without finding a way to ensure that + // precondition. + + let mut certs: Vec = Vec::new(); + + for d in ders { + let cert = openssl::x509::X509::from_der(d)?; + certs.push(cert); + } + + Ok(certs) +} + +// verify certificate and trust chain +pub(crate) fn verify_trust( + cap: &CertificateAcceptancePolicy, + chain_der: &[Vec], + cert_der: &[u8], + signing_time_epoc: Option, +) -> crate::Result { + // check the cert against the allowed list first + // TO DO: optimize by hashing the cert? + if cap.end_entity_cert_ders().any(|der| der == cert_der) { + return Ok(true); + } + + let _openssl = OpenSslMutex::acquire()?; + + let mut cert_chain = openssl::stack::Stack::new().map_err(Error::OpenSslError)?; + let mut store_ctx = openssl::x509::X509StoreContext::new().map_err(Error::OpenSslError)?; + + let chain = certs_der_to_x509(chain_der)?; + for c in chain { + cert_chain.push(c).map_err(Error::OpenSslError)?; + } + let cert = openssl::x509::X509::from_der(cert_der).map_err(Error::OpenSslError)?; + + let mut builder = openssl::x509::store::X509StoreBuilder::new().map_err(Error::OpenSslError)?; + builder + .set_flags(X509VerifyFlags::X509_STRICT) + .map_err(Error::OpenSslError)?; + + let mut verify_param = + openssl::x509::verify::X509VerifyParam::new().map_err(Error::OpenSslError)?; + + verify_param + .set_flags(X509VerifyFlags::X509_STRICT) + .map_err(Error::OpenSslError)?; + if let Some(st) = signing_time_epoc { + verify_param.set_time(st); + } else { + verify_param + .set_flags(X509VerifyFlags::NO_CHECK_TIME) + .map_err(Error::OpenSslError)?; + } + + builder + .set_param(&verify_param) + .map_err(Error::OpenSslError)?; + + // add trust anchors + let mut has_anchors = false; + for der in cap.trust_anchor_ders() { + let c = openssl::x509::X509::from_der(der).map_err(Error::OpenSslError)?; + builder.add_cert(c)?; + has_anchors = true + } + + // finalize store + let store = builder.build(); + + if !has_anchors { + return Ok(false); + } + + match store_ctx.init(&store, cert.as_ref(), &cert_chain, |f| f.verify_cert()) { + Ok(trust) => Ok(trust), + Err(_) => Ok(false), + } +} + +#[cfg(test)] +#[cfg(feature = "file_io")] +pub mod tests { + #![allow(clippy::expect_used)] + #![allow(clippy::panic)] + #![allow(clippy::unwrap_used)] + + use c2pa_crypto::SigningAlg; + + use super::*; + use crate::{utils::test_signer::test_signer, Signer}; + + #[test] + fn test_trust_store() { + let cap = crate::utils::test::test_certificate_acceptance_policy(); + + // test all the certs + let ps256 = test_signer(SigningAlg::Ps256); + let ps384 = test_signer(SigningAlg::Ps384); + let ps512 = test_signer(SigningAlg::Ps512); + let es256 = test_signer(SigningAlg::Es256); + let es384 = test_signer(SigningAlg::Es384); + let es512 = test_signer(SigningAlg::Es512); + let ed25519 = test_signer(SigningAlg::Ed25519); + + let ps256_certs = ps256.certs().unwrap(); + let ps384_certs = ps384.certs().unwrap(); + let ps512_certs = ps512.certs().unwrap(); + let es256_certs = es256.certs().unwrap(); + let es384_certs = es384.certs().unwrap(); + let es512_certs = es512.certs().unwrap(); + let ed25519_certs = ed25519.certs().unwrap(); + + assert!(verify_trust(&cap, &ps256_certs[1..], &ps256_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &ps384_certs[1..], &ps384_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &ps512_certs[1..], &ps512_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &es256_certs[1..], &es256_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &es384_certs[1..], &es384_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &es512_certs[1..], &es512_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &ed25519_certs[1..], &ed25519_certs[0], None).unwrap()); + } + + #[test] + fn test_broken_trust_chain() { + let cap = CertificateAcceptancePolicy::default(); + + // test all the certs + let ps256 = test_signer(SigningAlg::Ps256); + let ps384 = test_signer(SigningAlg::Ps384); + let ps512 = test_signer(SigningAlg::Ps512); + let es256 = test_signer(SigningAlg::Es256); + let es384 = test_signer(SigningAlg::Es384); + let es512 = test_signer(SigningAlg::Es512); + let ed25519 = test_signer(SigningAlg::Ed25519); + + let ps256_certs = ps256.certs().unwrap(); + let ps384_certs = ps384.certs().unwrap(); + let ps512_certs = ps512.certs().unwrap(); + let es256_certs = es256.certs().unwrap(); + let es384_certs = es384.certs().unwrap(); + let es512_certs = es512.certs().unwrap(); + let ed25519_certs = ed25519.certs().unwrap(); + + assert!(!verify_trust(&cap, &ps256_certs[2..], &ps256_certs[0], None).unwrap()); + assert!(!verify_trust(&cap, &ps384_certs[2..], &ps384_certs[0], None).unwrap()); + assert!(!verify_trust(&cap, &ps384_certs[2..], &ps384_certs[0], None).unwrap()); + assert!(!verify_trust(&cap, &ps512_certs[2..], &ps512_certs[0], None).unwrap()); + assert!(!verify_trust(&cap, &es256_certs[2..], &es256_certs[0], None).unwrap()); + assert!(!verify_trust(&cap, &es384_certs[2..], &es384_certs[0], None).unwrap()); + assert!(!verify_trust(&cap, &es512_certs[2..], &es512_certs[0], None).unwrap()); + assert!(!verify_trust(&cap, &ed25519_certs[2..], &ed25519_certs[0], None).unwrap()); + } + + #[test] + fn test_allowed_list() { + let cap = crate::utils::test::test_certificate_acceptance_policy(); + + // test all the certs + let ps256 = test_signer(SigningAlg::Ps256); + let ps384 = test_signer(SigningAlg::Ps384); + let ps512 = test_signer(SigningAlg::Ps512); + let es256 = test_signer(SigningAlg::Es256); + let es384 = test_signer(SigningAlg::Es384); + let es512 = test_signer(SigningAlg::Es512); + let ed25519 = test_signer(SigningAlg::Ed25519); + + let ps256_certs = ps256.certs().unwrap(); + let ps384_certs = ps384.certs().unwrap(); + let ps512_certs = ps512.certs().unwrap(); + let es256_certs = es256.certs().unwrap(); + let es384_certs = es384.certs().unwrap(); + let es512_certs = es512.certs().unwrap(); + let ed25519_certs = ed25519.certs().unwrap(); + + assert!(verify_trust(&cap, &ps256_certs[1..], &ps256_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &ps384_certs[1..], &ps384_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &ps512_certs[1..], &ps512_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &es256_certs[1..], &es256_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &es384_certs[1..], &es384_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &es512_certs[1..], &es512_certs[0], None).unwrap()); + assert!(verify_trust(&cap, &ed25519_certs[1..], &ed25519_certs[0], None).unwrap()); + } + + // TO REVIEW: Do we need this? Considering removing support for hashed certs. + // #[test] + // fn test_allowed_list_hashes() { + // let mut cap = CertificateAcceptancePolicy::default(); + + // cap.add_end_entity_credentials(include_bytes!( + // "../../tests/fixtures/certs/trust/allowed_list.hash" + // )) + // .unwrap(); + + // // test all the certs + // let ps256 = test_signer(SigningAlg::Ps256); + // let ps384 = test_signer(SigningAlg::Ps384); + // let ps512 = test_signer(SigningAlg::Ps512); + // let es256 = test_signer(SigningAlg::Es256); + // let es384 = test_signer(SigningAlg::Es384); + // let es512 = test_signer(SigningAlg::Es512); + // let ed25519 = test_signer(SigningAlg::Ed25519); + + // let ps256_certs = ps256.certs().unwrap(); + // let ps384_certs = ps384.certs().unwrap(); + // let ps512_certs = ps512.certs().unwrap(); + // let es256_certs = es256.certs().unwrap(); + // let es384_certs = es384.certs().unwrap(); + // let es512_certs = es512.certs().unwrap(); + // let ed25519_certs = ed25519.certs().unwrap(); + + // assert!(verify_trust(&cap, &ps256_certs[1..], &ps256_certs[0], None).unwrap()); + // assert!(verify_trust(&cap, &ps384_certs[1..], &ps384_certs[0], None).unwrap()); + // assert!(verify_trust(&cap, &ps512_certs[1..], &ps512_certs[0], None).unwrap()); + // assert!(verify_trust(&cap, &es256_certs[1..], &es256_certs[0], None).unwrap()); + // assert!(verify_trust(&cap, &es384_certs[1..], &es384_certs[0], None).unwrap()); + // assert!(verify_trust(&cap, &es512_certs[1..], &es512_certs[0], None).unwrap()); + // assert!(verify_trust(&cap, &ed25519_certs[1..], &ed25519_certs[0], None).unwrap()); + // } +} diff --git a/sdk/src/reader.rs b/sdk/src/reader.rs index 5e8896ab3..fda4738b6 100644 --- a/sdk/src/reader.rs +++ b/sdk/src/reader.rs @@ -27,22 +27,16 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "file_io")] use crate::error::Error; use crate::{ - claim::ClaimAssetData, error::Result, manifest_store::ManifestStore, - settings::get_settings_value, store::Store, validation_status::ValidationStatus, Manifest, - ManifestStoreReport, + claim::ClaimAssetData, + error::Result, + manifest_store::ManifestStore, + settings::get_settings_value, + store::Store, + validation_results::{ValidationResults, ValidationState}, + validation_status::ValidationStatus, + Manifest, ManifestStoreReport, }; -#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(feature = "json_schema", derive(JsonSchema))] -pub enum ValidationState { - /// Errors were found in the manifest store. - Invalid, - /// No errors were found in validation, but the active signature is not trusted. - Valid, - /// The manifest store is valid and the active signature is trusted. - Trusted, -} - /// A reader for the manifest store. #[derive(Serialize, Deserialize)] #[cfg_attr(feature = "json_schema", derive(JsonSchema))] @@ -269,8 +263,33 @@ impl Reader { self.manifest_store.validation_status() } + /// Get the [`ValidationResults`] map of an asset if it exists. + /// + /// Call this method to check for detailed validation results. + /// The validation_state method should be used to determine the overall validation state. + /// + /// The results are divided between the active manifest and ingredient deltas. + /// The deltas will only exist if there are validation errors not already reported in ingredients + /// It is normal for there to be many success and information statuses. + /// Any errors will be reported in the failure array. + /// + /// # Example + /// ```no_run + /// use c2pa::Reader; + /// let stream = std::io::Cursor::new(include_bytes!("../tests/fixtures/CA.jpg")); + /// let reader = Reader::from_stream("image/jpeg", stream).unwrap(); + /// let status = reader.validation_results(); + /// ``` + pub fn validation_results(&self) -> Option<&ValidationResults> { + self.manifest_store.validation_results() + } + /// Get the [`ValidationState`] of the manifest store. pub fn validation_state(&self) -> ValidationState { + if let Some(validation_results) = self.manifest_store.validation_results() { + return validation_results.validation_state(); + } + let verify_trust = get_settings_value("verify.trusted").unwrap_or(false); match self.validation_status() { Some(status) => { @@ -280,8 +299,11 @@ impl Reader { .any(|s| s.code() != crate::validation_status::SIGNING_CREDENTIAL_UNTRUSTED); if errs { ValidationState::Invalid - } else { + } else if verify_trust { + // If we verified trust and didn't get an error, we can assume it is trusted ValidationState::Trusted + } else { + ValidationState::Valid } } None => { @@ -418,8 +440,9 @@ impl std::fmt::Display for Reader { /// Prints the full debug details of the manifest data. impl std::fmt::Debug for Reader { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let report = ManifestStoreReport::from_store(self.manifest_store.store()) + let mut report = ManifestStoreReport::from_store(self.manifest_store.store()) .map_err(|_| std::fmt::Error)?; + report.validation_results = self.manifest_store.validation_results().cloned(); f.write_str(&report.to_string()) } } diff --git a/sdk/src/resource_store.rs b/sdk/src/resource_store.rs index 0d75c36ec..0774b1b82 100644 --- a/sdk/src/resource_store.rs +++ b/sdk/src/resource_store.rs @@ -26,10 +26,9 @@ use std::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[cfg(feature = "unstable_api")] -use crate::asset_io::CAIRead; use crate::{ assertions::{labels, AssetType}, + asset_io::CAIRead, claim::Claim, hashed_uri::HashedUri, jumbf::labels::{assertion_label_from_uri, to_absolute_uri}, @@ -367,13 +366,11 @@ impl Default for ResourceStore { } } -#[cfg(feature = "unstable_api")] pub trait ResourceResolver { /// Read the data in a [`ResourceRef`][ResourceRef] via a stream. fn open(&self, reference: &ResourceRef) -> Result>; } -#[cfg(feature = "unstable_api")] impl ResourceResolver for ResourceStore { fn open(&self, reference: &ResourceRef) -> Result> { let data = self.get(&reference.identifier)?.into_owned(); diff --git a/sdk/src/settings.rs b/sdk/src/settings.rs index 9764e891b..1f932e3a9 100644 --- a/sdk/src/settings.rs +++ b/sdk/src/settings.rs @@ -173,7 +173,7 @@ impl Default for Verify { Self { verify_after_reading: true, verify_after_sign: true, - verify_trust: false, + verify_trust: false, //cfg!(test), ocsp_fetch: false, remote_manifest_fetch: true, check_ingredient_trust: true, diff --git a/sdk/src/store.rs b/sdk/src/store.rs index 956ddcaed..f7ae66467 100644 --- a/sdk/src/store.rs +++ b/sdk/src/store.rs @@ -30,6 +30,8 @@ use c2pa_crypto::{ use c2pa_status_tracker::{log_item, DetailedStatusTracker, OneShotStatusTracker, StatusTracker}; use log::error; +#[cfg(feature = "v1_api")] +use crate::jumbf_io::save_jumbf_to_memory; #[cfg(feature = "file_io")] use crate::jumbf_io::{ get_file_extension, get_supported_file_extension, load_jumbf_from_file, object_locations, @@ -47,7 +49,10 @@ use crate::{ asset_io::{ CAIRead, CAIReadWrite, HashBlockObjectType, HashObjectPositions, RemoteRefEmbedType, }, - claim::{check_ocsp_status, Claim, ClaimAssertion, ClaimAssetData, RemoteManifest}, + claim::{ + check_ocsp_status, Claim, ClaimAssertion, ClaimAssertionType, ClaimAssetData, + RemoteManifest, + }, cose_sign::{cose_sign, cose_sign_async}, cose_validator::{verify_cose, verify_cose_async}, dynamic_assertion::{DynamicAssertion, PreliminaryClaim}, @@ -62,7 +67,7 @@ use crate::{ }, jumbf_io::{ get_assetio_handler, is_bmff_format, load_jumbf_from_stream, object_locations_from_stream, - save_jumbf_to_memory, save_jumbf_to_stream, + save_jumbf_to_stream, }, manifest_store_report::ManifestStoreReport, salt::DefaultSalt, @@ -73,13 +78,18 @@ use crate::{ const MANIFEST_STORE_EXT: &str = "c2pa"; // file extension for external manifests +pub(crate) struct ManifestHashes { + pub manifest_box_hash: Vec, + pub signature_box_hash: Vec, +} + /// A `Store` maintains a list of `Claim` structs. /// /// Typically, this list of `Claim`s represents all of the claims in an asset. #[derive(Debug)] pub struct Store { claims_map: HashMap, - manifest_box_hash_cache: HashMap>, + manifest_box_hash_cache: HashMap, Vec)>, claims: Vec, label: String, provenance_path: Option, @@ -199,15 +209,15 @@ impl Store { if self.provenance_path.is_none() { // if we have claims and no provenance, return last claim if let Some(claim) = self.claims.last() { - return Some(Claim::to_claim_uri(claim.label())); + return Some(claim.to_claim_uri()); } } self.provenance_path.as_ref().cloned() } // set the path of the current provenance claim - fn set_provenance_path(&mut self, claim_label: &str) { - let path = Claim::to_claim_uri(claim_label); + fn set_provenance_path(&mut self, claim: &Claim) { + let path = claim.to_claim_uri(); self.provenance_path = Some(path); } @@ -216,12 +226,20 @@ impl Store { &self.claims } - /// the JUMBF manifest box hash (spec 1.2) - pub fn get_manifest_box_hash(&self, claim: &Claim) -> Vec { - if let Some(bh) = self.manifest_box_hash_cache.get(claim.label()) { - bh.clone() + /// the JUMBF manifest box hash (spec 1.2) and signature box hash (2.x) + pub(crate) fn get_manifest_box_hashes(&self, claim: &Claim) -> ManifestHashes { + if let Some((mbh, sbh)) = self.manifest_box_hash_cache.get(claim.label()) { + ManifestHashes { + manifest_box_hash: mbh.clone(), + signature_box_hash: sbh.clone(), + } } else { - Store::calc_manifest_box_hash(claim, None, claim.alg()).unwrap_or_default() + ManifestHashes { + manifest_box_hash: Store::calc_manifest_box_hash(claim, None, claim.alg()) + .unwrap_or_default(), + signature_box_hash: Claim::calc_sig_box_hash(claim, claim.alg()) + .unwrap_or_default(), + } } } @@ -258,7 +276,7 @@ impl Store { } // update the provenance path - self.set_provenance_path(claim.label()); + self.set_provenance_path(&claim); let claim_label = claim.label().to_string(); @@ -489,13 +507,18 @@ impl Store { ) -> Result> { let claim_bytes = claim.data()?; + let tss = if claim.version() > 1 { + TimeStampStorage::V2_sigTst2_CTT + } else { + TimeStampStorage::V1_sigTst + }; + let result = if _sync { if signer.direct_cose_handling() { // Let the signer do all the COSE processing and return the structured COSE data. return signer.sign(&claim_bytes); // do not verify remote signers (we never did) } else { - // TEMPORARY: Assume V1 until we plumb things through further. - cose_sign(signer, &claim_bytes, box_size, TimeStampStorage::V1_sigTst) + cose_sign(signer, &claim_bytes, box_size, tss) } } else { if signer.direct_cose_handling() { @@ -503,8 +526,7 @@ impl Store { return signer.sign(claim_bytes.clone()).await; // do not verify remote signers (we never did) } else { - // TEMPORARY: Assume V1 until we plumb things through further. - cose_sign_async(signer, &claim_bytes, box_size, TimeStampStorage::V1_sigTst).await + cose_sign_async(signer, &claim_bytes, box_size, tss).await } }; match result { @@ -659,6 +681,8 @@ impl Store { None => claim.alg().to_string(), }; + let at = ClaimAssertionType::V1; //todo: set based on label and claim type + // get salt value if set let salt = assertion_desc_box.get_salt(); @@ -669,7 +693,9 @@ impl Store { .ok_or(Error::JumbfBoxNotFound)?; let assertion = Assertion::from_data_json(&raw_label, json_box.json())?; let hash = Claim::calc_assertion_box_hash(label, &assertion, salt.clone(), &alg)?; - Ok(ClaimAssertion::new(assertion, instance, &hash, &alg, salt)) + Ok(ClaimAssertion::new( + assertion, instance, &hash, &alg, salt, at, + )) } CAI_EMBEDDED_FILE_UUID => { let ef_box = assertion_box @@ -682,7 +708,9 @@ impl Store { let assertion = Assertion::from_data_binary(&raw_label, &media_type, data_box.data()); let hash = Claim::calc_assertion_box_hash(label, &assertion, salt.clone(), &alg)?; - Ok(ClaimAssertion::new(assertion, instance, &hash, &alg, salt)) + Ok(ClaimAssertion::new( + assertion, instance, &hash, &alg, salt, at, + )) } CAI_CBOR_ASSERTION_UUID => { let cbor_box = assertion_box @@ -690,7 +718,9 @@ impl Store { .ok_or(Error::JumbfBoxNotFound)?; let assertion = Assertion::from_data_cbor(&raw_label, cbor_box.cbor()); let hash = Claim::calc_assertion_box_hash(label, &assertion, salt.clone(), &alg)?; - Ok(ClaimAssertion::new(assertion, instance, &hash, &alg, salt)) + Ok(ClaimAssertion::new( + assertion, instance, &hash, &alg, salt, at, + )) } CAI_UUID_ASSERTION_UUID => { let uuid_box = assertion_box @@ -700,7 +730,9 @@ impl Store { let assertion = Assertion::from_data_uuid(&raw_label, &uuid_str, uuid_box.data()); let hash = Claim::calc_assertion_box_hash(label, &assertion, salt.clone(), &alg)?; - Ok(ClaimAssertion::new(assertion, instance, &hash, &alg, salt)) + Ok(ClaimAssertion::new( + assertion, instance, &hash, &alg, salt, at, + )) } _ => Err(Error::JumbfCreationError), }; @@ -780,7 +812,7 @@ impl Store { cai_store.add_box(Box::new(a_store)); // add the assertion store to the manifest } CLAIM => { - let mut cb = CAIClaimBox::new(); + let mut cb = CAIClaimBox::new(claim.version()); // Add the Claim json let claim_cbor_bytes = claim.data()?; @@ -804,7 +836,7 @@ impl Store { } CREDENTIALS => { // add vc_store if needed - if !claim.get_verifiable_credentials().is_empty() { + if !claim.get_verifiable_credentials().is_empty() && claim.version() < 2 { let mut vc_store = CAIVerifiableCredentialStore::new(); // Add assertions to CAI assertion store. @@ -833,8 +865,8 @@ impl Store { let mut databoxes = CAIDataboxStore::new(); for (uri, db) in claim.databoxes() { - let db_cbor_bytes = - serde_cbor::to_vec(db).map_err(|_err| Error::AssertionEncoding)?; + let db_cbor_bytes = serde_cbor::to_vec(db) + .map_err(|err| Error::AssertionEncoding(err.to_string()))?; let (link, instance) = Claim::assertion_label_from_link(&uri.url()); let label = Claim::label_with_instance(&link, instance); @@ -981,7 +1013,9 @@ impl Store { )); } - match desc_box.label().as_ref() { + let (box_label, _instance) = + Claim::box_name_label_instance(desc_box.label().as_ref()); + match box_label.as_ref() { ASSERTIONS => box_order.push(ASSERTIONS), CLAIM => box_order.push(CLAIM), SIGNATURE => box_order.push(SIGNATURE), @@ -1019,7 +1053,7 @@ impl Store { // check if version is supported let claim_box_ver = claim_desc_box.label(); - if !Self::check_label_version(Claim::build_version(), &claim_box_ver) { + if !Self::check_label_version(&Claim::build_version_support(), &claim_box_ver) { return Err(Error::InvalidClaim(InvalidClaimError::ClaimVersionTooNew)); } @@ -1101,6 +1135,14 @@ impl Store { .ok_or(Error::JumbfBoxNotFound)?; let mut claim = Claim::from_data(&cai_store_desc_box.label(), cbor_box.cbor())?; + // make sure box version label match the read Claim + if claim.version() > 1 { + match labels::version(&claim_box_ver) { + Some(v) if claim.version() >= v => (), + _ => return Err(Error::InvalidClaim(InvalidClaimError::ClaimBoxVersion)), + } + } + // set the type of manifest claim.set_update_manifest(is_update_manifest); @@ -1162,6 +1204,13 @@ impl Store { let vc_store = mi.sbox; let num_vcs = vc_store.data_box_count(); + // VC stores should not be in a 2.x claim + if claim.version() > 1 { + return Err(Error::InvalidClaim(InvalidClaimError::UnsupportedFeature( + "Verifiable Credentials Store > v1 claim".to_string(), + ))); + } + for idx in 0..num_vcs { let vc_box = vc_store .data_box_as_superbox(idx) @@ -1203,9 +1252,13 @@ impl Store { } // save the hash of the loaded manifest for ingredient validation + // and the signature box for Ingredient_v3 store.manifest_box_hash_cache.insert( claim.label().to_owned(), - Store::calc_manifest_box_hash(&claim, None, claim.alg())?, + ( + Store::calc_manifest_box_hash(&claim, None, claim.alg())?, + Claim::calc_sig_box_hash(&claim, claim.alg())?, + ), ); // add claim to store @@ -1237,6 +1290,9 @@ impl Store { for i in claim.ingredient_assertions() { let ingredient_assertion = Ingredient::from_assertion(i)?; + validation_log + .push_ingredient_uri(jumbf::labels::to_assertion_uri(claim.label(), &i.label())); + // is this an ingredient if let Some(ref c2pa_manifest) = &ingredient_assertion.c2pa_manifest { let label = Store::manifest_label_from_path(&c2pa_manifest.url()); @@ -1253,7 +1309,7 @@ impl Store { }; // get the 1.1-1.2 box hash - let box_hash = store.get_manifest_box_hash(ingredient); + let box_hash = store.get_manifest_box_hashes(ingredient).manifest_box_hash; // test for 1.1 hash then 1.0 version if !vec_compare(&c2pa_manifest.hash(), &box_hash) @@ -1301,6 +1357,7 @@ impl Store { )?; } } + validation_log.pop_ingredient_uri(); } // check ingredient rules @@ -1346,6 +1403,9 @@ impl Store { for i in claim.ingredient_assertions() { let ingredient_assertion = Ingredient::from_assertion(i)?; + validation_log + .push_ingredient_uri(jumbf::labels::to_assertion_uri(claim.label(), &i.label())); + // is this an ingredient if let Some(ref c2pa_manifest) = &ingredient_assertion.c2pa_manifest { let label = Store::manifest_label_from_path(&c2pa_manifest.url()); @@ -1357,7 +1417,7 @@ impl Store { }; // get the 1.1-1.2 box hash - let box_hash = store.get_manifest_box_hash(ingredient); + let box_hash = store.get_manifest_box_hashes(ingredient).manifest_box_hash; // test for 1.1 hash then 1.0 version if !vec_compare(&c2pa_manifest.hash(), &box_hash) @@ -1407,6 +1467,7 @@ impl Store { )?; } } + validation_log.pop_ingredient_uri(); } Ok(()) @@ -2356,7 +2417,9 @@ impl Store { }; // get the 1.1-1.2 box hash - let box_hash = new_store.get_manifest_box_hash(&ingredient.clone()); + let box_hash = new_store + .get_manifest_box_hashes(&ingredient.clone()) + .manifest_box_hash; // test for 1.1 hash then 1.0 version if !vec_compare(&c2pa_manifest.hash(), &box_hash) @@ -2461,6 +2524,7 @@ impl Store { /// asset and manifest. Updates XMP with provenance record. /// When called, the stream should contain an asset matching format. /// Returns a tuple (output asset, manifest store) with a `Vec` containing the output asset and a `Vec` containing the insert manifest store. (output asset, ) + #[cfg(feature = "v1_api")] pub(crate) async fn save_to_memory_remote_signed( &mut self, format: &str, @@ -2883,6 +2947,7 @@ impl Store { Ok((sig, jumbf_bytes)) } + #[cfg(feature = "v1_api")] fn finish_save_to_memory( &self, mut jumbf_bytes: Vec, @@ -3538,6 +3603,15 @@ impl Store { ) -> Result { let mut report = OneShotStatusTracker::default(); let store = Store::from_jumbf(data, &mut report)?; + + // make sure the claims stores are compatible + let pc = store.provenance_claim().ok_or(Error::OtherError( + "ingredient missing provenace claim".into(), + ))?; + if claim.version() < pc.version() { + return Err(Error::OtherError("ingredient version too new".into())); + } + claim.add_ingredient_data(provenance_label, store.claims.clone(), redactions)?; Ok(store) } @@ -3580,6 +3654,10 @@ pub enum InvalidClaimError { #[error("claim version is too new, not supported")] ClaimVersionTooNew, + /// The claim has a version does not match JUMBF box label. + #[error("claim version does not match JUMBF box label")] + ClaimBoxVersion, + /// The claim description box could not be parsed. #[error("claim description box was invalid")] ClaimDescriptionBoxInvalid, @@ -3604,6 +3682,10 @@ pub enum InvalidClaimError { #[error("the verifiable credentials store could not be read")] VerifiableCredentialStoreInvalid, + /// The feature is not supported by version + #[error("the manifest contained a feature not support by version")] + UnsupportedFeature(String), + /// The assertion store does not contain the expected number of assertions. #[error( "unexpected number of assertions in assertion store (expected {expected}, found {found})" @@ -3684,11 +3766,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? @@ -3782,6 +3864,124 @@ pub mod tests { .expect_err("Should not verify"); } + #[test] + #[cfg(feature = "file_io")] + fn test_claim_v2_generation() { + // test adding to actual image + + use crate::ClaimGeneratorInfo; + let ap = fixture_path("earth_apollo17.jpg"); + let temp_dir = tempdir().expect("temp dir"); + let op = temp_dir_path(&temp_dir, "test-image.jpg"); + + // Create claims store. + let mut store = Store::new(); + + // ClaimGeneratorInfo is mandatory in Claim V2 + let cgi = ClaimGeneratorInfo::new("claim_v2_unit_test"); + + // Create a 3rd party claim + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); + create_capture_claim(&mut claim_capture).unwrap(); + claim_capture.add_claim_generator_info(cgi.clone()); + + // Create a new v2 claim. + let mut claimv2 = Claim::new("Photoshop", Some("Adobe"), 2); + // first assertion must be Actiion c2pa.opened, c2pa.created + let action = Actions::new().add_action(Action::new("c2pa.opened")); + claimv2.add_assertion(&action).unwrap(); + create_editing_claim(&mut claimv2).unwrap(); + claimv2.add_claim_generator_info(cgi); + + // Do we generate JUMBF? + let signer = test_signer(SigningAlg::Ps256); + + // Test generate JUMBF + // Get labels for label test + let capture = claim_capture.label().to_string(); + let claim2_label = claimv2.label().to_string(); + + // Move the claim to claims list. Note this is not real, the claims would have to be signed in between commits + //store.commit_claim(claim1).unwrap(); + //store.save_to_asset(&ap, signer.as_ref(), &op).unwrap(); + store.commit_claim(claim_capture).unwrap(); + store.save_to_asset(&ap, signer.as_ref(), &op).unwrap(); + store.commit_claim(claimv2).unwrap(); + store.save_to_asset(&op, signer.as_ref(), &op).unwrap(); + + // test finding claims by label + //let c1 = store.get_claim(&claim1_label); + let c2 = store.get_claim(&capture); + let c3 = store.get_claim(&claim2_label); + //assert_eq!(&claim1_label, c1.unwrap().label()); + assert_eq!(&capture, c2.unwrap().label()); + assert_eq!(claim2_label, c3.unwrap().label()); + + // write to new file + println!("Provenance: {}\n", store.provenance_path().unwrap()); + + let mut report = DetailedStatusTracker::default(); + + // read from new file + let new_store = Store::load_from_asset(&op, true, &mut report).unwrap(); + + let errors = report.take_errors(); + assert!(errors.is_empty()); + + // dump store and compare to original + for claim in new_store.claims() { + let _restored_json = claim + .to_json(AssertionStoreJsonFormat::OrderedList, false) + .unwrap(); + let _orig_json = store + .get_claim(claim.label()) + .unwrap() + .to_json(AssertionStoreJsonFormat::OrderedList, false) + .unwrap(); + + // these better match + //assert_eq!(orig_json, restored_json); + //assert_eq!(claim.hash(), store.claims()[idx].hash()); + + println!( + "Claim: {} \n{}", + claim.label(), + claim + .to_json(AssertionStoreJsonFormat::OrderedListNoBinary, true) + .expect("could not restore from json") + ); + + for hashed_uri in claim.assertions() { + let (label, instance) = Claim::assertion_label_from_link(&hashed_uri.url()); + claim.get_claim_assertion(&label, instance).unwrap(); + } + } + } + + #[test] + #[cfg(feature = "file_io")] + fn test_bad_claim_v2_generation() { + // first assertion must be Actiion c2pa.opened, c2pa.created + let action = Actions::new().add_action(Action::new("c2pa.opened")); + let edit_action = Actions::new().add_action(Action::new("c2pa.edited")); + + let my_content = r#"{"my_tag": "some value I will replace"}"#; + let my_label = "com.mycompany.myassertion"; + let user = crate::assertions::User::new(my_label, my_content); + + // test adding non opened or created assertion first + let mut claimv2 = Claim::new("Photoshop", Some("Adobe"), 2); + // ok to have other assertions first + claimv2.add_assertion(&user).unwrap(); + // not ok to have other actions first + claimv2.add_assertion(&edit_action).unwrap_err(); + + // test adding mulitple opened or created + let mut claimv2 = Claim::new("Photoshop", Some("Adobe"), 2); + claimv2.add_assertion(&action).unwrap(); + claimv2.add_assertion(&action).unwrap_err(); + } + #[test] #[cfg(feature = "file_io")] fn test_unknown_asset_type_generation() { @@ -3797,11 +3997,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? @@ -3969,11 +4169,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Test generate JUMBF @@ -4072,11 +4272,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? @@ -4168,11 +4368,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? @@ -4241,11 +4441,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? @@ -4315,11 +4515,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? @@ -4389,11 +4589,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? @@ -4463,11 +4663,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? @@ -4869,7 +5069,7 @@ pub mod tests { assert!(!pc.update_manifest()); // create a new update manifest - let mut claim = Claim::new("adobe unit test", Some("update_manfifest")); + let mut claim = Claim::new("adobe unit test", Some("update_manfifest"), 1); // must contain an ingredient let parent_hashed_uri = HashedUri::new( @@ -5372,11 +5572,11 @@ pub mod tests { let claim1 = create_test_claim().unwrap(); // Create a new claim. - let mut claim2 = Claim::new("Photoshop", Some("Adobe")); + let mut claim2 = Claim::new("Photoshop", Some("Adobe"), 1); create_editing_claim(&mut claim2).unwrap(); // Create a 3rd party claim - let mut claim_capture = Claim::new("capture", Some("claim_capture")); + let mut claim_capture = Claim::new("capture", Some("claim_capture"), 1); create_capture_claim(&mut claim_capture).unwrap(); // Do we generate JUMBF? diff --git a/sdk/src/utils/cbor_types.rs b/sdk/src/utils/cbor_types.rs index 215c32aa6..7daf4d033 100644 --- a/sdk/src/utils/cbor_types.rs +++ b/sdk/src/utils/cbor_types.rs @@ -143,6 +143,23 @@ impl fmt::Display for BytesT { } } +// Convert map member to concrete value. mp should be a Value::Map, key is the value of +// the map you would like to extract +pub(crate) fn map_cbor_to_type( + key: &str, + mp: &serde_cbor::Value, +) -> Option { + if let serde_cbor::Value::Map(m) = mp { + let k = serde_cbor::Value::Text(key.to_string()); + let v = m.get(&k)?; + let v_bytes = serde_cbor::ser::to_vec(v).ok()?; + let output: T = serde_cbor::from_slice(&v_bytes).ok()?; + Some(output) + } else { + None + } +} + #[cfg(test)] pub mod tests { #![allow(clippy::expect_used)] diff --git a/sdk/src/utils/mime.rs b/sdk/src/utils/mime.rs index f3c8802cd..1ae38da58 100644 --- a/sdk/src/utils/mime.rs +++ b/sdk/src/utils/mime.rs @@ -97,7 +97,7 @@ pub fn format_to_extension(format: &str) -> Option<&'static str> { /// /// This function will use the file extension to determine the MIME type. pub fn format_from_path>(path: P) -> Option { - path.as_ref() - .extension() - .map(|ext| crate::utils::mime::format_to_mime(ext.to_string_lossy().as_ref())) + path.as_ref().extension().map(|ext| { + crate::utils::mime::format_to_mime(ext.to_string_lossy().to_lowercase().as_ref()) + }) } diff --git a/sdk/src/utils/test.rs b/sdk/src/utils/test.rs index 8debc3c44..37c5f13d5 100644 --- a/sdk/src/utils/test.rs +++ b/sdk/src/utils/test.rs @@ -70,9 +70,23 @@ pub const TEST_VC: &str = r#"{ } }"#; +/// Create new C2PA compatible UUID +pub(crate) fn gen_c2pa_uuid() -> String { + let guid = uuid::Uuid::new_v4(); + guid.hyphenated() + .encode_lower(&mut uuid::Uuid::encode_buffer()) + .to_owned() +} + +// Returns a non-changing C2PA compatible UUID for testing +pub(crate) fn static_test_uuid() -> &'static str { + const TEST_GUID: &str = "f75ddc48-cdc8-4723-bcfe-77a8d68a5920"; + TEST_GUID +} + /// creates a claim for testing pub fn create_test_claim() -> Result { - let mut claim = Claim::new("adobe unit test", Some("adobe")); + let mut claim = Claim::new("adobe unit test", Some("adobe"), 1); // add some data boxes let _db_uri = claim.add_databox("text/plain", "this is a test".as_bytes().to_vec(), None)?; diff --git a/sdk/src/validation_results.rs b/sdk/src/validation_results.rs new file mode 100644 index 000000000..b20f88a4b --- /dev/null +++ b/sdk/src/validation_results.rs @@ -0,0 +1,263 @@ +// Copyright 2024 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. + +pub use c2pa_status_tracker::validation_codes::*; +use c2pa_status_tracker::LogKind; +#[cfg(feature = "json_schema")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::validation_status::ValidationStatus; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +/// Indicates if the manifest store is valid and trusted. +/// +/// The Trusted state implies the manifest store is valid and the active signature is trusted. +pub enum ValidationState { + /// Errors were found in the manifest store. + Invalid, + /// No errors were found in validation, but the active signature is not trusted. + Valid, + /// The manifest store is valid and the active signature is trusted. + Trusted, +} + +#[derive(Clone, Serialize, Default, Deserialize, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +/// Contains a set of success, informational, and failure validation status codes. +pub struct StatusCodes { + pub success: Vec, // an array of validation success codes. May be empty. + pub informational: Vec, // an array of validation informational codes. May be empty. + pub failure: Vec, // an array of validation failure codes. May be empty. +} + +impl StatusCodes { + /// Adds a [ValidationStatus] to the StatusCodes. + pub fn add_status(&mut self, status: ValidationStatus) { + match status.kind() { + LogKind::Success => self.success.push(status), + LogKind::Informational => self.informational.push(status), + LogKind::Failure => self.failure.push(status), + } + } + + pub fn add_success_val(mut self, sm: ValidationStatus) -> Self { + self.success.push(sm); + self + } + + pub fn success(&self) -> &Vec { + self.success.as_ref() + } + + pub fn add_informational_val(mut self, sm: ValidationStatus) -> Self { + self.informational.push(sm); + self + } + + pub fn informational(&self) -> &Vec { + self.informational.as_ref() + } + + pub fn add_failure_val(mut self, sm: ValidationStatus) -> Self { + self.failure.push(sm); + self + } + + pub fn failure(&self) -> &Vec { + self.failure.as_ref() + } +} + +#[derive(Clone, Serialize, Default, Deserialize, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +/// A map of validation results for a manifest store. +/// +/// The map contains the validation results for the active manifest and any ingredient deltas. +/// It is normal for there to be many +pub struct ValidationResults { + #[serde(rename = "activeManifest", skip_serializing_if = "Option::is_none")] + active_manifest: Option, // Validation status codes for the ingredient's active manifest. Present if ingredient is a C2PA asset. Not present if the ingredient is not a C2PA asset. + + #[serde(rename = "ingredientDeltas", skip_serializing_if = "Option::is_none")] + ingredient_deltas: Option>, // List of any changes/deltas between the current and previous validation results for each ingredient's manifest. Present if the the ingredient is a C2PA asset. +} + +impl ValidationResults { + /// Returns the [ValidationState] of the manifest store based on the validation results. + pub fn validation_state(&self) -> ValidationState { + let mut is_trusted = true; // Assume the state is trusted until proven otherwise + if let Some(active_manifest) = self.active_manifest.as_ref() { + if !active_manifest.failure().is_empty() { + return ValidationState::Invalid; + } + // There must be a trusted credential in the active manifest for the state to be trusted + is_trusted = active_manifest.success().iter().any(|status| { + status.code() == crate::validation_status::SIGNING_CREDENTIAL_TRUSTED + }); + } + if let Some(ingredient_deltas) = self.ingredient_deltas.as_ref() { + for idv in ingredient_deltas.iter() { + if !idv.validation_deltas().failure().is_empty() { + return ValidationState::Invalid; + } + } + } + if is_trusted { + ValidationState::Trusted + } else { + ValidationState::Valid + } + } + + /// Returns a list of all validation errors in [ValidationResults]. + pub(crate) fn validation_errors(&self) -> Option> { + let mut status_vec = Vec::new(); + if let Some(active_manifest) = self.active_manifest.as_ref() { + status_vec.extend(active_manifest.failure().to_vec()); + } + if let Some(ingredient_deltas) = self.ingredient_deltas.as_ref() { + for idv in ingredient_deltas.iter() { + status_vec.extend(idv.validation_deltas().failure().to_vec()); + } + } + if status_vec.is_empty() { + None + } else { + Some(status_vec) + } + } + + /// Returns a list of all validation status codes in [ValidationResults]. + pub(crate) fn validation_status(&self) -> Vec { + let mut status = Vec::new(); + if let Some(active_manifest) = self.active_manifest.as_ref() { + status.extend(active_manifest.success().to_vec()); + status.extend(active_manifest.informational().to_vec()); + status.extend(active_manifest.failure().to_vec()); + } + if let Some(ingredient_deltas) = self.ingredient_deltas.as_ref() { + for idv in ingredient_deltas.iter() { + status.extend(idv.validation_deltas().success().to_vec()); + status.extend(idv.validation_deltas().informational().to_vec()); + status.extend(idv.validation_deltas().failure().to_vec()); + } + } + status + } + + /// Adds a [ValidationStatus] to the [ValidationResults]. + pub fn add_status( + &mut self, + active_manifest_label: &str, + status: ValidationStatus, + ) -> &mut Self { + use crate::jumbf::labels::manifest_label_from_uri; + let active_manifest_label = active_manifest_label.to_string(); + + // This closure returns true if the URI references the store's active manifest. + let is_active_manifest = |uri: Option<&str>| { + uri.is_some_and(|uri| manifest_label_from_uri(uri) == Some(active_manifest_label)) + }; + + // todo - test if we can just use lack of an ingredient URI to determine if it's the active manifest + if is_active_manifest(status.url()) { + let scm = self + .active_manifest + .get_or_insert_with(StatusCodes::default); + scm.add_status(status); + } else { + let ingredient_url = status.ingredient_uri().unwrap_or("NOT FOUND!!!"); //todo: is there an error status for this? + let ingredient_vec = self.ingredient_deltas.get_or_insert_with(Vec::new); + match ingredient_vec + .iter_mut() + .find(|idv| idv.ingredient_assertion_uri() == ingredient_url) + { + Some(idv) => { + idv.validation_deltas_mut().add_status(status); + } + None => { + let mut idv = IngredientDeltaValidationResult::new( + ingredient_url, + StatusCodes::default(), + ); + idv.validation_deltas_mut().add_status(status); + ingredient_vec.push(idv); + } + }; + } + self + } + + /// Returns the active manifest status codes, if present. + pub fn active_manifest(&self) -> Option<&StatusCodes> { + self.active_manifest.as_ref() + } + + /// Returns the ingredient deltas, if present. + pub fn ingredient_deltas(&self) -> Option<&Vec> { + self.ingredient_deltas.as_ref() + } + + pub fn add_active_manifest(mut self, scm: StatusCodes) -> Self { + self.active_manifest = Some(scm); + self + } + + pub fn add_ingredient_delta(mut self, idv: IngredientDeltaValidationResult) -> Self { + if let Some(id) = self.ingredient_deltas.as_mut() { + id.push(idv); + } else { + self.ingredient_deltas = Some(vec![idv]); + } + self + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "json_schema", derive(JsonSchema))] +/// Represents any changes or deltas between the current and previous validation results for an ingredient's manifest. +pub struct IngredientDeltaValidationResult { + #[serde(rename = "ingredientAssertionURI")] + /// JUMBF URI reference to the ingredient assertion + ingredient_assertion_uri: String, + #[serde(rename = "validationDeltas")] + /// Validation results for the ingredient's active manifest + validation_deltas: StatusCodes, +} + +impl IngredientDeltaValidationResult { + /// Creates a new [IngredientDeltaValidationResult] with the provided ingredient URI and validation deltas. + pub fn new>( + ingredient_assertion_uri: S, + validation_deltas: StatusCodes, + ) -> Self { + IngredientDeltaValidationResult { + ingredient_assertion_uri: ingredient_assertion_uri.into(), + validation_deltas, + } + } + + pub fn ingredient_assertion_uri(&self) -> &str { + self.ingredient_assertion_uri.as_str() + } + + pub fn validation_deltas(&self) -> &StatusCodes { + &self.validation_deltas + } + + pub fn validation_deltas_mut(&mut self) -> &mut StatusCodes { + &mut self.validation_deltas + } +} diff --git a/sdk/src/validation_status.rs b/sdk/src/validation_status.rs index 41b977fa0..4ee49a37c 100644 --- a/sdk/src/validation_status.rs +++ b/sdk/src/validation_status.rs @@ -18,7 +18,7 @@ #![deny(missing_docs)] pub use c2pa_status_tracker::validation_codes::*; -use c2pa_status_tracker::{LogItem, StatusTracker}; +use c2pa_status_tracker::{LogItem, LogKind, StatusTracker}; use log::debug; #[cfg(feature = "json_schema")] use schemars::JsonSchema; @@ -30,7 +30,7 @@ use crate::{assertion::AssertionBase, assertions::Ingredient, error::Error, jumb /// specific part of a manifest. /// /// See . -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, Eq)] #[cfg_attr(feature = "json_schema", derive(JsonSchema))] pub struct ValidationStatus { code: String, @@ -40,6 +40,21 @@ pub struct ValidationStatus { #[serde(skip_serializing_if = "Option::is_none")] explanation: Option, + + #[serde(skip_serializing)] + #[allow(dead_code)] + success: Option, // deprecated in 2.x, allow reading for compatibility + + #[serde(skip)] + #[serde(default = "default_log_kind")] + kind: LogKind, + + #[serde(skip)] + ingredient_uri: Option, +} + +fn default_log_kind() -> LogKind { + LogKind::Success } impl ValidationStatus { @@ -48,9 +63,16 @@ impl ValidationStatus { code: code.into(), url: None, explanation: None, + success: None, + ingredient_uri: None, + kind: LogKind::Success, } } + pub(crate) fn new_failure>(code: S) -> Self { + Self::new(code).set_kind(LogKind::Failure) + } + /// Returns the validation status code. /// /// Validation status codes are the labels from the "Value" @@ -72,9 +94,26 @@ impl ValidationStatus { self.explanation.as_deref() } + /// Returns the internal JUMBF reference to the Ingredient that was validated. + pub fn ingredient_uri(&self) -> Option<&str> { + self.ingredient_uri.as_deref() + } + /// Sets the internal JUMBF reference to the entity was validated. - pub(crate) fn set_url(mut self, url: String) -> Self { - self.url = Some(url); + pub fn set_url>(mut self, url: S) -> Self { + self.url = Some(url.into()); + self + } + + /// Sets the LogKind for this validation status. + pub fn set_kind(mut self, kind: LogKind) -> Self { + self.kind = kind; + self + } + + /// Sets the internal JUMBF reference to the Ingredient that was validated. + pub fn set_ingredient_uri>(mut self, uri: S) -> Self { + self.ingredient_uri = Some(uri.into()); self } @@ -89,6 +128,11 @@ impl ValidationStatus { is_success(&self.code) } + /// Returns the LogKind for this validation status. + pub fn kind(&self) -> &LogKind { + &self.kind + } + // Maps errors into validation_status codes. fn code_from_error_str(error: &str) -> &str { match error { @@ -120,22 +164,27 @@ impl ValidationStatus { // We need to create error codes here for client processing. let code = Self::code_from_error(error); debug!("ValidationStatus {} from error {:#?}", code, error); - Self::new(code.to_string()).set_explanation(error.to_string()) + Self::new_failure(code.to_string()).set_explanation(error.to_string()) } /// Creates a ValidationStatus from a validation_log item. - pub(crate) fn from_validation_item(item: &LogItem) -> Option { + pub(crate) fn from_log_item(item: &LogItem) -> Option { match item.validation_status.as_ref() { - Some(status) => Some( - Self::new(status.to_string()) + Some(status) => Some({ + let mut vi = Self::new(status.to_string()) .set_url(item.label.to_string()) - .set_explanation(item.description.to_string()), - ), + .set_kind(item.kind.clone()) + .set_explanation(item.description.to_string()); + if let Some(ingredient_uri) = &item.ingredient_uri { + vi = vi.set_ingredient_uri(ingredient_uri.to_string()); + } + vi + }), // If we don't have a validation_status, then make one from the err_val // using the description plus error text explanation. None => item.err_val.as_ref().map(|e| { let code = Self::code_from_error_str(e); - Self::new(code.to_string()) + Self::new_failure(code.to_string()) .set_url(item.label.to_string()) .set_explanation(format!("{}: {}", item.description, e)) }), @@ -149,20 +198,18 @@ impl PartialEq for ValidationStatus { } } -// TODO: Does this still need to be public? (I do see one reference in the JS SDK.) - -/// Given a `Store` and a `StatusTracker`, return `ValidationStatus` items for each -/// item in the tracker which reflect errors in the active manifest or which would not -/// be reported as a validation error for any ingredient. -pub fn status_for_store( +use crate::validation_results::ValidationResults; +/// Given a `Store` and a `StatusTracker`, return `ValidationResultsMap +pub fn validation_results_for_store( store: &Store, validation_log: &impl StatusTracker, -) -> Vec { - let statuses: Vec = validation_log +) -> ValidationResults { + let mut results = ValidationResults::default(); + + let mut statuses: Vec = validation_log .logged_items() .iter() - .filter_map(ValidationStatus::from_validation_item) - .filter(|s| !is_success(&s.code)) + .filter_map(ValidationStatus::from_log_item) .collect(); // Filter out any status that is already captured in an ingredient assertion. @@ -174,27 +221,34 @@ pub fn status_for_store( uri.is_some_and(|uri| jumbf::labels::manifest_label_from_uri(uri) == active_manifest) }; - // Convert any relative manifest urls found in ingredient validation statuses to absolute. - let make_absolute = - |active_manifest: Option, - validation_status: Option>| { - validation_status.map(|mut statuses| { - if let Some(label) = active_manifest - .map(|m| m.url()) - .and_then(|uri| jumbf::labels::manifest_label_from_uri(&uri)) - { - for status in &mut statuses { - if let Some(url) = &status.url { - if url.starts_with("self#jumbf") { - // Some are just labels (i.e. "Cose_Sign1") - status.url = Some(jumbf::labels::to_absolute_uri(&label, url)); - } + let make_absolute = |i: Ingredient| { + // Get a flat list of validation statuses from the ingredient. + let validation_status = match i.validation_results { + Some(v) => Some(v.validation_status()), + None => i.validation_status, + }; + + // Convert any relative manifest urls found in ingredient validation statuses to absolute. + validation_status.map(|mut statuses| { + if let Some(label) = i + .active_manifest + .as_ref() + .or(i.c2pa_manifest.as_ref()) + .map(|m| m.url()) + .and_then(|uri| jumbf::labels::manifest_label_from_uri(&uri)) + { + for status in &mut statuses { + if let Some(url) = &status.url { + if url.starts_with("self#jumbf") { + // Some are just labels (i.e. "Cose_Sign1") + status.url = Some(jumbf::labels::to_absolute_uri(&label, url)); } } } - statuses - }) - }; + } + statuses + }) + }; // We only need to do the more detailed filtering if there are any status // reports that reference ingredients. @@ -203,27 +257,40 @@ pub fn status_for_store( .any(|s| !is_active_manifest(s.url.as_deref())) { // Collect all the ValidationStatus records from all the ingredients in the store. + // Since we need to process v1,v2 and v3 ingredients, we process all in the same format. let ingredient_statuses: Vec = store .claims() .iter() .flat_map(|c| c.ingredient_assertions()) .filter_map(|a| Ingredient::from_assertion(a).ok()) - .filter_map(|i| make_absolute(i.c2pa_manifest, i.validation_status)) + .filter_map(make_absolute) .flatten() .collect(); // Filter statuses to only contain those from the active manifest and those not found in any ingredient. - return statuses - .into_iter() - .filter(|s| { - is_active_manifest(s.url.as_deref()) - || !ingredient_statuses.iter().any(|i| i == s) - }) - .collect(); + statuses.retain(|s| { + is_active_manifest(s.url.as_deref()) || !ingredient_statuses.iter().any(|i| i == s) + }) + } + let active_manifest_label = claim.label().to_string(); + for status in statuses { + results.add_status(&active_manifest_label, status); } } + results +} + +// TODO: Does this still need to be public? (I do see one reference in the JS SDK.) - statuses +/// Given a `Store` and a `StatusTracker`, return `ValidationStatus` items for each +/// item in the tracker which reflect errors in the active manifest or which would not +/// be reported as a validation error for any ingredient. +pub fn status_for_store( + store: &Store, + validation_log: &impl StatusTracker, +) -> Vec { + let validation_results = validation_results_for_store(store, validation_log); + validation_results.validation_errors().unwrap_or_default() } // -- unofficial status code -- diff --git a/sdk/src/wasm/webcrypto_validator.rs b/sdk/src/wasm/webcrypto_validator.rs new file mode 100644 index 000000000..f8c186967 --- /dev/null +++ b/sdk/src/wasm/webcrypto_validator.rs @@ -0,0 +1,559 @@ +// 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::convert::TryFrom; + +use c2pa_crypto::{webcrypto::WindowOrWorker, SigningAlg}; +use js_sys::{Array, ArrayBuffer, Object, Reflect, Uint8Array}; +use spki::SubjectPublicKeyInfoRef; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; +use web_sys::{CryptoKey, SubtleCrypto}; +use x509_parser::der_parser::ber::{parse_ber_sequence, BerObject}; + +use crate::{Error, Result}; + +pub struct EcKeyImportParams { + name: String, + named_curve: String, + hash: String, +} + +impl EcKeyImportParams { + pub fn new(name: &str, hash: &str, named_curve: &str) -> Self { + EcKeyImportParams { + name: name.to_owned(), + named_curve: named_curve.to_owned(), + hash: hash.to_owned(), + } + } + + pub fn as_js_object(&self) -> Object { + let obj = Object::new(); + Reflect::set(&obj, &"name".into(), &self.name.clone().into()).expect("not valid name"); + Reflect::set(&obj, &"namedCurve".into(), &self.named_curve.clone().into()) + .expect("not valid name"); + + let inner_obj = Object::new(); + Reflect::set(&inner_obj, &"name".into(), &self.hash.clone().into()) + .expect("not valid name"); + + Reflect::set(&obj, &"hash".into(), &inner_obj).expect("not valid name"); + + obj + } +} + +pub struct EcdsaParams { + name: String, + hash: String, +} + +impl EcdsaParams { + pub fn new(name: &str, hash: &str) -> Self { + EcdsaParams { + name: name.to_owned(), + hash: hash.to_owned(), + } + } + + pub fn as_js_object(&self) -> Object { + let obj = Object::new(); + Reflect::set(&obj, &"name".into(), &self.name.clone().into()).expect("not valid name"); + + let inner_obj = Object::new(); + Reflect::set(&inner_obj, &"name".into(), &self.hash.clone().into()) + .expect("not valid name"); + + Reflect::set(&obj, &"hash".into(), &inner_obj).expect("not valid name"); + + obj + } +} + +fn data_as_array_buffer(data: &[u8]) -> ArrayBuffer { + let typed_array = Uint8Array::new_with_length(data.len() as u32); + typed_array.copy_from(data); + typed_array.buffer() +} + +async fn crypto_is_verified( + subtle_crypto: &SubtleCrypto, + alg: &Object, + key: &CryptoKey, + sig: &Object, + data: &Object, +) -> Result { + let promise = subtle_crypto + .verify_with_object_and_buffer_source_and_buffer_source(alg, key, sig, data) + .map_err(|_err| Error::WasmVerifier)?; + let verified: JsValue = JsFuture::from(promise) + .await + .map_err(|_err| Error::WasmVerifier)? + .into(); + let result = verified.is_truthy(); + web_sys::console::debug_2(&"verified".into(), &result.into()); + Ok(result) +} + +// Conversion utility from num-bigint::BigUint (used by x509_parser) +// to num-bigint-dig::BigUint (used by rsa) +fn biguint_val(ber_object: &BerObject) -> rsa::BigUint { + ber_object + .as_biguint() + .map(|x| x.to_u32_digits()) + .map(rsa::BigUint::new) + .unwrap_or_default() +} + +// Validate an Ed25519 signature for the provided data. The pkey must +// be the raw bytes representing CompressedEdwardsY. The length must 32 bytes. +fn ed25519_validate(sig: Vec, data: Vec, pkey: Vec) -> Result { + use ed25519_dalek::{Signature, Verifier, VerifyingKey, PUBLIC_KEY_LENGTH}; + + if pkey.len() == PUBLIC_KEY_LENGTH { + let ed_sig = Signature::from_slice(&sig).map_err(|_| Error::CoseInvalidCert)?; + + // convert to VerifyingKey + let mut cert_slice: [u8; 32] = Default::default(); + cert_slice.copy_from_slice(&pkey[0..PUBLIC_KEY_LENGTH]); + + let vk = VerifyingKey::from_bytes(&cert_slice).map_err(|_| Error::CoseInvalidCert)?; + + match vk.verify(&data, &ed_sig) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + } else { + web_sys::console::debug_2( + &"Ed25519 public key incorrect length: ".into(), + &pkey.len().to_string().into(), + ); + Err(Error::CoseInvalidCert) + } +} + +pub(crate) async fn async_validate( + algo: String, + hash: String, + _salt_len: u32, + pkey: Vec, + sig: Vec, + data: Vec, +) -> Result { + use rsa::{ + sha2::{Sha256, Sha384, Sha512}, + RsaPublicKey, + }; + + let context = WindowOrWorker::new(); + let subtle_crypto = context?.subtle_crypto()?; + let sig_array_buf = data_as_array_buffer(&sig); + let data_array_buf = data_as_array_buffer(&data); + + match algo.as_ref() { + "RSASSA-PKCS1-v1_5" => { + use rsa::{pkcs1v15::Signature, signature::Verifier}; + + // used for certificate validation + let spki = SubjectPublicKeyInfoRef::try_from(pkey.as_ref()) + .map_err(|err| Error::WasmRsaKeyImport(err.to_string()))?; + + let (_, seq) = parse_ber_sequence(&spki.subject_public_key.raw_bytes()) + .map_err(|err| Error::WasmRsaKeyImport(err.to_string()))?; + + let modulus = biguint_val(&seq[0]); + let exp = biguint_val(&seq[1]); + let public_key = RsaPublicKey::new(modulus, exp) + .map_err(|err| Error::WasmRsaKeyImport(err.to_string()))?; + let normalized_hash = hash.clone().replace("-", "").to_lowercase(); + + let result = match normalized_hash.as_ref() { + "sha256" => { + let vk = rsa::pkcs1v15::VerifyingKey::::new(public_key); + let signature: Signature = sig.as_slice().try_into().map_err(|_e| { + Error::WasmRsaKeyImport("could no process RSA signature".to_string()) + })?; + vk.verify(&data, &signature) + } + "sha384" => { + let vk = rsa::pkcs1v15::VerifyingKey::::new(public_key); + let signature: Signature = sig.as_slice().try_into().map_err(|_e| { + Error::WasmRsaKeyImport("could no process RSA signature".to_string()) + })?; + vk.verify(&data, &signature) + } + "sha512" => { + let vk = rsa::pkcs1v15::VerifyingKey::::new(public_key); + let signature: Signature = sig.as_slice().try_into().map_err(|_e| { + Error::WasmRsaKeyImport("could no process RSA signature".to_string()) + })?; + vk.verify(&data, &signature) + } + _ => return Err(Error::UnknownAlgorithm), + }; + + match result { + Ok(()) => { + web_sys::console::debug_1(&"RSA validation success:".into()); + Ok(true) + } + Err(err) => { + web_sys::console::debug_2( + &"RSA validation failed:".into(), + &err.to_string().into(), + ); + Ok(false) + } + } + } + "RSA-PSS" => { + use rsa::{pss::Signature, signature::Verifier}; + + let spki = SubjectPublicKeyInfoRef::try_from(pkey.as_ref()) + .map_err(|err| Error::WasmRsaKeyImport(err.to_string()))?; + + let (_, seq) = parse_ber_sequence(&spki.subject_public_key.raw_bytes()) + .map_err(|err| Error::WasmRsaKeyImport(err.to_string()))?; + + // We need to normalize this from SHA-256 (the format WebCrypto uses) to sha256 + // (the format the util function expects) so that it maps correctly + let normalized_hash = hash.clone().replace("-", "").to_lowercase(); + let modulus = biguint_val(&seq[0]); + let exp = biguint_val(&seq[1]); + let public_key = RsaPublicKey::new(modulus, exp) + .map_err(|err| Error::WasmRsaKeyImport(err.to_string()))?; + + let result = match normalized_hash.as_ref() { + "sha256" => { + let vk = rsa::pss::VerifyingKey::::new(public_key); + let signature: Signature = sig.as_slice().try_into().map_err(|_e| { + Error::WasmRsaKeyImport("could no process RSA signature".to_string()) + })?; + vk.verify(&data, &signature) + } + "sha384" => { + let vk = rsa::pss::VerifyingKey::::new(public_key); + let signature: Signature = sig.as_slice().try_into().map_err(|_e| { + Error::WasmRsaKeyImport("could no process RSA signature".to_string()) + })?; + vk.verify(&data, &signature) + } + "sha512" => { + let vk = rsa::pss::VerifyingKey::::new(public_key); + let signature: Signature = sig.as_slice().try_into().map_err(|_e| { + Error::WasmRsaKeyImport("could no process RSA signature".to_string()) + })?; + vk.verify(&data, &signature) + } + _ => return Err(Error::UnknownAlgorithm), + }; + + match result { + Ok(()) => { + web_sys::console::debug_1(&"RSA-PSS validation success:".into()); + Ok(true) + } + Err(err) => { + web_sys::console::debug_2( + &"RSA-PSS validation failed:".into(), + &err.to_string().into(), + ); + Ok(false) + } + } + } + "ECDSA" => { + // Create Key + let named_curve = match hash.as_ref() { + "SHA-256" => "P-256".to_string(), + "SHA-384" => "P-384".to_string(), + "SHA-512" => "P-521".to_string(), + _ => return Err(Error::UnsupportedType), + }; + let mut algorithm = EcKeyImportParams::new(&algo, &hash, &named_curve).as_js_object(); + let key_array_buf = data_as_array_buffer(&pkey); + let usages = Array::new(); + usages.push(&"verify".into()); + + let promise = subtle_crypto + .import_key_with_object("spki", &key_array_buf, &algorithm, true, &usages) + .map_err(|_err| Error::WasmKey)?; + let crypto_key: CryptoKey = JsFuture::from(promise) + .await + .map_err(|_| { + web_sys::console::debug_1(&"bad EC key".into()); + Error::CoseInvalidCert + })? + .into(); + web_sys::console::debug_2(&"CryptoKey".into(), &crypto_key); + + // Create verifier + algorithm = EcdsaParams::new(&algo, &hash).as_js_object(); + crypto_is_verified( + &subtle_crypto, + &algorithm, + &crypto_key, + &sig_array_buf, + &data_array_buf, + ) + .await + } + "ED25519" => { + use x509_parser::{prelude::*, public_key::PublicKey}; + + // pull out raw Ed code points + if let Ok((_, certificate_public_key)) = SubjectPublicKeyInfo::from_der(&pkey) { + match certificate_public_key.parsed() { + Ok(key) => match key { + PublicKey::Unknown(raw_key) => { + ed25519_validate(sig, data, raw_key.to_vec()) + } + _ => Err(Error::OtherError( + "could not unwrap Ed25519 public key".into(), + )), + }, + Err(_) => Err(Error::OtherError( + "could not recognize Ed25519 public key".into(), + )), + } + } else { + Err(Error::OtherError( + "could not parse Ed25519 public key".into(), + )) + } + } + _ => Err(Error::UnsupportedType), + } +} + +// This interface is called from CoseValidator. RSA validation not supported here. +pub async fn validate_async(alg: SigningAlg, sig: &[u8], data: &[u8], pkey: &[u8]) -> Result { + web_sys::console::debug_2(&"Validating with algorithm".into(), &alg.to_string().into()); + + match alg { + SigningAlg::Ps256 => { + async_validate( + "RSA-PSS".to_string(), + "SHA-256".to_string(), + 32, + pkey.to_vec(), + sig.to_vec(), + data.to_vec(), + ) + .await + } + SigningAlg::Ps384 => { + async_validate( + "RSA-PSS".to_string(), + "SHA-384".to_string(), + 48, + pkey.to_vec(), + sig.to_vec(), + data.to_vec(), + ) + .await + } + SigningAlg::Ps512 => { + async_validate( + "RSA-PSS".to_string(), + "SHA-512".to_string(), + 64, + pkey.to_vec(), + sig.to_vec(), + data.to_vec(), + ) + .await + } + // "rs256" => { + // async_validate( + // "RSASSA-PKCS1-v1_5".to_string(), + // "SHA-256".to_string(), + // 0, + // pkey.to_vec(), + // sig.to_vec(), + // data.to_vec(), + // ) + // .await + // } + // "rs384" => { + // async_validate( + // "RSASSA-PKCS1-v1_5".to_string(), + // "SHA-384".to_string(), + // 0, + // pkey.to_vec(), + // sig.to_vec(), + // data.to_vec(), + // ) + // .await + // } + // "rs512" => { + // async_validate( + // "RSASSA-PKCS1-v1_5".to_string(), + // "SHA-512".to_string(), + // 0, + // pkey.to_vec(), + // sig.to_vec(), + // data.to_vec(), + // ) + // .await + // } + SigningAlg::Es256 => { + async_validate( + "ECDSA".to_string(), + "SHA-256".to_string(), + 0, + pkey.to_vec(), + sig.to_vec(), + data.to_vec(), + ) + .await + } + SigningAlg::Es384 => { + async_validate( + "ECDSA".to_string(), + "SHA-384".to_string(), + 0, + pkey.to_vec(), + sig.to_vec(), + data.to_vec(), + ) + .await + } + SigningAlg::Es512 => { + async_validate( + "ECDSA".to_string(), + "SHA-512".to_string(), + 0, + pkey.to_vec(), + sig.to_vec(), + data.to_vec(), + ) + .await + } + SigningAlg::Ed25519 => { + async_validate( + "ED25519".to_string(), + "SHA-512".to_string(), + 0, + pkey.to_vec(), + sig.to_vec(), + data.to_vec(), + ) + .await + } + } +} + +#[cfg(test)] +pub mod tests { + #![allow(clippy::unwrap_used)] + + use c2pa_crypto::SigningAlg; + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::*; + + use super::*; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[wasm_bindgen_test] + async fn test_async_verify_rsa_pss() { + // PS signatures + let sig_bytes = include_bytes!("../../tests/fixtures/sig_ps256.data"); + let data_bytes = include_bytes!("../../tests/fixtures/data_ps256.data"); + let key_bytes = include_bytes!("../../tests/fixtures/key_ps256.data"); + + let validated = validate_async(SigningAlg::Ps256, sig_bytes, data_bytes, key_bytes) + .await + .unwrap(); + + assert_eq!(validated, true); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[wasm_bindgen_test] + async fn test_async_verify_ecdsa() { + // EC signatures + let sig_es384_bytes = include_bytes!("../../tests/fixtures/sig_es384.data"); + let data_es384_bytes = include_bytes!("../../tests/fixtures/data_es384.data"); + let key_es384_bytes = include_bytes!("../../tests/fixtures/key_es384.data"); + + let mut validated = validate_async( + SigningAlg::Es384, + sig_es384_bytes, + data_es384_bytes, + key_es384_bytes, + ) + .await + .unwrap(); + + assert_eq!(validated, true); + + let sig_es512_bytes = include_bytes!("../../tests/fixtures/sig_es512.data"); + let data_es512_bytes = include_bytes!("../../tests/fixtures/data_es512.data"); + let key_es512_bytes = include_bytes!("../../tests/fixtures/key_es512.data"); + + validated = validate_async( + SigningAlg::Es512, + sig_es512_bytes, + data_es512_bytes, + key_es512_bytes, + ) + .await + .unwrap(); + + assert_eq!(validated, true); + + let sig_es256_bytes = include_bytes!("../../tests/fixtures/sig_es256.data"); + let data_es256_bytes = include_bytes!("../../tests/fixtures/data_es256.data"); + let key_es256_bytes = include_bytes!("../../tests/fixtures/key_es256.data"); + + let validated = validate_async( + SigningAlg::Es256, + sig_es256_bytes, + data_es256_bytes, + key_es256_bytes, + ) + .await + .unwrap(); + + assert_eq!(validated, true); + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + #[wasm_bindgen_test] + #[ignore] + async fn test_async_verify_bad() { + let sig_bytes = include_bytes!("../../tests/fixtures/sig_ps256.data"); + let data_bytes = include_bytes!("../../tests/fixtures/data_ps256.data"); + let key_bytes = include_bytes!("../../tests/fixtures/key_ps256.data"); + + let mut bad_bytes = data_bytes.to_vec(); + bad_bytes[0] = b'c'; + bad_bytes[1] = b'2'; + bad_bytes[2] = b'p'; + bad_bytes[3] = b'a'; + + let validated = validate_async(SigningAlg::Ps256, sig_bytes, &bad_bytes, key_bytes) + .await + .unwrap(); + + assert_eq!(validated, false); + } +} diff --git a/sdk/tests/common/compare_readers.rs b/sdk/tests/common/compare_readers.rs index e68b91ba6..880ed0dcd 100644 --- a/sdk/tests/common/compare_readers.rs +++ b/sdk/tests/common/compare_readers.rs @@ -168,13 +168,13 @@ fn compare_json_values( || path.ends_with(".identifier") || path.ends_with(".time") || path.contains(".hash") + || path.contains(".label") || path.contains("claim_generator") // changes with every version (todo: get more specific) || val1.is_string() && val2.is_string() && val1.to_string().contains("urn:uuid:")) { if val2.is_null() { issues.push(format!("Missing {}: {}", path, val1)); } else if val2.is_null() { - dbg!(&path); issues.push(format!("Added {}: {}", path, val2)); } else { issues.push(format!("Changed {}: {} vs {}", path, val1, val2)); diff --git a/sdk/tests/fixtures/certs/trust/test_cert_root_bundle.pem b/sdk/tests/fixtures/certs/trust/test_cert_root_bundle.pem index 917e48708..8abd78c05 100644 --- a/sdk/tests/fixtures/certs/trust/test_cert_root_bundle.pem +++ b/sdk/tests/fixtures/certs/trust/test_cert_root_bundle.pem @@ -205,4 +205,5 @@ xk/1hyZg+khAPVKRxhWeIr6/KIuQYu6kJeTqmXKafx5oHAS6OqcK7G1KbEa1bWMV K0+GGwenJOzSTKWKtLO/6goBItGnhyQJCjwiBKOvcW5yfEVjLT+fJ7dkvlSzFMaM OZIbev39n3rQTWb4ORq1HIX2JwNsEQX+gBv6aGjMT2a88QFS0TsAA5LtFl8xeVgt xPd7wFhjRZHfuWb2cs63xjAGjQ== ------END CERTIFICATE----- \ No newline at end of file +-----END CERTIFICATE----- + diff --git a/sdk/tests/integration.rs b/sdk/tests/integration.rs index e3661fab8..8adb8d12d 100644 --- a/sdk/tests/integration.rs +++ b/sdk/tests/integration.rs @@ -123,7 +123,7 @@ mod integration_1 { parent.set_is_parent(); // add an action assertion stating that we imported this file actions = actions.add_action( - Action::new(c2pa_action::EDITED) + Action::new(c2pa_action::OPENED) .set_when("2015-06-26T16:43:23+0200") .set_parameter("name".to_owned(), "import")? .set_parameter("identifier".to_owned(), parent.instance_id().to_owned())?, diff --git a/sdk/tests/known_good/CA_test.json b/sdk/tests/known_good/CA_test.json index 4c0f7f2fa..46c9f8531 100644 --- a/sdk/tests/known_good/CA_test.json +++ b/sdk/tests/known_good/CA_test.json @@ -1,20 +1,20 @@ { - "active_manifest": "urn:uuid:b6eb5aed-cc20-469f-b23a-88eb3b43775b", + "active_manifest": "urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84", "manifests": { - "urn:uuid:b6eb5aed-cc20-469f-b23a-88eb3b43775b": { + "urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84": { "claim_generator": "test/1.0", "claim_generator_info": [ { "name": "test", "version": "1.0", - "org.cai.c2pa_rs": "0.37.1" + "org.cai.c2pa_rs": "0.40.0" } ], "format": "image/jpeg", - "instance_id": "xmp:iid:c0044b65-0988-459b-b08d-f890668cfb7d", + "instance_id": "xmp:iid:8ffe1962-953d-4629-9403-c7ba2af804e1", "thumbnail": { "format": "image/jpeg", - "identifier": "self#jumbf=/c2pa/urn:uuid:b6eb5aed-cc20-469f-b23a-88eb3b43775b/c2pa.assertions/c2pa.thumbnail.claim.jpeg" + "identifier": "self#jumbf=/c2pa/urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84/c2pa.assertions/c2pa.thumbnail.claim.jpeg" }, "ingredients": [], "assertions": [ @@ -43,7 +43,40 @@ "issuer": "C2PA Test Signing Cert", "cert_serial_number": "638838410810235485828984295321338730070538954823" }, - "label": "urn:uuid:b6eb5aed-cc20-469f-b23a-88eb3b43775b" + "label": "urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84" + } + }, + "validation_results": { + "activeManifest": { + "success": [ + { + "code": "claimSignature.validated", + "url": "self#jumbf=/c2pa/urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84/c2pa.signature", + "explanation": "claim signature valid" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84/c2pa.assertions/c2pa.thumbnail.claim.jpeg", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84/c2pa.assertions/c2pa.actions.v2", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84/c2pa.assertions/c2pa.hash.data", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data" + }, + { + "code": "assertion.dataHash.match", + "url": "self#jumbf=/c2pa/urn:uuid:08600fc7-b696-4410-8e73-a7d5dcf68c84/c2pa.assertions/c2pa.hash.data", + "explanation": "data hash valid" + } + ], + "informational": [], + "failure": [] } } } \ No newline at end of file diff --git a/sdk/tests/test_builder.rs b/sdk/tests/test_builder.rs index 9a47354a5..e1ced9ace 100644 --- a/sdk/tests/test_builder.rs +++ b/sdk/tests/test_builder.rs @@ -13,7 +13,9 @@ use std::io::{self, Cursor}; -use c2pa::{settings::load_settings_from_str, Builder, Reader, Result}; +use c2pa::{ + settings::load_settings_from_str, validation_status, Builder, Reader, Result, ValidationState, +}; mod common; use common::{compare_stream_to_known_good, fixtures_path, test_signer}; @@ -151,3 +153,32 @@ fn test_builder_remote_url_no_embed() -> Result<()> { } Ok(()) } + +#[test] +#[cfg_attr(not(any(target_arch = "wasm32", feature = "openssl")), ignore)] +fn test_builder_embedded_v1_otgp() -> Result<()> { + let manifest_def = include_str!("fixtures/simple_manifest.json"); + let mut source = Cursor::new(include_bytes!("fixtures/XCA.jpg")); + let format = "image/jpeg"; + + let mut builder = Builder::from_json(manifest_def)?; + builder.add_ingredient_from_stream(r#"{"relationship": "parentOf"}"#, format, &mut source)?; + source.set_position(0); + let mut dest = Cursor::new(Vec::new()); + builder.sign(&test_signer(), format, &mut source, &mut dest)?; + dest.set_position(0); + let reader = Reader::from_stream(format, &mut dest)?; + // check that the v1 OTGP is embedded and we catch it correct with validation_results + assert_eq!(reader.validation_status(), None); + assert_ne!(reader.validation_state(), ValidationState::Invalid); + //println!("reader: {}", reader); + assert_eq!( + reader.active_manifest().unwrap().ingredients()[0] + .validation_status() + .unwrap()[0] + .code(), + validation_status::ASSERTION_DATAHASH_MISMATCH + ); + + Ok(()) +} diff --git a/sdk/tests/test_reader.rs b/sdk/tests/test_reader.rs index ccbf6204b..449d4ea37 100644 --- a/sdk/tests/test_reader.rs +++ b/sdk/tests/test_reader.rs @@ -12,7 +12,7 @@ // each license. mod common; -use c2pa::{Error, Reader, Result}; +use c2pa::{validation_status, Error, Reader, Result}; use common::{assert_err, compare_to_known_good, fixture_stream}; #[test] @@ -52,6 +52,17 @@ fn test_reader_c_jpg() -> Result<()> { fn test_reader_xca_jpg() -> Result<()> { let (format, mut stream) = fixture_stream("XCA.jpg")?; let reader = Reader::from_stream(&format, &mut stream)?; + // validation_results should have the expected failure + assert_eq!( + reader + .validation_results() + .unwrap() + .active_manifest() + .unwrap() + .failure[0] + .code(), + validation_status::ASSERTION_DATAHASH_MISMATCH + ); compare_to_known_good(&reader, "XCA.json") } diff --git a/sdk/tests/v2_api_integration.rs b/sdk/tests/v2_api_integration.rs index 7a64ae6d8..39189bd57 100644 --- a/sdk/tests/v2_api_integration.rs +++ b/sdk/tests/v2_api_integration.rs @@ -72,7 +72,7 @@ mod integration_v2 { "data": { "actions": [ { - "action": "c2pa.edited", + "action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", "softwareAgent": "Adobe Firefly 0.1.0", "parameters": {