From 560fe01cccd3b36e706287a8e539cd8a1d237eab Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Sun, 15 Feb 2026 09:05:28 -0500 Subject: [PATCH 01/18] initial commit of crJson --- docs/crjson-format.adoc | 290 ++++ sdk/src/cr_json_reader.rs | 1734 +++++++++++++++++++ sdk/src/lib.rs | 4 + sdk/tests/test_cr_json_asset_hash.rs | 207 +++ sdk/tests/test_cr_json_created_gathered.rs | 286 +++ sdk/tests/test_cr_json_hash_assertions.rs | 287 +++ sdk/tests/test_cr_json_hash_encoding.rs | 313 ++++ sdk/tests/test_cr_json_ingredients.rs | 315 ++++ sdk/tests/test_cr_json_schema_compliance.rs | 330 ++++ 9 files changed, 3766 insertions(+) create mode 100644 docs/crjson-format.adoc create mode 100644 sdk/src/cr_json_reader.rs create mode 100644 sdk/tests/test_cr_json_asset_hash.rs create mode 100644 sdk/tests/test_cr_json_created_gathered.rs create mode 100644 sdk/tests/test_cr_json_hash_assertions.rs create mode 100644 sdk/tests/test_cr_json_hash_encoding.rs create mode 100644 sdk/tests/test_cr_json_ingredients.rs create mode 100644 sdk/tests/test_cr_json_schema_compliance.rs diff --git a/docs/crjson-format.adoc b/docs/crjson-format.adoc new file mode 100644 index 000000000..59273a1ba --- /dev/null +++ b/docs/crjson-format.adoc @@ -0,0 +1,290 @@ += Content Credential JSON (CrJSON) File Format Specification +:doctype: article +:toc: left +:toclevels: 3 +:sectnums: + +== 1. Scope + +This document describes a JSON serialization for Content Credentials (aka a C2PA manifest store) known as the *Content Credential JSON* format (abbreviated *CrJSON*). It's purpose is to provide a JSON-based representation of a C2PA manifest store for profile evaluation, interoperability testing, and validation reporting. + +== 2. Normative References + +* C2PA Technical Specification v2.3: https://c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html +* Local CrJSON JSON Schema: `/Users/lrosenth/Development/c2pa-rs/export_schema/indicators-schema.json` + +== 3. Relationship to C2PA v2.3 + +CrJSON does not replace C2PA claim stores, JUMBF, or COSE structures. Instead, it is a *derived JSON view* over C2PA data. + +The following C2PA concepts are directly represented: + +* C2PA manifests -> `manifests[]` +* C2PA assertions -> `manifests[].assertions` (object keyed by assertion label) +* C2PA claim data -> `manifests[].claim.v2` +* C2PA claim signature and credential details -> `manifests[].claim_signature` +* C2PA validation results -> `manifests[].status` and `extras:validation_status` + +== 4. Data Model Overview + +[source,text] +---- +C2PA Asset/Manifest Store + -> Reader validation + manifest extraction + -> CrJSON transformation + -> Output JSON object + |- @context + |- asset_info (optional) + |- manifests[] + | |- label + | |- claim.v2 + | |- assertions{...} + | |- claim_signature (optional) + | |- status (optional) + |- content + |- metadata (optional) + |- extras:validation_status (optional) +---- + +== 5. Serialization Requirements + +=== 5.1 Root Object + +A CrJSON document SHALL be a JSON object. + +The following top-level properties are used: + +[cols="1,1,3"] +|=== +|Property |Presence |Description + +|`@context` +|REQUIRED +|JSON-LD context. Implementation emits an object with `@vocab` and `extras`. + +|`asset_info` +|OPTIONAL +|Asset-level hash descriptor containing `alg` and `hash`. + +|`manifests` +|REQUIRED +|Array of manifest objects. + +|`content` +|REQUIRED +|Content metadata object. Current implementation emits `{}`. + +|`metadata` +|OPTIONAL +|Extended metadata object. Currently not populated by implementation. + +|`extras:validation_status` +|OPTIONAL +|Overall validation report for the active manifest. +|=== + +=== 5.2 `@context` + +Implementation output: + +[source,json] +---- +"@context": { + "@vocab": "https://jpeg.org/jpegtrust", + "extras": "https://jpeg.org/jpegtrust/extras" +} +---- + +=== 5.3 `asset_info` + +`asset_info` SHALL contain: + +* `alg`: hash algorithm identifier (for computed hash, `sha256`) +* `hash`: Base64-encoded digest bytes + +The implementation provides two paths: + +* `compute_asset_hash(...)` / `compute_asset_hash_from_file(...)`: computes SHA-256 and stores result. +* `set_asset_hash(algorithm, hash)`: caller supplies precomputed values. + +=== 5.4 `manifests` + +`manifests` SHALL be an array. Ordering rules: + +1. Active manifest first, if present. +2. Remaining manifests in store order, most recent first. + +Each manifest object includes: + +* `label` (manifest label/URN) +* `assertions` (object) +* `claim.v2` (object) +* `claim_signature` (optional) +* `status` (optional) + +=== 5.5 `claim.v2` + +`claim.v2` is a normalized object built from C2PA manifest/claim data. Typical fields: + +* `dc:title` +* `instanceID` +* `claim_generator` +* `claim_generator_info` +* `alg` (implementation currently emits `SHA-256`) +* `signature` (`self#jumbf=/c2pa/{label}/c2pa.signature`) +* `created_assertions[]` (`{url, hash}`) +* `gathered_assertions[]` (`{url, hash}`) +* `redacted_assertions[]` (currently empty array) + +All `hash` values in assertion references SHALL be Base64 strings. + +=== 5.6 `assertions` + +`assertions` SHALL be an object keyed by assertion label. + +==== 5.6.1 Included assertion sources + +CrJSON aggregates assertions from multiple C2PA sources: + +* Regular manifest assertions (`manifest.assertions()`) +* Hash assertions from claim store (`c2pa.hash.data`, `c2pa.hash.bmff`, `c2pa.hash.boxes`, including versioned variants) +* Ingredient assertions from `manifest.ingredients()` +* Gathered assertions not present in regular list (including binary/UUID payload cases) + +==== 5.6.2 Labeling and instance rules + +* Base label is the assertion label string. +* For repeated hash/gathered assertion instances, suffix `_N` is used where `N = instance + 1`. +* For multiple ingredient assertions, suffix `__N` is used where `N` is 1-based index. + +Examples: + +* `c2pa.hash.data` +* `c2pa.hash.bmff.v2` +* `c2pa.ingredient` +* `c2pa.ingredient.v3__2` + +==== 5.6.3 Binary normalization and hash encoding + +CrJSON normalizes byte-array encodings into Base64 string encodings. + +If fields are serialized as integer arrays, they are converted to Base64 strings for: + +* `hash` +* `pad` +* `pad1` +* `pad2` +* Certain `signature` byte payloads (decoded when possible; otherwise Base64) + +This rule applies recursively to nested objects/arrays. + +==== 5.6.4 Gathered binary assertion representation + +For gathered binary/UUID assertions, CrJSON emits a reference form: + +[source,json] +---- +{ + "format": "", + "identifier": "", + "hash": "" +} +---- + +=== 5.7 `claim_signature` + +When signature information is available, `claim_signature` includes: + +* `algorithm` +* `serial_number` +* `issuer` (DN map, e.g., `C`, `ST`, `L`, `O`, `OU`, `CN`) +* `subject` (DN map) +* `validity.not_before` +* `validity.not_after` + +The implementation parses the first certificate in the chain and serializes times as RFC 3339 strings. + +=== 5.8 `status` (per manifest) + +Per-manifest `status` is derived from active-manifest validation results and may include: + +* `signature`: first code prefixed by `claimSignature` +* `trust`: preferred signing credential code (`trusted`, else `invalid`, `untrusted`, `expired`, then fallback) +* `content`: first code prefixed by `assertion.dataHash` +* `assertion`: map of assertion label -> validation code (when found) + +=== 5.9 `extras:validation_status` (global) + +Global validation object includes: + +* `isValid` (boolean) +* `error` (`null` or first failure explanation) +* `validationErrors[]` objects with: +** `code` +** `message` (optional) +** `severity` (`error`) +* `entries[]` objects with: +** `code` +** `url` (optional) +** `explanation` (optional) +** `severity` (`info`, `warning`, or `error`) + +== 6. Constraints and Current Implementation Limits + +* `content` is currently emitted as an empty object. +* `metadata` extraction is currently not implemented by `JpegTrustReader` and is omitted. +* CrJSON is export-oriented; it is not the canonical source of cryptographic truth. Canonical validation remains bound to C2PA/JUMBF/COSE data structures per C2PA v2.3. + +== 7. Minimal Example + +[source,json] +---- +{ + "@context": { + "@vocab": "https://jpeg.org/jpegtrust", + "extras": "https://jpeg.org/jpegtrust/extras" + }, + "asset_info": { + "alg": "sha256", + "hash": "" + }, + "manifests": [ + { + "label": "urn:uuid:...", + "claim.v2": { + "instanceID": "xmp:iid:...", + "signature": "self#jumbf=/c2pa/urn:uuid:.../c2pa.signature", + "created_assertions": [], + "gathered_assertions": [], + "redacted_assertions": [] + }, + "assertions": { + "c2pa.actions.v2": {"actions": []}, + "c2pa.hash.data": {"alg": "sha256", "hash": ""} + }, + "status": { + "signature": "claimSignature.validated", + "trust": "signingCredential.trusted" + } + } + ], + "content": {}, + "extras:validation_status": { + "isValid": true, + "error": null, + "validationErrors": [], + "entries": [] + } +} +---- + +== 8. Conformance Guidance + +A CrJSON producer is conformant to this implementation profile when it: + +* Emits the root structure and field types described above. +* Emits Base64 strings for hash-bearing binary fields. +* Includes hash and ingredient assertions in `assertions`. +* Serializes validation output into both per-manifest `status` and global `extras:validation_status` when validation results are available. + +A CrJSON consumer should tolerate additional fields and assertion labels not explicitly listed here, consistent with C2PA extensibility. diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs new file mode 100644 index 000000000..f3be642d2 --- /dev/null +++ b/sdk/src/cr_json_reader.rs @@ -0,0 +1,1734 @@ +// 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. + +//! crJSON format exporter for C2PA manifests. +//! +//! This module provides a Reader-like API that exports C2PA manifests in the +//! crJSON format as described in the crJSON specification. + +use std::{ + io::{Read, Seek}, + sync::Arc, +}; + +use async_generic::async_generic; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use serde_with::skip_serializing_none; +use x509_parser::{certificate::X509Certificate, prelude::FromDer}; + +use crate::{ + assertion::AssertionData, + claim::Claim, + context::Context, + crypto::base64, + error::{Error, Result}, + jumbf::labels::to_absolute_uri, + reader::{AsyncPostValidator, MaybeSend, PostValidator, Reader}, + utils::hash_utils::hash_stream_by_alg, + validation_results::{ + validation_codes::{ + SIGNING_CREDENTIAL_EXPIRED, SIGNING_CREDENTIAL_INVALID, SIGNING_CREDENTIAL_TRUSTED, + SIGNING_CREDENTIAL_UNTRUSTED, + }, + ValidationState, + }, + validation_status::ValidationStatus, + Manifest, +}; + +/// Use a CrJsonReader to read and validate a manifest store in crJSON format. +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Default)] +pub struct CrJsonReader { + #[serde(skip)] + inner: Reader, + + /// Optional asset hash computed from the original asset + #[serde(skip)] + asset_hash: Option, +} + +/// Represents the hash of an asset +#[derive(Debug, Clone)] +struct AssetHash { + algorithm: String, + hash: String, +} + +impl CrJsonReader { + /// Create a new CrJsonReader with the given [`Context`]. + pub fn from_context(context: Context) -> Self { + Self { + inner: Reader::from_context(context), + asset_hash: None, + } + } + + /// Create a new CrJsonReader with a shared [`Context`]. + pub fn from_shared_context(context: &Arc) -> Self { + Self { + inner: Reader::from_shared_context(context), + asset_hash: None, + } + } + + /// Add manifest store from a stream to the [`CrJsonReader`] + #[async_generic] + pub fn with_stream( + mut self, + format: &str, + stream: impl Read + Seek + MaybeSend, + ) -> Result { + if _sync { + self.inner = self.inner.with_stream(format, stream)?; + } else { + self.inner = self.inner.with_stream_async(format, stream).await?; + } + Ok(self) + } + + /// Create a crJSON format [`CrJsonReader`] from a stream. + #[async_generic] + pub fn from_stream(format: &str, stream: impl Read + Seek + MaybeSend) -> Result { + if _sync { + Ok(Self { + inner: Reader::from_stream(format, stream)?, + asset_hash: None, + }) + } else { + Ok(Self { + inner: Reader::from_stream_async(format, stream).await?, + asset_hash: None, + }) + } + } + + /// Add manifest store from a file to the [`CrJsonReader`]. + #[cfg(feature = "file_io")] + #[async_generic] + pub fn with_file>(mut self, path: P) -> Result { + if _sync { + self.inner = self.inner.with_file(path)?; + } else { + self.inner = self.inner.with_file_async(path).await?; + } + Ok(self) + } + + /// Create a crJSON format [`CrJsonReader`] from a file. + #[cfg(feature = "file_io")] + #[async_generic] + pub fn from_file>(path: P) -> Result { + if _sync { + Ok(Self { + inner: Reader::from_file(path)?, + asset_hash: None, + }) + } else { + Ok(Self { + inner: Reader::from_file_async(path).await?, + asset_hash: None, + }) + } + } + + /// Add manifest store from existing `c2pa_data` and a stream to the [`CrJsonReader`]. + #[async_generic] + pub fn with_manifest_data_and_stream( + mut self, + c2pa_data: &[u8], + format: &str, + stream: impl Read + Seek + MaybeSend, + ) -> Result { + if _sync { + self.inner = self + .inner + .with_manifest_data_and_stream(c2pa_data, format, stream)?; + } else { + self.inner = self + .inner + .with_manifest_data_and_stream_async(c2pa_data, format, stream) + .await?; + } + Ok(self) + } + + /// Create a crJSON format [`CrJsonReader`] from existing `c2pa_data` and a stream. + #[async_generic] + pub fn from_manifest_data_and_stream( + c2pa_data: &[u8], + format: &str, + stream: impl Read + Seek + MaybeSend, + ) -> Result { + if _sync { + Ok(Self { + inner: Reader::from_manifest_data_and_stream(c2pa_data, format, stream)?, + asset_hash: None, + }) + } else { + Ok(Self { + inner: Reader::from_manifest_data_and_stream_async(c2pa_data, format, stream) + .await?, + asset_hash: None, + }) + } + } + + /// Post-validate the reader + #[async_generic(async_signature( + &mut self, + validator: &impl AsyncPostValidator + ))] + pub fn post_validate(&mut self, validator: &impl PostValidator) -> Result<()> { + if _sync { + self.inner.post_validate(validator) + } else { + self.inner.post_validate_async(validator).await + } + } + + /// Returns the remote url of the manifest if this [`CrJsonReader`] obtained the manifest remotely. + pub fn remote_url(&self) -> Option<&str> { + self.inner.remote_url() + } + + /// Returns if the [`CrJsonReader`] was created from an embedded manifest. + pub fn is_embedded(&self) -> bool { + self.inner.is_embedded() + } + + /// Get the [`ValidationState`] of the manifest store. + pub fn validation_state(&self) -> ValidationState { + self.inner.validation_state() + } + + /// Convert the reader to a crJSON format JSON value. + pub fn to_json_value(&self) -> Result { + let mut result = json!({ + "@context": { + "@vocab": "https://jpeg.org/jpegtrust", + "extras": "https://jpeg.org/jpegtrust/extras" + } + }); + + // Add asset_info if we have computed the hash + if let Some(asset_info) = self.get_asset_hash_json() { + result["asset_info"] = asset_info; + } + + // Convert manifests from HashMap to Array + let manifests_array = self.convert_manifests_to_array()?; + result["manifests"] = manifests_array; + + // Add content (typically empty) + result["content"] = json!({}); + + // Add metadata if available + if let Some(metadata) = self.extract_metadata()? { + result["metadata"] = metadata; + } + + // Add extras:validation_status + if let Some(validation_status) = self.build_validation_status()? { + result["extras:validation_status"] = validation_status; + } + + Ok(result) + } + + /// Get the CrJsonReader as a JSON string + pub fn json(&self) -> String { + match self.to_json_value() { + Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_default(), + Err(_) => "{}".to_string(), + } + } + + /// Compute and store the asset hash from a stream. + /// + /// This method computes the SHA-256 hash of the asset and stores it for inclusion + /// in the crJSON format output. The stream will be rewound to the beginning + /// before computing the hash. + /// + /// # Arguments + /// * `stream` - A readable and seekable stream containing the asset data + /// + /// # Returns + /// The computed hash as a base64-encoded string + /// + /// # Example + /// ```no_run + /// # use c2pa::{CrJsonReader, Result}; + /// # fn main() -> Result<()> { + /// use std::fs::File; + /// + /// let mut reader = CrJsonReader::from_file("image.jpg")?; + /// + /// // Compute hash from the same file + /// let mut file = File::open("image.jpg")?; + /// let hash = reader.compute_asset_hash(&mut file)?; + /// + /// // Now the JSON output will include asset_info + /// let json = reader.json(); + /// # Ok(()) + /// # } + /// ``` + pub fn compute_asset_hash(&mut self, stream: &mut (impl Read + Seek)) -> Result { + // Rewind to the beginning + stream.rewind()?; + + // Compute SHA-256 hash of the entire stream + let hash = hash_stream_by_alg("sha256", stream, None, true)?; + let hash_b64 = base64::encode(&hash); + + // Store for later use + self.asset_hash = Some(AssetHash { + algorithm: "sha256".to_string(), + hash: hash_b64.clone(), + }); + + Ok(hash_b64) + } + + /// Compute and store the asset hash from a file. + /// + /// This is a convenience method that opens the file and computes its hash. + /// + /// # Arguments + /// * `path` - Path to the asset file + /// + /// # Returns + /// The computed hash as a base64-encoded string + /// + /// # Example + /// ```no_run + /// # use c2pa::{CrJsonReader, Result}; + /// # fn main() -> Result<()> { + /// let mut reader = CrJsonReader::from_file("image.jpg")?; + /// let hash = reader.compute_asset_hash_from_file("image.jpg")?; + /// println!("Asset hash: {}", hash); + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "file_io")] + pub fn compute_asset_hash_from_file>( + &mut self, + path: P, + ) -> Result { + let mut file = std::fs::File::open(path)?; + self.compute_asset_hash(&mut file) + } + + /// Set the asset hash directly without computing it. + /// + /// This method allows you to provide a pre-computed hash, which can be useful + /// if you've already computed the hash elsewhere or want to use a different + /// algorithm. + /// + /// # Arguments + /// * `algorithm` - The hash algorithm used (e.g., "sha256") + /// * `hash` - The base64-encoded hash value + /// + /// # Example + /// ```no_run + /// # use c2pa::{CrJsonReader, Result}; + /// # fn main() -> Result<()> { + /// let mut reader = CrJsonReader::from_file("image.jpg")?; + /// reader.set_asset_hash("sha256", "JPkcXXC5DfT9IUUBPK5UaKxGsJ8YIE67BayL+ei3ats="); + /// # Ok(()) + /// # } + /// ``` + pub fn set_asset_hash(&mut self, algorithm: &str, hash: &str) { + self.asset_hash = Some(AssetHash { + algorithm: algorithm.to_string(), + hash: hash.to_string(), + }); + } + + /// Get the currently stored asset hash, if any. + /// + /// # Returns + /// A tuple of (algorithm, hash) if the hash has been set, or None + pub fn asset_hash(&self) -> Option<(&str, &str)> { + self.asset_hash + .as_ref() + .map(|h| (h.algorithm.as_str(), h.hash.as_str())) + } + + /// Get asset hash info for JSON output + fn get_asset_hash_json(&self) -> Option { + self.asset_hash.as_ref().map(|h| { + json!({ + "alg": h.algorithm, + "hash": h.hash + }) + }) + } + + /// Convert manifests from HashMap to Array format. + /// The first element is always the active manifest (if any); the rest are in manifest store order (most recent first, earliest last). + fn convert_manifests_to_array(&self) -> Result { + let active_label = self.inner.active_label(); + let mut labeled: Vec<(String, Value)> = Vec::new(); + + for (label, manifest) in self.inner.manifests() { + let mut manifest_obj = Map::new(); + manifest_obj.insert("label".to_string(), json!(label)); + + // Convert assertions from array to object + let assertions_obj = self.convert_assertions(manifest, label)?; + manifest_obj.insert("assertions".to_string(), json!(assertions_obj)); + + // Build claim.v2 object + let claim_v2 = self.build_claim_v2(manifest, label)?; + manifest_obj.insert("claim.v2".to_string(), claim_v2); + + // Build claim_signature object + if let Some(claim_signature) = self.build_claim_signature(manifest)? { + manifest_obj.insert("claim_signature".to_string(), claim_signature); + } + + // Build status object + if let Some(status) = self.build_manifest_status(manifest, label)? { + manifest_obj.insert("status".to_string(), status); + } + + labeled.push((label.clone(), Value::Object(manifest_obj))); + } + + // Order: active manifest first, then others by manifest store order (most recent first) + let store_order: std::collections::HashMap = self + .inner + .store + .claims() + .into_iter() + .enumerate() + .map(|(i, claim)| (claim.label().to_string(), i)) + .collect(); + labeled.sort_by(|a, b| { + let a_active = active_label.map_or(false, |l| l == a.0); + let b_active = active_label.map_or(false, |l| l == b.0); + match (a_active, b_active) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => { + let a_idx = store_order.get(&a.0).copied().unwrap_or(0); + let b_idx = store_order.get(&b.0).copied().unwrap_or(0); + b_idx.cmp(&a_idx) // descending: most recent (higher index) first + } + } + }); + + let manifests_array = labeled.into_iter().map(|(_, v)| v).collect(); + Ok(Value::Array(manifests_array)) + } + + /// Convert assertions from array format to object format (keyed by label) + fn convert_assertions(&self, manifest: &Manifest, manifest_label: &str) -> Result> { + let mut assertions_obj = Map::new(); + + // Process regular assertions + for assertion in manifest.assertions() { + let label = assertion.label().to_string(); + + // Try to get the value - if it fails (e.g., binary or CBOR), try to decode it + let value_result = if let Ok(value) = assertion.value() { + Ok(value.clone()) + } else { + // For CBOR assertions (like ingredients), try to decode them + self.decode_assertion_data(assertion) + }; + + if let Ok(value) = value_result { + // Fix any byte array hashes to base64 strings + let fixed_value = Self::fix_hash_encoding(value); + assertions_obj.insert(label, fixed_value); + } + } + + // Add hash assertions (c2pa.hash.data, c2pa.hash.bmff, c2pa.hash.boxes) + // These are filtered out by Manifest::from_store but we need them for crJSON format + if let Some(claim) = self.inner.store.get_claim(manifest_label) { + for hash_assertion in claim.hash_assertions() { + let label = hash_assertion.label_raw(); + let instance = hash_assertion.instance(); + + // Get the assertion and convert to JSON + if let Some(assertion) = claim.get_claim_assertion(&label, instance) { + if let Ok(assertion_obj) = assertion.assertion().as_json_object() { + let fixed_value = Self::fix_hash_encoding(assertion_obj); + + // Handle instance numbers for multiple assertions with same label + let final_label = if instance > 0 { + format!("{}_{}", label, instance + 1) + } else { + label + }; + + assertions_obj.insert(final_label, fixed_value); + } + } + } + } + + // Add ingredient assertions from the ingredients array + // Each ingredient is itself an assertion that should be in the assertions object + for (index, ingredient) in manifest.ingredients().iter().enumerate() { + // Convert ingredient to JSON + if let Ok(ingredient_json) = serde_json::to_value(ingredient) { + // Fix any byte array hashes to base64 strings + let mut fixed_ingredient = Self::fix_hash_encoding(ingredient_json); + + // Get the label from the ingredient (includes version if v2+) + // The label field contains the correct versioned label like "c2pa.ingredient.v2" + let base_label = if let Some(label_value) = fixed_ingredient.get("label") { + label_value + .as_str() + .unwrap_or("c2pa.ingredient") + .to_string() + } else { + "c2pa.ingredient".to_string() + }; + + // Remove the label field since it's redundant (the label is the key in assertions object) + if let Some(obj) = fixed_ingredient.as_object_mut() { + obj.remove("label"); + } + + // Add instance number if there are multiple ingredients + let label = if manifest.ingredients().len() > 1 { + format!("{}__{}", base_label, index + 1) + } else { + base_label + }; + + assertions_obj.insert(label, fixed_ingredient); + } + } + + // Add gathered assertions (e.g. c2pa.icon) that are not in manifest.assertions(). + // Manifest::from_store skips binary assertions like icon (AssertionData::Binary => {}), + // so they must be added from the claim's gathered_assertions for crJSON output. + if let Some(claim) = self.inner.store.get_claim(manifest_label) { + if let Some(gathered) = claim.gathered_assertions() { + for assertion_ref in gathered { + let (label, instance) = Claim::assertion_label_from_link(&assertion_ref.url()); + let final_label = if instance > 0 { + format!("{}_{}", label, instance + 1) + } else { + label.clone() + }; + if assertions_obj.contains_key(&final_label) { + continue; + } + if let Some(claim_assertion) = claim.get_claim_assertion(&label, instance) { + let assertion = claim_assertion.assertion(); + // Binary/Uuid assertions (e.g. c2pa.icon EmbeddedData): emit format, identifier, hash + // instead of raw bytes so the output is usable and matches claim_generator_info icon refs + let use_ref_format = matches!( + assertion.decode_data(), + AssertionData::Binary(_) | AssertionData::Uuid(_, _) + ); + if use_ref_format { + let absolute_uri = + to_absolute_uri(manifest_label, &assertion_ref.url()); + let mut ref_obj = Map::new(); + ref_obj.insert( + "format".to_string(), + json!(assertion.content_type()), + ); + ref_obj.insert("identifier".to_string(), json!(absolute_uri)); + ref_obj.insert( + "hash".to_string(), + json!(base64::encode(&assertion_ref.hash())), + ); + assertions_obj.insert(final_label, Value::Object(ref_obj)); + } else if let Ok(assertion_obj) = assertion.as_json_object() { + let fixed_value = Self::fix_hash_encoding(assertion_obj); + assertions_obj.insert(final_label, fixed_value); + } + } + } + } + } + + Ok(assertions_obj) + } + + /// Decode assertion data that's not in JSON format (e.g., CBOR) + fn decode_assertion_data(&self, assertion: &crate::ManifestAssertion) -> Result { + // Try to get binary data and decode as CBOR + if let Ok(binary) = assertion.binary() { + // Try to decode as CBOR to JSON + let cbor_value: c2pa_cbor::Value = c2pa_cbor::from_slice(binary)?; + let json_value: Value = serde_json::to_value(&cbor_value)?; + Ok(json_value) + } else { + Err(Error::UnsupportedType) + } + } + + /// Recursively convert byte array hashes and pads to base64 strings + /// + /// This fixes the issue where hash and pad fields are serialized as byte arrays + /// instead of base64 strings when converting from CBOR to JSON. + fn fix_hash_encoding(value: Value) -> Value { + match value { + Value::Object(mut map) => { + // Check if this object has a "hash" field that's an array + if let Some(hash_value) = map.get("hash") { + if let Some(hash_array) = hash_value.as_array() { + // Check if it's an array of integers (byte array) + if hash_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let bytes: Vec = hash_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Convert to base64 + let hash_b64 = base64::encode(&bytes); + map.insert("hash".to_string(), json!(hash_b64)); + } + } + } + + // Check if this object has a "pad" field that's an array + if let Some(pad_value) = map.get("pad") { + if let Some(pad_array) = pad_value.as_array() { + // Check if it's an array of integers (byte array) + if pad_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let bytes: Vec = pad_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Convert to base64 + let pad_b64 = base64::encode(&bytes); + map.insert("pad".to_string(), json!(pad_b64)); + } + } + } + + // Check if this object has a "pad1" field that's an array (cawg.identity) + if let Some(pad1_value) = map.get("pad1") { + if let Some(pad1_array) = pad1_value.as_array() { + // Check if it's an array of integers (byte array) + if pad1_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let bytes: Vec = pad1_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Convert to base64 + let pad1_b64 = base64::encode(&bytes); + map.insert("pad1".to_string(), json!(pad1_b64)); + } + } + } + + // Check if this object has a "pad2" field that's an array (cawg.identity) + if let Some(pad2_value) = map.get("pad2") { + if let Some(pad2_array) = pad2_value.as_array() { + // Check if it's an array of integers (byte array) + if pad2_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let bytes: Vec = pad2_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Convert to base64 + let pad2_b64 = base64::encode(&bytes); + map.insert("pad2".to_string(), json!(pad2_b64)); + } + } + } + + // Check if this object has a "signature" field that's an array (cawg.identity) + // This should be decoded as COSE_Sign1 and expanded similar to claimSignature + if let Some(signature_value) = map.get("signature") { + if let Some(sig_array) = signature_value.as_array() { + // Check if it's an array of integers (byte array) + if sig_array.iter().all(|v| v.is_u64() || v.is_i64()) { + // Convert to Vec + let sig_bytes: Vec = sig_array + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + + // Try to decode as COSE_Sign1 and extract certificate info + if let Ok(decoded_sig) = Self::decode_cawg_signature(&sig_bytes) { + map.insert("signature".to_string(), decoded_sig); + } else { + // If decoding fails, fall back to base64 + let sig_b64 = base64::encode(&sig_bytes); + map.insert("signature".to_string(), json!(sig_b64)); + } + } + } + } + + // Recursively process all values in the map + for (_key, val) in map.iter_mut() { + *val = Self::fix_hash_encoding(val.clone()); + } + + Value::Object(map) + } + Value::Array(arr) => { + // Recursively process all array elements + Value::Array(arr.into_iter().map(Self::fix_hash_encoding).collect()) + } + other => other, + } + } + + /// Build the claim.v2 object from scattered manifest properties + fn build_claim_v2(&self, manifest: &Manifest, label: &str) -> Result { + let mut claim_v2 = Map::new(); + + // Add dc:title + if let Some(title) = manifest.title() { + claim_v2.insert("dc:title".to_string(), json!(title)); + } + + // Add instanceID + claim_v2.insert("instanceID".to_string(), json!(manifest.instance_id())); + + // Add claim_generator (string, e.g. for V1) + if let Some(claim_generator) = manifest.claim_generator() { + claim_v2.insert("claim_generator".to_string(), json!(claim_generator)); + } + + // Add claim_generator_info (full info including name, version, icon) when present. + // This ensures icons and other generator details are exported to crJSON format. + if let Some(ref info_vec) = manifest.claim_generator_info { + if let Ok(info_value) = serde_json::to_value(info_vec) { + let fixed_info = Self::fix_hash_encoding(info_value); + claim_v2.insert("claim_generator_info".to_string(), fixed_info); + } + } + + // Add algorithm (from data hash assertion if available) + claim_v2.insert("alg".to_string(), json!("SHA-256")); + + // Add signature reference + let signature_ref = format!("self#jumbf=/c2pa/{}/c2pa.signature", label); + claim_v2.insert("signature".to_string(), json!(signature_ref)); + + // Build created_assertions and gathered_assertions arrays with hashes from the underlying claim + let (created_assertions, gathered_assertions) = self.build_assertion_references(manifest, label)?; + claim_v2.insert("created_assertions".to_string(), created_assertions); + claim_v2.insert("gathered_assertions".to_string(), gathered_assertions); + + // Add empty array for redacted assertions + claim_v2.insert("redacted_assertions".to_string(), json!([])); + + Ok(Value::Object(claim_v2)) + } + + /// Build assertion references with hashes from manifest + /// Returns a tuple of (created_assertions, gathered_assertions) arrays + fn build_assertion_references(&self, _manifest: &Manifest, label: &str) -> Result<(Value, Value)> { + // Get the underlying claim to access created_assertions and gathered_assertions separately + let claim = self.inner.store.get_claim(label) + .ok_or_else(|| Error::ClaimMissing { label: label.to_owned() })?; + + // Build created_assertions array + let mut created_refs = Vec::new(); + for assertion_ref in claim.created_assertions() { + let mut ref_obj = Map::new(); + ref_obj.insert("url".to_string(), json!(assertion_ref.url())); + let hash = assertion_ref.hash(); + ref_obj.insert("hash".to_string(), json!(base64::encode(&hash))); + created_refs.push(Value::Object(ref_obj)); + } + + // Build gathered_assertions array if available + let mut gathered_refs = Vec::new(); + if let Some(gathered) = claim.gathered_assertions() { + for assertion_ref in gathered { + let mut ref_obj = Map::new(); + ref_obj.insert("url".to_string(), json!(assertion_ref.url())); + let hash = assertion_ref.hash(); + ref_obj.insert("hash".to_string(), json!(base64::encode(&hash))); + gathered_refs.push(Value::Object(ref_obj)); + } + } + + // For Claim V1, created_assertions and gathered_assertions will both be empty + // In that case, populate created_assertions with all assertions (V1 doesn't distinguish) + if created_refs.is_empty() && gathered_refs.is_empty() && claim.version() == 1 { + for assertion_ref in claim.assertions() { + let mut ref_obj = Map::new(); + ref_obj.insert("url".to_string(), json!(assertion_ref.url())); + let hash = assertion_ref.hash(); + ref_obj.insert("hash".to_string(), json!(base64::encode(&hash))); + created_refs.push(Value::Object(ref_obj)); + } + } + + Ok((Value::Array(created_refs), Value::Array(gathered_refs))) + } + + /// Build claim_signature object with detailed certificate information + fn build_claim_signature(&self, manifest: &Manifest) -> Result> { + let sig_info = match manifest.signature_info() { + Some(info) => info, + None => return Ok(None), + }; + + let mut claim_signature = Map::new(); + + // Add algorithm + if let Some(alg) = &sig_info.alg { + claim_signature.insert("algorithm".to_string(), json!(alg.to_string())); + } + + // Parse certificate to get detailed DN components and validity + if let Some(cert_info) = self.parse_certificate(&sig_info.cert_chain)? { + // Add serial number (hex format) + if let Some(serial) = cert_info.serial_number { + claim_signature.insert("serial_number".to_string(), json!(serial)); + } + + // Add issuer DN components + if let Some(issuer) = cert_info.issuer { + claim_signature.insert("issuer".to_string(), json!(issuer)); + } + + // Add subject DN components + if let Some(subject) = cert_info.subject { + claim_signature.insert("subject".to_string(), json!(subject)); + } + + // Add validity period + if let Some(validity) = cert_info.validity { + claim_signature.insert("validity".to_string(), json!(validity)); + } + } + + Ok(Some(Value::Object(claim_signature))) + } + + /// Parse certificate to extract DN components and validity + fn parse_certificate(&self, cert_chain: &str) -> Result> { + // Parse PEM format certificate chain + let cert_der = self.parse_pem_to_der(cert_chain)?; + if cert_der.is_empty() { + return Ok(None); + } + + // Parse the first certificate (end entity) + let (_, cert) = X509Certificate::from_der(&cert_der[0]).map_err(|_e| { + Error::CoseInvalidCert // Use appropriate error type + })?; + + let mut details = CertificateDetails::default(); + + // Extract serial number in hex format + details.serial_number = Some(format!("{:x}", cert.serial)); + + // Extract issuer DN components + details.issuer = Some(self.extract_dn_components(cert.issuer())?); + + // Extract subject DN components + details.subject = Some(self.extract_dn_components(cert.subject())?); + + // Extract validity period + let not_before = cert.validity().not_before.to_datetime(); + let not_after = cert.validity().not_after.to_datetime(); + // Convert OffsetDateTime to RFC3339 format using chrono + let not_before_chrono: DateTime = + DateTime::from_timestamp(not_before.unix_timestamp(), 0) + .ok_or(Error::CoseInvalidCert)?; + let not_after_chrono: DateTime = + DateTime::from_timestamp(not_after.unix_timestamp(), 0) + .ok_or(Error::CoseInvalidCert)?; + details.validity = Some(json!({ + "not_before": not_before_chrono.to_rfc3339(), + "not_after": not_after_chrono.to_rfc3339() + })); + + Ok(Some(details)) + } + + /// Extract DN components from X.509 name + fn extract_dn_components( + &self, + name: &x509_parser::x509::X509Name, + ) -> Result> { + let mut components = Map::new(); + + for rdn in name.iter() { + for attr in rdn.iter() { + let oid = attr.attr_type(); + let value = attr.as_str().map_err(|_| Error::CoseInvalidCert)?; + + // Map OIDs to standard abbreviations + let key = match oid.to_string().as_str() { + "2.5.4.6" => "C", // countryName + "2.5.4.8" => "ST", // stateOrProvinceName + "2.5.4.7" => "L", // localityName + "2.5.4.10" => "O", // organizationName + "2.5.4.11" => "OU", // organizationalUnitName + "2.5.4.3" => "CN", // commonName + _ => continue, // Skip unknown OIDs + }; + + components.insert(key.to_string(), json!(value)); + } + } + + Ok(components) + } + + /// Parse PEM format certificate chain to DER format + fn parse_pem_to_der(&self, pem_chain: &str) -> Result>> { + let mut certs = Vec::new(); + + for pem in pem_chain.split("-----BEGIN CERTIFICATE-----") { + if let Some(end_idx) = pem.find("-----END CERTIFICATE-----") { + // Extract PEM data and remove all whitespace (newlines, spaces, tabs) + let pem_data: String = pem[..end_idx] + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + if let Ok(der) = base64::decode(&pem_data) { + certs.push(der); + } + } + } + + Ok(certs) + } + + /// Decode cawg.identity signature field (COSE_Sign1) and extract certificate info + /// Similar to build_claim_signature but for the signature field in cawg.identity assertions + fn decode_cawg_signature(signature_bytes: &[u8]) -> Result { + use coset::{CoseSign1, TaggedCborSerializable}; + use crate::crypto::cose::{cert_chain_from_sign1, signing_alg_from_sign1}; + + // Parse COSE_Sign1 + let sign1 = ::from_tagged_slice(signature_bytes) + .map_err(|_| Error::CoseSignature)?; + + let mut signature_obj = Map::new(); + + // Extract algorithm from protected headers + if let Ok(alg) = signing_alg_from_sign1(&sign1) { + signature_obj.insert("algorithm".to_string(), json!(alg.to_string())); + } + + // Try to extract X.509 certificate chain (for cawg.x509.cose signatures) + if let Ok(cert_chain) = cert_chain_from_sign1(&sign1) { + if !cert_chain.is_empty() { + // Parse the first certificate (end entity) + if let Ok((_rem, cert)) = X509Certificate::from_der(&cert_chain[0]) { + // Extract serial number in hex format + signature_obj.insert("serial_number".to_string(), json!(format!("{:x}", cert.serial))); + + // Extract issuer DN components + if let Ok(issuer) = Self::extract_dn_components_static(cert.issuer()) { + signature_obj.insert("issuer".to_string(), json!(issuer)); + } + + // Extract subject DN components + if let Ok(subject) = Self::extract_dn_components_static(cert.subject()) { + signature_obj.insert("subject".to_string(), json!(subject)); + } + + // Extract validity period + let not_before = cert.validity().not_before.to_datetime(); + let not_after = cert.validity().not_after.to_datetime(); + + if let Some(not_before_chrono) = DateTime::::from_timestamp(not_before.unix_timestamp(), 0) { + if let Some(not_after_chrono) = DateTime::::from_timestamp(not_after.unix_timestamp(), 0) { + signature_obj.insert("validity".to_string(), json!({ + "not_before": not_before_chrono.to_rfc3339(), + "not_after": not_after_chrono.to_rfc3339() + })); + } + } + } + } + } + + // If no certificate chain was found, try to extract Verifiable Credential + // (for cawg.identity_claims_aggregation signatures) + if !signature_obj.contains_key("serial_number") { + if let Some(payload) = sign1.payload.as_ref() { + // Try to parse payload as JSON (W3C Verifiable Credential) + if let Ok(vc_value) = serde_json::from_slice::(payload) { + // Extract issuer (DID) + if let Some(issuer) = vc_value.get("issuer") { + signature_obj.insert("issuer".to_string(), issuer.clone()); + } + + // Extract validity period + if let Some(valid_from) = vc_value.get("validFrom") { + signature_obj.insert("validFrom".to_string(), valid_from.clone()); + } + if let Some(valid_until) = vc_value.get("validUntil") { + signature_obj.insert("validUntil".to_string(), valid_until.clone()); + } + + // Extract verified identities from credential subject + if let Some(cred_subject) = vc_value.get("credentialSubject") { + if let Some(verified_ids) = cred_subject.get("verifiedIdentities") { + signature_obj.insert("verifiedIdentities".to_string(), verified_ids.clone()); + } + } + + // Mark this as an ICA credential + signature_obj.insert("credentialType".to_string(), json!("IdentityClaimsAggregation")); + } + } + } + + Ok(Value::Object(signature_obj)) + } + + /// Static version of extract_dn_components for use in decode_cawg_signature + fn extract_dn_components_static(name: &x509_parser::x509::X509Name) -> Result> { + let mut components = Map::new(); + + for rdn in name.iter() { + for attr in rdn.iter() { + let oid = attr.attr_type(); + let value = attr.as_str().map_err(|_| Error::CoseInvalidCert)?; + + // Map OIDs to standard abbreviations + let key = match oid.to_string().as_str() { + "2.5.4.6" => "C", // countryName + "2.5.4.8" => "ST", // stateOrProvinceName + "2.5.4.7" => "L", // localityName + "2.5.4.10" => "O", // organizationName + "2.5.4.11" => "OU", // organizationalUnitName + "2.5.4.3" => "CN", // commonName + _ => continue, // Skip unknown OIDs + }; + + components.insert(key.to_string(), json!(value)); + } + } + + Ok(components) + } + + /// Build status object for a manifest + fn build_manifest_status(&self, manifest: &Manifest, _label: &str) -> Result> { + let validation_results = match self.inner.validation_results() { + Some(results) => results, + None => return Ok(None), + }; + + let mut status = Map::new(); + + // Extract key validation codes from results + let active_manifest = match validation_results.active_manifest() { + Some(am) => am, + None => return Ok(None), + }; + + // Signature validation status + if let Some(sig_code) = + Self::find_validation_code(&active_manifest.success, "claimSignature") + { + status.insert("signature".to_string(), json!(sig_code)); + } + + // Trust status: prefer trusted, invalid, untrusted, expired; else first signingCredential code + if let Some(trust_code) = + Self::find_preferred_trust_code(&active_manifest) + { + status.insert("trust".to_string(), json!(trust_code)); + } else if let Some(trust_code) = + Self::find_validation_code(&active_manifest.success, "signingCredential") + { + status.insert("trust".to_string(), json!(trust_code)); + } else if let Some(trust_code) = + Self::find_validation_code(&active_manifest.failure, "signingCredential") + { + status.insert("trust".to_string(), json!(trust_code)); + } + + // Content validation status + if let Some(content_code) = + Self::find_validation_code(&active_manifest.success, "assertion.dataHash") + { + status.insert("content".to_string(), json!(content_code)); + } + + // Assertion-specific validation codes + let mut assertion_status = Map::new(); + for assertion in manifest.assertions() { + let assertion_label = assertion.label(); + if let Some(code) = + Self::find_validation_code_for_assertion(&active_manifest.success, assertion_label) + { + assertion_status.insert(assertion_label.to_string(), json!(code)); + } + } + if !assertion_status.is_empty() { + status.insert("assertion".to_string(), Value::Object(assertion_status)); + } + + Ok(Some(Value::Object(status))) + } + + /// Find a preferred trust status (trusted, invalid, untrusted, expired) in the active manifest. + /// Returns the first match in that order; if none found, returns None (caller should fall back + /// to first signingCredential code). + fn find_preferred_trust_code(active_manifest: &crate::validation_results::StatusCodes) -> Option { + // Trusted is in success + if active_manifest.success.iter().any(|s| s.code() == SIGNING_CREDENTIAL_TRUSTED) { + return Some(SIGNING_CREDENTIAL_TRUSTED.to_string()); + } + // Invalid, untrusted, expired are in failure (check in that order) + for code in &[ + SIGNING_CREDENTIAL_INVALID, + SIGNING_CREDENTIAL_UNTRUSTED, + SIGNING_CREDENTIAL_EXPIRED, + ] { + if active_manifest.failure.iter().any(|s| s.code() == *code) { + return Some((*code).to_string()); + } + } + None + } + + /// Find a validation code matching a prefix in a list of validation statuses + fn find_validation_code(statuses: &[ValidationStatus], prefix: &str) -> Option { + statuses + .iter() + .find(|s| s.code().starts_with(prefix)) + .map(|s| s.code().to_string()) + } + + /// Find validation code for a specific assertion + fn find_validation_code_for_assertion( + statuses: &[ValidationStatus], + assertion_label: &str, + ) -> Option { + statuses + .iter() + .find(|s| { + s.url() + .map(|u| u.contains(assertion_label)) + .unwrap_or(false) + }) + .map(|s| s.code().to_string()) + } + + /// Extract metadata from manifest (placeholder - not fully available) + fn extract_metadata(&self) -> Result> { + // TODO: This would require extracting EXIF/XMP metadata from the asset + // which is not currently available from the Reader API. + Ok(None) + } + + /// Build extras:validation_status from validation results + fn build_validation_status(&self) -> Result> { + let validation_results = match self.inner.validation_results() { + Some(results) => results, + None => return Ok(None), + }; + + let mut validation_status = Map::new(); + + // Determine overall validity + let is_valid = validation_results.validation_state() != ValidationState::Invalid; + validation_status.insert("isValid".to_string(), json!(is_valid)); + + // Add error field (null if valid, or first error message if not) + let error_message = if !is_valid { + if let Some(active_manifest) = validation_results.active_manifest() { + active_manifest + .failure + .first() + .and_then(|s| s.explanation()) + .map(|e| Value::String(e.to_string())) + .unwrap_or(Value::Null) + } else { + Value::Null + } + } else { + Value::Null + }; + validation_status.insert("error".to_string(), error_message); + + // Build validationErrors array from failures (as objects with code, message, severity) + let mut errors = Vec::new(); + if let Some(active_manifest) = validation_results.active_manifest() { + for status in active_manifest.failure.iter() { + let mut error_obj = Map::new(); + error_obj.insert("code".to_string(), json!(status.code())); + if let Some(explanation) = status.explanation() { + error_obj.insert("message".to_string(), json!(explanation)); + } + error_obj.insert("severity".to_string(), json!("error")); + errors.push(Value::Object(error_obj)); + } + } + validation_status.insert("validationErrors".to_string(), json!(errors)); + + // Build entries array from all validation statuses + let mut entries = Vec::new(); + + if let Some(active_manifest) = validation_results.active_manifest() { + // Add success entries + for status in active_manifest.success.iter() { + entries.push(self.build_validation_entry(status, "info")?); + } + + // Add informational entries + for status in active_manifest.informational.iter() { + entries.push(self.build_validation_entry(status, "warning")?); + } + + // Add failure entries + for status in active_manifest.failure.iter() { + entries.push(self.build_validation_entry(status, "error")?); + } + } + + validation_status.insert("entries".to_string(), json!(entries)); + + Ok(Some(Value::Object(validation_status))) + } + + /// Build a single validation entry for the entries array + fn build_validation_entry(&self, status: &ValidationStatus, severity: &str) -> Result { + let mut entry = Map::new(); + entry.insert("code".to_string(), json!(status.code())); + if let Some(url) = status.url() { + entry.insert("url".to_string(), json!(url)); + } + if let Some(explanation) = status.explanation() { + entry.insert("explanation".to_string(), json!(explanation)); + } + entry.insert("severity".to_string(), json!(severity)); + Ok(Value::Object(entry)) + } + + /// Get a reference to the underlying Reader + pub fn inner(&self) -> &Reader { + &self.inner + } + + /// Get a mutable reference to the underlying Reader + pub fn inner_mut(&mut self) -> &mut Reader { + &mut self.inner + } +} + +/// Certificate details extracted from X.509 certificate +#[derive(Debug, Default)] +struct CertificateDetails { + serial_number: Option, + issuer: Option>, + subject: Option>, + validity: Option, +} + +/// Prints the JSON of the crJSON format manifest data. +impl std::fmt::Display for CrJsonReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.json().as_str()) + } +} + +/// Prints the full debug details of the crJSON format manifest data. +impl std::fmt::Debug for CrJsonReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let json = self.to_json_value().map_err(|_| std::fmt::Error)?; + let output = serde_json::to_string_pretty(&json).map_err(|_| std::fmt::Error)?; + f.write_str(&output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../tests/fixtures/CA.jpg"); + + #[test] + fn test_jpeg_trust_reader_from_stream() -> Result<()> { + let reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + assert_eq!(reader.validation_state(), ValidationState::Trusted); + Ok(()) + } + + #[test] + fn test_jpeg_trust_format_json() -> Result<()> { + let reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + let json_value = reader.to_json_value()?; + + // Verify required fields + assert!(json_value.get("@context").is_some()); + assert!(json_value.get("manifests").is_some()); + + // Verify manifests is an array + assert!(json_value["manifests"].is_array()); + + // Verify first manifest structure + if let Some(manifest) = json_value["manifests"].as_array().and_then(|a| a.first()) { + assert!(manifest.get("label").is_some()); + assert!(manifest.get("assertions").is_some()); + assert!(manifest.get("claim.v2").is_some()); + + // Verify assertions is an object (not array) + assert!(manifest["assertions"].is_object()); + + // Verify claim.v2 has expected fields + let claim_v2 = &manifest["claim.v2"]; + assert!(claim_v2.get("instanceID").is_some()); + assert!(claim_v2.get("signature").is_some()); + assert!(claim_v2.get("created_assertions").is_some()); + } + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_jpeg_trust_reader_from_file() -> Result<()> { + let reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + assert_eq!(reader.validation_state(), ValidationState::Trusted); + + let json = reader.json(); + assert!(json.contains("@context")); + assert!(json.contains("manifests")); + + Ok(()) + } + + #[test] + fn test_compute_asset_hash_from_stream() -> Result<()> { + // Create reader + let mut reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Initially no asset hash + assert!(reader.asset_hash().is_none()); + + // Compute hash from stream + let mut stream = std::io::Cursor::new(IMAGE_WITH_MANIFEST); + let hash = reader.compute_asset_hash(&mut stream)?; + + // Verify hash was computed + assert!(!hash.is_empty()); + assert!(reader.asset_hash().is_some()); + + // Verify hash is accessible + let (alg, stored_hash) = reader.asset_hash().unwrap(); + assert_eq!(alg, "sha256"); + assert_eq!(stored_hash, hash); + + // Verify JSON output includes asset_info + let json_value = reader.to_json_value()?; + assert!(json_value.get("asset_info").is_some()); + + let asset_info = &json_value["asset_info"]; + assert_eq!(asset_info["alg"], "sha256"); + assert_eq!(asset_info["hash"], hash); + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_compute_asset_hash_from_file() -> Result<()> { + // Create reader + let mut reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + + // Compute hash from same file + let hash = reader.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; + + // Verify hash was computed + assert!(!hash.is_empty()); + assert!(reader.asset_hash().is_some()); + + // Verify JSON includes asset_info + let json_value = reader.to_json_value()?; + assert!(json_value.get("asset_info").is_some()); + + let asset_info = &json_value["asset_info"]; + assert_eq!(asset_info["alg"], "sha256"); + assert_eq!(asset_info["hash"], hash); + + Ok(()) + } + + #[test] + fn test_set_asset_hash_directly() -> Result<()> { + // Create reader + let mut reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Set hash directly + let test_hash = "JPkcXXC5DfT9IUUBPK5UaKxGsJ8YIE67BayL+ei3ats="; + reader.set_asset_hash("sha256", test_hash); + + // Verify hash is set + let (alg, hash) = reader.asset_hash().unwrap(); + assert_eq!(alg, "sha256"); + assert_eq!(hash, test_hash); + + // Verify JSON includes asset_info + let json_value = reader.to_json_value()?; + let asset_info = &json_value["asset_info"]; + assert_eq!(asset_info["alg"], "sha256"); + assert_eq!(asset_info["hash"], test_hash); + + Ok(()) + } + + #[test] + fn test_asset_hash_consistency() -> Result<()> { + // Create two readers from the same data + let mut reader1 = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + let mut reader2 = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Compute hashes + let mut stream1 = std::io::Cursor::new(IMAGE_WITH_MANIFEST); + let hash1 = reader1.compute_asset_hash(&mut stream1)?; + + let mut stream2 = std::io::Cursor::new(IMAGE_WITH_MANIFEST); + let hash2 = reader2.compute_asset_hash(&mut stream2)?; + + // Hashes should be identical + assert_eq!(hash1, hash2); + + Ok(()) + } + + #[test] + fn test_json_without_asset_hash() -> Result<()> { + // Create reader without computing hash + let reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + // JSON should not include asset_info + let json_value = reader.to_json_value()?; + assert!(json_value.get("asset_info").is_none()); + + Ok(()) + } + + #[test] + fn test_json_with_asset_hash() -> Result<()> { + // Create reader and compute hash + let mut reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + let mut stream = std::io::Cursor::new(IMAGE_WITH_MANIFEST); + reader.compute_asset_hash(&mut stream)?; + + // JSON should include asset_info + let json_value = reader.to_json_value()?; + assert!(json_value.get("asset_info").is_some()); + + // Verify structure + let asset_info = &json_value["asset_info"]; + assert!(asset_info.get("alg").is_some()); + assert!(asset_info.get("hash").is_some()); + + Ok(()) + } + + #[test] + fn test_asset_hash_update() -> Result<()> { + // Create reader + let mut reader = + CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Set initial hash + reader.set_asset_hash("sha256", "hash1"); + assert_eq!(reader.asset_hash().unwrap().1, "hash1"); + + // Update hash + reader.set_asset_hash("sha512", "hash2"); + let (alg, hash) = reader.asset_hash().unwrap(); + assert_eq!(alg, "sha512"); + assert_eq!(hash, "hash2"); + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_asset_hash_with_different_files() -> Result<()> { + // Test with two different files + let mut reader1 = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + let hash1 = reader1.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; + + let mut reader2 = CrJsonReader::from_file("tests/fixtures/C.jpg")?; + let hash2 = reader2.compute_asset_hash_from_file("tests/fixtures/C.jpg")?; + + // Different files should have different hashes + assert_ne!(hash1, hash2); + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_claim_signature_decoding() -> Result<()> { + // Test that claim_signature is decoded with full certificate details + let mut reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + reader.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; + + let json_value = reader.to_json_value()?; + let manifests = json_value["manifests"].as_array().unwrap(); + + // Find a manifest with claim_signature + let manifest = manifests.iter().find(|m| m.get("claim_signature").is_some()); + assert!(manifest.is_some(), "Should have a manifest with claim_signature"); + + let claim_sig = &manifest.unwrap()["claim_signature"]; + + // Verify algorithm is present + assert!(claim_sig.get("algorithm").is_some(), "claim_signature should have algorithm"); + + // Verify certificate details are decoded (not just algorithm) + // Should have serial_number, issuer, subject, and validity for X.509 certificates + assert!( + claim_sig.get("serial_number").is_some(), + "claim_signature should have serial_number from decoded certificate" + ); + assert!( + claim_sig.get("issuer").is_some(), + "claim_signature should have issuer from decoded certificate" + ); + assert!( + claim_sig.get("subject").is_some(), + "claim_signature should have subject from decoded certificate" + ); + assert!( + claim_sig.get("validity").is_some(), + "claim_signature should have validity from decoded certificate" + ); + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_cawg_identity_x509_signature_decoding() -> Result<()> { + // Test that cawg.identity with X.509 signature is fully decoded + let mut reader = CrJsonReader::from_file("tests/fixtures/C_with_CAWG_data.jpg")?; + reader.compute_asset_hash_from_file("tests/fixtures/C_with_CAWG_data.jpg")?; + + let json_value = reader.to_json_value()?; + let manifests = json_value["manifests"].as_array().unwrap(); + + // Find the manifest and its cawg.identity assertion + let manifest = &manifests[0]; + let assertions = manifest["assertions"].as_object().unwrap(); + + let cawg_identity = assertions.get("cawg.identity"); + assert!(cawg_identity.is_some(), "Should have cawg.identity assertion"); + + let cawg_identity = cawg_identity.unwrap(); + + // Verify pad1 and pad2 are base64 strings, not arrays + assert!( + cawg_identity["pad1"].is_string(), + "pad1 should be a base64 string, not an array" + ); + if cawg_identity.get("pad2").is_some() { + assert!( + cawg_identity["pad2"].is_string(), + "pad2 should be a base64 string, not an array" + ); + } + + // Verify signature is decoded + let signature = &cawg_identity["signature"]; + assert!(signature.is_object(), "signature should be an object, not an array"); + + // For X.509 signatures (sig_type: cawg.x509.cose), verify certificate details + let sig_type = cawg_identity["signer_payload"]["sig_type"].as_str().unwrap(); + if sig_type == "cawg.x509.cose" { + assert!( + signature.get("algorithm").is_some(), + "signature should have algorithm" + ); + assert!( + signature.get("serial_number").is_some(), + "X.509 signature should have serial_number" + ); + assert!( + signature.get("issuer").is_some(), + "X.509 signature should have issuer DN components" + ); + assert!( + signature.get("subject").is_some(), + "X.509 signature should have subject DN components" + ); + assert!( + signature.get("validity").is_some(), + "X.509 signature should have validity period" + ); + } + + Ok(()) + } + + #[test] + #[cfg(feature = "file_io")] + fn test_cawg_identity_ica_signature_decoding() -> Result<()> { + // Test that cawg.identity with ICA signature extracts VC information + // Note: This test uses a fixture from the identity tests + let test_image = include_bytes!("identity/tests/fixtures/claim_aggregation/adobe_connected_identities.jpg"); + + let mut reader = CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(&test_image[..]))?; + let mut stream = std::io::Cursor::new(&test_image[..]); + reader.compute_asset_hash(&mut stream)?; + + let json_value = reader.to_json_value()?; + let manifests = json_value["manifests"].as_array().unwrap(); + + // Find a manifest with cawg.identity assertion + let manifest = manifests.iter().find(|m| { + if let Some(assertions) = m.get("assertions").and_then(|a| a.as_object()) { + assertions.keys().any(|k| k.starts_with("cawg.identity")) + } else { + false + } + }); + + if let Some(manifest) = manifest { + let assertions = manifest["assertions"].as_object().unwrap(); + let cawg_identity_key = assertions.keys().find(|k| k.starts_with("cawg.identity")).unwrap(); + let cawg_identity = &assertions[cawg_identity_key]; + + // Verify pad fields are base64 strings + assert!( + cawg_identity["pad1"].is_string(), + "pad1 should be a base64 string" + ); + + // Verify signature is decoded + let signature = &cawg_identity["signature"]; + assert!(signature.is_object(), "signature should be an object"); + + // For ICA signatures (sig_type: cawg.identity_claims_aggregation), + // verify VC information is extracted + let sig_type = cawg_identity["signer_payload"]["sig_type"].as_str().unwrap(); + if sig_type == "cawg.identity_claims_aggregation" { + assert!( + signature.get("algorithm").is_some(), + "ICA signature should have algorithm" + ); + assert!( + signature.get("issuer").is_some(), + "ICA signature should have issuer (DID)" + ); + + // ICA signatures should have VC-specific fields + // Note: Some of these may be optional depending on the VC + let has_vc_info = signature.get("validFrom").is_some() + || signature.get("validUntil").is_some() + || signature.get("verifiedIdentities").is_some() + || signature.get("credentialType").is_some(); + + assert!( + has_vc_info, + "ICA signature should have at least some VC information (validFrom, validUntil, verifiedIdentities, or credentialType)" + ); + } + } + + Ok(()) + } + + /// Test that claim_generator_info (including icon) is exported to claim.v2 when present. + /// Uses an image produced by crTool with a claim generator icon. + #[test] + #[cfg(feature = "file_io")] + fn test_claim_generator_info_with_icon_exported() -> Result<()> { + use std::path::Path; + + let path = Path::new("/Users/lrosenth/Development/crTool/target/test_output/testset/p-actions-created-with-icon.jpg"); + if !path.exists() { + eprintln!("Skipping test_claim_generator_info_with_icon_exported: fixture not found at {:?}", path); + return Ok(()); + } + + let reader = CrJsonReader::from_file(path)?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be an array"); + let manifest = manifests + .first() + .expect("should have at least one manifest"); + + let claim_v2 = manifest + .get("claim.v2") + .expect("claim.v2 should be present"); + + let claim_generator_info = claim_v2 + .get("claim_generator_info") + .expect("claim.v2 should include claim_generator_info when manifest has an icon"); + + let info_arr = claim_generator_info + .as_array() + .expect("claim_generator_info should be an array"); + assert!( + !info_arr.is_empty(), + "claim_generator_info should have at least one entry" + ); + + // At least one entry should have an icon. Icon may be serialized as: + // - ResourceRef { format, identifier } (when resolved from HashedUri in reader), or + // - HashedUri { url, alg?, hash } with hash as base64 string (not byte array) + let has_icon = info_arr.iter().any(|entry| { + let icon = match entry.get("icon") { + Some(icon) => icon, + None => return false, + }; + // If icon has "hash" (HashedUri), it must be a base64 string after fix_hash_encoding + if let Some(hash) = icon.get("hash") { + return hash.is_string(); + } + // ResourceRef has format and identifier + icon.get("format").is_some() && icon.get("identifier").is_some() + }); + + assert!( + has_icon, + "claim_generator_info should include an entry with icon (ResourceRef or HashedUri with base64 hash)" + ); + + Ok(()) + } +} diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index 64bd61db7..7a00e26c5 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -222,6 +222,9 @@ pub mod identity; /// The `jumbf_io` module contains the definitions for the JUMBF data in assets. pub mod jumbf_io; +/// The cr_json_reader module provides a Reader-like API that exports C2PA manifests in crJSON format. +pub mod cr_json_reader; + /// The settings module provides a way to configure the C2PA SDK. pub mod settings; @@ -261,6 +264,7 @@ pub use ingredient::{DefaultOptions, IngredientOptions}; pub use manifest::{Manifest, SignatureInfo}; pub use manifest_assertion::{ManifestAssertion, ManifestAssertionKind}; pub use reader::Reader; +pub use cr_json_reader::CrJsonReader; #[doc(inline)] pub use resource_store::{ResourceRef, ResourceStore}; #[doc(inline)] diff --git a/sdk/tests/test_cr_json_asset_hash.rs b/sdk/tests/test_cr_json_asset_hash.rs new file mode 100644 index 000000000..8c80292cf --- /dev/null +++ b/sdk/tests/test_cr_json_asset_hash.rs @@ -0,0 +1,207 @@ +// 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. + +//! Integration tests for CrJsonReader asset hash functionality + +use c2pa::{CrJsonReader, Result}; +use std::io::Cursor; + +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("fixtures/CA.jpg"); + +#[test] +fn test_asset_hash_in_json_output() -> Result<()> { + // Create reader and compute hash + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Initially no asset_info in output + let json_without_hash = reader.json(); + assert!(!json_without_hash.contains("asset_info")); + + // Compute hash + let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); + let computed_hash = reader.compute_asset_hash(&mut stream)?; + + // Now asset_info should be present + let json_with_hash = reader.json(); + assert!(json_with_hash.contains("asset_info")); + assert!(json_with_hash.contains(&computed_hash)); + assert!(json_with_hash.contains("\"alg\": \"sha256\"")); + + Ok(()) +} + +#[test] +fn test_multiple_hash_computations() -> Result<()> { + // Test that computing hash multiple times gives consistent results + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // First computation + let mut stream1 = Cursor::new(IMAGE_WITH_MANIFEST); + let hash1 = reader.compute_asset_hash(&mut stream1)?; + + // Second computation (should overwrite) + let mut stream2 = Cursor::new(IMAGE_WITH_MANIFEST); + let hash2 = reader.compute_asset_hash(&mut stream2)?; + + // Hashes should be identical + assert_eq!(hash1, hash2); + + // JSON should contain the hash + let json = reader.json(); + assert!(json.contains(&hash2)); + + Ok(()) +} + +#[test] +fn test_set_hash_directly() -> Result<()> { + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Set a custom hash + let custom_hash = "AAABBBCCCDDDEEEFFF111222333444555666777888999==="; + reader.set_asset_hash("sha512", custom_hash); + + // Verify it appears in JSON with correct algorithm + let json = reader.json(); + assert!(json.contains(custom_hash)); + assert!(json.contains("\"alg\": \"sha512\"")); + + Ok(()) +} + +#[test] +fn test_accessor_methods() -> Result<()> { + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Initially None + assert!(reader.asset_hash().is_none()); + + // Set hash + reader.set_asset_hash("sha256", "test_hash_value"); + + // Should be accessible + let (alg, hash) = reader.asset_hash().expect("Hash should be set"); + assert_eq!(alg, "sha256"); + assert_eq!(hash, "test_hash_value"); + + Ok(()) +} + +#[test] +#[cfg(feature = "file_io")] +fn test_compute_from_file() -> Result<()> { + let mut reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + + // Compute hash from file + let hash = reader.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; + + // Verify it's not empty + assert!(!hash.is_empty()); + + // Verify it's accessible + assert!(reader.asset_hash().is_some()); + + // Verify JSON includes it + let json = reader.json(); + assert!(json.contains("asset_info")); + assert!(json.contains(&hash)); + + Ok(()) +} + +#[test] +#[cfg(feature = "file_io")] +fn test_different_files_different_hashes() -> Result<()> { + // Read two different files + let mut reader1 = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + let hash1 = reader1.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; + + let mut reader2 = CrJsonReader::from_file("tests/fixtures/C.jpg")?; + let hash2 = reader2.compute_asset_hash_from_file("tests/fixtures/C.jpg")?; + + // Different files should have different hashes + assert_ne!(hash1, hash2); + + Ok(()) +} + +#[test] +fn test_hash_persistence_across_json_calls() -> Result<()> { + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Compute hash once + let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); + let hash = reader.compute_asset_hash(&mut stream)?; + + // Get JSON multiple times + let json1 = reader.json(); + let json2 = reader.json(); + + // Both should contain the hash + assert!(json1.contains(&hash)); + assert!(json2.contains(&hash)); + assert_eq!(json1, json2); + + Ok(()) +} + +#[test] +fn test_hash_format_is_base64() -> Result<()> { + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); + let hash = reader.compute_asset_hash(&mut stream)?; + + // Base64 should only contain valid characters + let is_valid_base64 = hash + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='); + + assert!(is_valid_base64, "Hash should be valid base64: {}", hash); + + Ok(()) +} + +#[test] +fn test_complete_cr_json_format_with_asset_info() -> Result<()> { + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Compute hash + let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); + reader.compute_asset_hash(&mut stream)?; + + // Get JSON value + let json_value = reader.to_json_value()?; + + // Verify complete structure + assert!(json_value.get("@context").is_some()); + assert!(json_value.get("asset_info").is_some()); + assert!(json_value.get("manifests").is_some()); + assert!(json_value.get("content").is_some()); + + // Verify asset_info structure + let asset_info = json_value["asset_info"] + .as_object() + .expect("asset_info should be an object"); + assert!(asset_info.contains_key("alg")); + assert!(asset_info.contains_key("hash")); + assert_eq!(asset_info["alg"], "sha256"); + + // Verify hash is a non-empty string + let hash = asset_info["hash"] + .as_str() + .expect("hash should be a string"); + assert!(!hash.is_empty()); + + Ok(()) +} diff --git a/sdk/tests/test_cr_json_created_gathered.rs b/sdk/tests/test_cr_json_created_gathered.rs new file mode 100644 index 000000000..160fa2b17 --- /dev/null +++ b/sdk/tests/test_cr_json_created_gathered.rs @@ -0,0 +1,286 @@ +// 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. + +//! Tests for crJSON format created_assertions vs gathered_assertions + +use std::io::Cursor; + +use c2pa::{Builder, Context, CrJsonReader, Result, Settings}; + +const TEST_IMAGE: &[u8] = include_bytes!("fixtures/CA.jpg"); +const TEST_SETTINGS: &str = include_str!("../tests/fixtures/test_settings.toml"); + +#[test] +fn test_created_and_gathered_assertions_separated() -> Result<()> { + use serde_json::json; + + let settings = Settings::new().with_toml(TEST_SETTINGS)?; + let context = Context::new().with_settings(settings)?.into_shared(); + + let format = "image/jpeg"; + let mut source = Cursor::new(TEST_IMAGE); + + // Create a manifest with both created and gathered assertions + let definition = json!( + { + "assertions": [ + { + "label": "org.test.gathered", + "data": { + "value": "gathered assertion" + } + }, + { + "label": "org.test.created", + "kind": "Json", + "data": { + "value": "created assertion" + }, + "created": true + }] + } + ) + .to_string(); + + let mut builder = Builder::from_shared_context(&context).with_definition(&definition)?; + + // Add another regular assertion (should default to gathered) + builder.add_assertion("org.test.regular", &json!({"value": "regular assertion"}))?; + + let mut dest = Cursor::new(Vec::new()); + builder.sign(context.signer()?, format, &mut source, &mut dest)?; + + // Now read it with CrJsonReader + dest.set_position(0); + let reader = CrJsonReader::from_stream(format, dest)?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + // We need to find the manifest with our test assertions (the newly created one) + // This is the most recent manifest with claim version 2 (which has created_assertions/gathered_assertions) + let active_manifest = manifests + .iter() + .filter(|m| { + // Filter for claim v2 manifests (which have non-empty created_assertions or gathered_assertions) + if let Some(claim_v2) = m.get("claim.v2") { + if let Some(created) = claim_v2.get("created_assertions") { + if let Some(arr) = created.as_array() { + return !arr.is_empty(); + } + } + if let Some(gathered) = claim_v2.get("gathered_assertions") { + if let Some(arr) = gathered.as_array() { + return !arr.is_empty(); + } + } + } + false + }) + .last() + .expect("should have at least one claim v2 manifest"); + + // Print the label for debugging + println!("Manifest label: {:?}", active_manifest.get("label")); + println!("Claim version: {:?}", active_manifest.get("claim.v2")); + println!("Total manifests: {}", manifests.len()); + + // Check claim.v2 for created_assertions and gathered_assertions + let claim_v2 = active_manifest["claim.v2"] + .as_object() + .expect("claim.v2 should exist"); + + let created_assertions = claim_v2["created_assertions"] + .as_array() + .expect("created_assertions should be array"); + + let gathered_assertions = claim_v2["gathered_assertions"] + .as_array() + .expect("gathered_assertions should be array"); + + // Print for debugging + println!("Created assertions count: {}", created_assertions.len()); + println!("Gathered assertions count: {}", gathered_assertions.len()); + + for (i, assertion) in created_assertions.iter().enumerate() { + println!("Created[{}]: {}", i, assertion.get("url").unwrap()); + } + + for (i, assertion) in gathered_assertions.iter().enumerate() { + println!("Gathered[{}]: {}", i, assertion.get("url").unwrap()); + } + + // Verify that gathered_assertions is not empty + assert!( + !gathered_assertions.is_empty(), + "gathered_assertions should not be empty, but got {} gathered vs {} created", + gathered_assertions.len(), + created_assertions.len() + ); + + // Find the created assertion - should be in created_assertions + let has_created_ref = created_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("org.test.created")) + .unwrap_or(false) + } else { + false + } + }); + + assert!( + has_created_ref, + "created_assertions should reference org.test.created" + ); + + // Find the gathered assertion - should be in gathered_assertions + let has_gathered_ref = gathered_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("org.test.gathered")) + .unwrap_or(false) + } else { + false + } + }); + + assert!( + has_gathered_ref, + "gathered_assertions should reference org.test.gathered" + ); + + // Verify the regular assertion is in gathered_assertions (default behavior) + let has_regular_ref = gathered_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("org.test.regular")) + .unwrap_or(false) + } else { + false + } + }); + + assert!( + has_regular_ref, + "gathered_assertions should reference org.test.regular (default)" + ); + + // Verify all assertion references have proper hash format + for assertion_ref in created_assertions.iter().chain(gathered_assertions.iter()) { + assert!( + assertion_ref.get("url").is_some(), + "All assertion refs should have url" + ); + assert!( + assertion_ref.get("hash").is_some(), + "All assertion refs should have hash" + ); + + let hash = assertion_ref.get("hash").unwrap(); + assert!( + hash.is_string(), + "Hash should be a string (base64), not an array" + ); + } + + Ok(()) +} + +#[test] +fn test_hash_assertions_in_created() -> Result<()> { + use serde_json::json; + + let settings = Settings::new().with_toml(TEST_SETTINGS)?; + let context = Context::new().with_settings(settings)?.into_shared(); + + let format = "image/jpeg"; + let mut source = Cursor::new(TEST_IMAGE); + + // Create a simple manifest + let definition = json!( + { + "assertions": [ + { + "label": "org.test.simple", + "data": { + "value": "test" + } + }] + } + ) + .to_string(); + + let mut builder = Builder::from_shared_context(&context).with_definition(&definition)?; + + let mut dest = Cursor::new(Vec::new()); + builder.sign(context.signer()?, format, &mut source, &mut dest)?; + + // Now read it with CrJsonReader + dest.set_position(0); + let reader = CrJsonReader::from_stream(format, dest)?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + // Find the manifest with our test assertions (the newly created one with claim v2) + let active_manifest = manifests + .iter() + .filter(|m| { + // Filter for claim v2 manifests with non-empty created_assertions + if let Some(claim_v2) = m.get("claim.v2") { + if let Some(created) = claim_v2.get("created_assertions") { + if let Some(arr) = created.as_array() { + return !arr.is_empty(); + } + } + } + false + }) + .last() + .expect("should have at least one claim v2 manifest"); + + // Check claim.v2 + let claim_v2 = active_manifest["claim.v2"] + .as_object() + .expect("claim.v2 should exist"); + + let created_assertions = claim_v2["created_assertions"] + .as_array() + .expect("created_assertions should be array"); + + // Hash assertions (c2pa.hash.data, etc.) should be in created_assertions + let has_hash_assertion = created_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("c2pa.hash")) + .unwrap_or(false) + } else { + false + } + }); + + assert!( + has_hash_assertion, + "created_assertions should include hash assertions (c2pa.hash.*)" + ); + + Ok(()) +} + diff --git a/sdk/tests/test_cr_json_hash_assertions.rs b/sdk/tests/test_cr_json_hash_assertions.rs new file mode 100644 index 000000000..190e9d3d9 --- /dev/null +++ b/sdk/tests/test_cr_json_hash_assertions.rs @@ -0,0 +1,287 @@ +// 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. + +use std::io::Cursor; + +use c2pa::{CrJsonReader, Result}; + +// Test image with manifest +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("fixtures/C.jpg"); + +#[test] +fn test_hash_data_assertion_included() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check that c2pa.hash.data is present + assert!( + assertions.contains_key("c2pa.hash.data"), + "Should have c2pa.hash.data assertion" + ); + + Ok(()) +} + +#[test] +fn test_hash_data_structure() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + let hash_data = assertions + .get("c2pa.hash.data") + .expect("Should have c2pa.hash.data"); + + let hash_data_obj = hash_data.as_object().expect("hash.data should be object"); + + // Check required fields + assert!(hash_data_obj.contains_key("hash"), "Should have hash field"); + assert!(hash_data_obj.contains_key("alg"), "Should have alg field"); + + // Verify hash is base64 string, not byte array + let hash = hash_data_obj["hash"] + .as_str() + .expect("hash should be a string (base64 encoded)"); + assert!(!hash.is_empty(), "hash should not be empty"); + + // Verify the hash value doesn't look like a byte array representation + // (if it were an array, it would serialize as an array in JSON) + assert!( + !hash_data_obj["hash"].is_array(), + "hash should be a string, not an array" + ); + + Ok(()) +} + +#[test] +fn test_hash_data_algorithm() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + let hash_data = assertions + .get("c2pa.hash.data") + .expect("Should have c2pa.hash.data"); + + let alg = hash_data["alg"] + .as_str() + .expect("alg should be a string"); + + // Algorithm should be one of the standard hash algorithms + assert!( + alg == "sha256" || alg == "sha384" || alg == "sha512", + "Algorithm should be a standard hash algorithm, got: {}", + alg + ); + + Ok(()) +} + +#[test] +fn test_multiple_hash_assertions() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Count hash assertions (c2pa.hash.data, c2pa.hash.bmff, c2pa.hash.boxes) + let hash_assertion_count = assertions + .keys() + .filter(|k| k.starts_with("c2pa.hash.")) + .count(); + + // Should have at least one hash assertion + assert!( + hash_assertion_count >= 1, + "Should have at least one hash assertion, found {}", + hash_assertion_count + ); + + Ok(()) +} + +#[test] +fn test_hash_data_not_filtered() -> Result<()> { + // This test ensures that c2pa.hash.data is included in crJSON format + // even though it's filtered out in the standard Manifest format + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Verify that hash.data is included + assert!( + assertions.contains_key("c2pa.hash.data"), + "c2pa.hash.data should be included in crJSON format" + ); + + // Compare with standard Reader to confirm it's normally filtered + let standard_reader = c2pa::Reader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let standard_json = serde_json::to_value(standard_reader)?; + + // In standard format, manifests is a map, not an array + let manifests_map = standard_json["manifests"] + .as_object() + .expect("standard manifests should be object"); + + let first_standard_manifest = manifests_map.values().next().expect("should have a manifest"); + let standard_assertions = first_standard_manifest["assertions"] + .as_array() + .expect("standard assertions should be array"); + + // Verify it's NOT in the standard assertions array + let has_hash_data_in_standard = standard_assertions + .iter() + .any(|a| a.get("label").and_then(|l| l.as_str()) == Some("c2pa.hash.data")); + + assert!( + !has_hash_data_in_standard, + "c2pa.hash.data should be filtered out in standard format" + ); + + Ok(()) +} + +#[test] +fn test_hash_assertion_versioning() -> Result<()> { + // This test verifies that hash assertions with versions (e.g., c2pa.hash.bmff.v2, v3) + // are correctly labeled with their version suffix + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check all hash assertion keys + for (key, _value) in assertions.iter() { + if key.starts_with("c2pa.hash.") { + // If this is a versioned assertion, it should have .v{N} suffix + // The key should be one of: + // - c2pa.hash.data (v1, no suffix) + // - c2pa.hash.data.v2 + // - c2pa.hash.data.v3 + // - c2pa.hash.bmff (v1, no suffix) + // - c2pa.hash.bmff.v2 + // - c2pa.hash.bmff.v3 + // - c2pa.hash.boxes (v1, no suffix) + // - etc. + + // Remove any instance suffix (_1, _2, etc.) for checking + let base_key = key.split('_').next().unwrap_or(key); + + // Verify the label follows correct versioning pattern + let is_valid = base_key == "c2pa.hash.data" + || base_key == "c2pa.hash.bmff" + || base_key == "c2pa.hash.boxes" + || base_key == "c2pa.hash.collection.data" + || base_key.starts_with("c2pa.hash.data.v") + || base_key.starts_with("c2pa.hash.bmff.v") + || base_key.starts_with("c2pa.hash.boxes.v") + || base_key.starts_with("c2pa.hash.collection.data.v"); + + assert!( + is_valid, + "Hash assertion key '{}' should follow versioning pattern", + key + ); + } + } + + Ok(()) +} + +#[test] +fn test_hash_assertion_pad_encoding() -> Result<()> { + // This test verifies that the 'pad' field in hash assertions is base64 encoded, + // not an array of integers + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + let hash_data = assertions + .get("c2pa.hash.data") + .expect("Should have c2pa.hash.data"); + + let hash_data_obj = hash_data.as_object().expect("hash.data should be object"); + + // Check if pad field exists + if let Some(pad_value) = hash_data_obj.get("pad") { + // Verify pad is a base64 string, not a byte array + assert!( + pad_value.is_string(), + "pad should be a string (base64), not an array" + ); + + let pad = pad_value.as_str().expect("pad should be a string"); + + // Verify it's not empty (unless the pad is actually empty) + // An empty pad would encode to an empty string + + // Verify the pad value doesn't look like an array representation + assert!( + !pad_value.is_array(), + "pad should be a string, not an array" + ); + } + + Ok(()) +} + + diff --git a/sdk/tests/test_cr_json_hash_encoding.rs b/sdk/tests/test_cr_json_hash_encoding.rs new file mode 100644 index 000000000..23ac20160 --- /dev/null +++ b/sdk/tests/test_cr_json_hash_encoding.rs @@ -0,0 +1,313 @@ +// 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. + +//! Tests for hash encoding in crJSON format +//! +//! Verifies that all hash fields are properly encoded as base64 strings +//! rather than byte arrays. + +use c2pa::{CrJsonReader, Result}; +use serde_json::Value; +use std::io::Cursor; + +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("fixtures/CA.jpg"); + +/// Recursively check that all "hash" fields in the JSON are strings (base64), +/// not arrays of integers. +fn verify_no_byte_array_hashes(value: &Value, path: &str) -> Vec { + let mut errors = Vec::new(); + + match value { + Value::Object(map) => { + // Check if this object has a "hash" field + if let Some(hash_value) = map.get("hash") { + let current_path = format!("{}.hash", path); + + if hash_value.is_array() { + // This is bad - hash should not be an array + errors.push(format!( + "Found byte array hash at {}: {:?}", + current_path, hash_value + )); + } else if let Some(hash_str) = hash_value.as_str() { + // Good - it's a string. Verify it looks like base64 + if !is_valid_base64(hash_str) { + errors.push(format!( + "Hash at {} is not valid base64: {}", + current_path, hash_str + )); + } + } + } + + // Recursively check all values + for (key, val) in map { + let new_path = if path.is_empty() { + key.clone() + } else { + format!("{}.{}", path, key) + }; + errors.extend(verify_no_byte_array_hashes(val, &new_path)); + } + } + Value::Array(arr) => { + // Recursively check all array elements + for (i, val) in arr.iter().enumerate() { + let new_path = format!("{}[{}]", path, i); + errors.extend(verify_no_byte_array_hashes(val, &new_path)); + } + } + _ => {} + } + + errors +} + +/// Check if a string is valid base64 +fn is_valid_base64(s: &str) -> bool { + // Base64 characters are A-Z, a-z, 0-9, +, /, and = for padding + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + && !s.is_empty() +} + +#[test] +fn test_no_byte_array_hashes() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Verify no byte array hashes exist anywhere in the output + let errors = verify_no_byte_array_hashes(&json_value, ""); + + if !errors.is_empty() { + panic!( + "Found {} byte array hash(es) in output:\n{}", + errors.len(), + errors.join("\n") + ); + } + + Ok(()) +} + +#[test] +fn test_action_ingredient_hash_is_base64() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Navigate to the actions assertion + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests + .first() + .expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check c2pa.actions.v2 if it exists + if let Some(actions_assertion) = assertions.get("c2pa.actions.v2") { + let actions = actions_assertion["actions"] + .as_array() + .expect("actions should be array"); + + for (i, action) in actions.iter().enumerate() { + // Check if this action has ingredient parameter + if let Some(params) = action.get("parameters") { + if let Some(ingredient) = params.get("ingredient") { + if let Some(hash) = ingredient.get("hash") { + assert!( + hash.is_string(), + "Action {} ingredient hash should be string, not array", + i + ); + + let hash_str = hash.as_str().unwrap(); + assert!( + is_valid_base64(hash_str), + "Action {} ingredient hash should be valid base64: {}", + i, + hash_str + ); + + // Verify it's not empty + assert!( + !hash_str.is_empty(), + "Action {} ingredient hash should not be empty", + i + ); + } + } + } + } + } + + Ok(()) +} + +#[test] +fn test_assertion_reference_hashes_are_base64() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Check created_assertions hashes in claim.v2 + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests + .first() + .expect("should have at least one manifest"); + let claim_v2 = first_manifest["claim.v2"] + .as_object() + .expect("claim.v2 should be object"); + + if let Some(created_assertions) = claim_v2.get("created_assertions") { + let assertions_array = created_assertions + .as_array() + .expect("created_assertions should be array"); + + for (i, assertion_ref) in assertions_array.iter().enumerate() { + if let Some(hash) = assertion_ref.get("hash") { + assert!( + hash.is_string(), + "Assertion reference {} hash should be string, not array", + i + ); + + let hash_str = hash.as_str().unwrap(); + assert!( + is_valid_base64(hash_str), + "Assertion reference {} hash should be valid base64: {}", + i, + hash_str + ); + } + } + } + + Ok(()) +} + +#[test] +fn test_ingredient_assertion_hashes_are_base64() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Check ingredient assertions + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests + .first() + .expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check for ingredient assertions (can have various labels) + for (label, assertion_value) in assertions { + if label.contains("ingredient") { + // Check c2pa_manifest hash + if let Some(c2pa_manifest) = assertion_value.get("c2pa_manifest") { + if let Some(hash) = c2pa_manifest.get("hash") { + assert!( + hash.is_string(), + "Ingredient {} c2pa_manifest hash should be string", + label + ); + } + } + + // Check thumbnail hash + if let Some(thumbnail) = assertion_value.get("thumbnail") { + if let Some(hash) = thumbnail.get("hash") { + assert!( + hash.is_string(), + "Ingredient {} thumbnail hash should be string", + label + ); + } + } + + // Check activeManifest hash + if let Some(active_manifest) = assertion_value.get("activeManifest") { + if let Some(hash) = active_manifest.get("hash") { + assert!( + hash.is_string(), + "Ingredient {} activeManifest hash should be string", + label + ); + } + } + + // Check claimSignature hash + if let Some(claim_signature) = assertion_value.get("claimSignature") { + if let Some(hash) = claim_signature.get("hash") { + assert!( + hash.is_string(), + "Ingredient {} claimSignature hash should be string", + label + ); + } + } + } + } + + Ok(()) +} + +#[test] +fn test_all_hashes_match_schema_format() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Collect all hash values + let mut hash_count = 0; + + fn count_hashes(value: &Value, counter: &mut usize) { + match value { + Value::Object(map) => { + if let Some(hash_value) = map.get("hash") { + if hash_value.is_string() { + *counter += 1; + } + } + for val in map.values() { + count_hashes(val, counter); + } + } + Value::Array(arr) => { + for val in arr { + count_hashes(val, counter); + } + } + _ => {} + } + } + + count_hashes(&json_value, &mut hash_count); + + // Should have multiple hashes in a typical manifest + assert!( + hash_count > 0, + "Should have at least one hash field in the output" + ); + + println!("Verified {} hash fields are all base64 strings", hash_count); + + Ok(()) +} diff --git a/sdk/tests/test_cr_json_ingredients.rs b/sdk/tests/test_cr_json_ingredients.rs new file mode 100644 index 000000000..7df3af49d --- /dev/null +++ b/sdk/tests/test_cr_json_ingredients.rs @@ -0,0 +1,315 @@ +// 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. + +//! Tests for ingredient assertions in crJSON format + +use c2pa::{CrJsonReader, Result}; +use std::io::Cursor; + +const IMAGE_WITH_INGREDIENT: &[u8] = include_bytes!("fixtures/CA.jpg"); + +#[test] +fn test_ingredient_assertions_included() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + // Check first manifest for ingredient assertion + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Should have ingredient assertion + assert!( + assertions.contains_key("c2pa.ingredient"), + "assertions should contain c2pa.ingredient" + ); + + // Verify ingredient structure + let ingredient = &assertions["c2pa.ingredient"]; + assert!(ingredient.is_object(), "ingredient should be an object"); + + // Check for expected ingredient fields + let ingredient_obj = ingredient.as_object().unwrap(); + assert!( + ingredient_obj.contains_key("title"), + "ingredient should have title" + ); + assert!( + ingredient_obj.contains_key("format"), + "ingredient should have format" + ); + + // Verify all hashes in ingredient are base64 strings, not byte arrays + if let Some(c2pa_manifest) = ingredient_obj.get("c2pa_manifest") { + if let Some(hash) = c2pa_manifest.get("hash") { + assert!( + hash.is_string(), + "ingredient c2pa_manifest hash should be string" + ); + } + } + + if let Some(thumbnail) = ingredient_obj.get("thumbnail") { + if let Some(hash) = thumbnail.get("hash") { + assert!(hash.is_string(), "ingredient thumbnail hash should be string"); + } + } + + Ok(()) +} + +#[test] +fn test_ingredient_count_matches() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Count ingredient assertions + let ingredient_count = assertions + .keys() + .filter(|k| k.starts_with("c2pa.ingredient")) + .count(); + + // CA.jpg has 1 ingredient (A.jpg as parent) + assert_eq!( + ingredient_count, 1, + "Should have exactly 1 ingredient assertion" + ); + + Ok(()) +} + +#[test] +fn test_ingredient_referenced_in_claim() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + println!("Total manifests: {}", manifests.len()); + for (i, m) in manifests.iter().enumerate() { + println!("Manifest {}: label={:?}", i, m.get("label")); + if let Some(claim_v2) = m.get("claim.v2") { + if let Some(created) = claim_v2.get("created_assertions") { + println!(" Created assertions count: {}", created.as_array().map(|a| a.len()).unwrap_or(0)); + } + if let Some(gathered) = claim_v2.get("gathered_assertions") { + println!(" Gathered assertions count: {}", gathered.as_array().map(|a| a.len()).unwrap_or(0)); + } + } + } + + // Find the manifest with the ingredient (active manifest with claim v2) + let active_manifest = manifests + .iter() + .filter(|m| { + // Look for a manifest with non-empty created or gathered assertions + if let Some(claim_v2) = m.get("claim.v2") { + let has_created = claim_v2.get("created_assertions") + .and_then(|a| a.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false); + let has_gathered = claim_v2.get("gathered_assertions") + .and_then(|a| a.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false); + has_created || has_gathered + } else { + false + } + }) + .next() + .expect("should have at least one manifest with assertions"); + + // Check if ingredient is referenced in created_assertions + let claim_v2 = active_manifest["claim.v2"] + .as_object() + .expect("claim.v2 should exist"); + + let created_assertions = claim_v2["created_assertions"] + .as_array() + .expect("created_assertions should be array"); + + let gathered_assertions = claim_v2["gathered_assertions"] + .as_array() + .expect("gathered_assertions should be array"); + + // Debug: Print what we found + println!("Created assertions count: {}", created_assertions.len()); + println!("Gathered assertions count: {}", gathered_assertions.len()); + for (i, a) in created_assertions.iter().enumerate() { + println!("Created[{}]: {}", i, a.get("url").unwrap_or(&serde_json::Value::Null)); + } + for (i, a) in gathered_assertions.iter().enumerate() { + println!("Gathered[{}]: {}", i, a.get("url").unwrap_or(&serde_json::Value::Null)); + } + + // Find ingredient reference in EITHER created or gathered + let has_ingredient_in_created = created_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("c2pa.ingredient")) + .unwrap_or(false) + } else { + false + } + }); + + let has_ingredient_in_gathered = gathered_assertions.iter().any(|assertion_ref| { + if let Some(url) = assertion_ref.get("url") { + url.as_str() + .map(|s| s.contains("c2pa.ingredient")) + .unwrap_or(false) + } else { + false + } + }); + + // Ingredient should be in one or the other + assert!( + has_ingredient_in_created || has_ingredient_in_gathered, + "Ingredient should be referenced in either created_assertions or gathered_assertions" + ); + + Ok(()) +} + +#[test] +fn test_ingredient_in_actions_parameter() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Check actions assertion for ingredient reference + if let Some(actions_assertion) = assertions.get("c2pa.actions.v2") { + let actions = actions_assertion["actions"] + .as_array() + .expect("actions should be array"); + + // Find action with ingredient parameter + let has_ingredient_param = actions.iter().any(|action| { + action + .get("parameters") + .and_then(|p| p.get("ingredient")) + .is_some() + }); + + assert!( + has_ingredient_param, + "At least one action should have ingredient parameter" + ); + } + + Ok(()) +} + +#[test] +fn test_multiple_ingredients_have_instances() -> Result<()> { + // Note: CA.jpg only has 1 ingredient, so this test verifies the instance logic + // For files with multiple ingredients, they would be labeled: + // c2pa.ingredient__1, c2pa.ingredient__2, etc. + + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // For single ingredient, should be just "c2pa.ingredient" + assert!( + assertions.contains_key("c2pa.ingredient"), + "Single ingredient should be c2pa.ingredient without instance" + ); + + // Should NOT have instance suffix for single ingredient + assert!( + !assertions.contains_key("c2pa.ingredient__1"), + "Single ingredient should not have instance number" + ); + + Ok(()) +} + +#[test] +fn test_ingredient_label_matches_version() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_INGREDIENT))?; + let json_value = reader.to_json_value()?; + + let manifests = json_value["manifests"] + .as_array() + .expect("manifests should be array"); + + let first_manifest = manifests.first().expect("should have at least one manifest"); + let assertions = first_manifest["assertions"] + .as_object() + .expect("assertions should be object"); + + // Get the ingredient assertion (could be c2pa.ingredient, c2pa.ingredient.v2, or c2pa.ingredient.v3) + let ingredient_key = assertions + .keys() + .find(|k| k.starts_with("c2pa.ingredient")) + .expect("Should have an ingredient assertion"); + + // Get the ingredient object + let ingredient = &assertions[ingredient_key]; + let ingredient_obj = ingredient.as_object().expect("ingredient should be object"); + + // Verify the label field is NOT present (it's redundant since the key is the label) + assert!( + !ingredient_obj.contains_key("label"), + "Ingredient should not have redundant label field" + ); + + // Verify the key follows correct versioning pattern + assert!( + ingredient_key == "c2pa.ingredient" + || ingredient_key.starts_with("c2pa.ingredient.v"), + "Ingredient key should be c2pa.ingredient or c2pa.ingredient.v{{N}}" + ); + + Ok(()) +} + diff --git a/sdk/tests/test_cr_json_schema_compliance.rs b/sdk/tests/test_cr_json_schema_compliance.rs new file mode 100644 index 000000000..e1e969a81 --- /dev/null +++ b/sdk/tests/test_cr_json_schema_compliance.rs @@ -0,0 +1,330 @@ +// 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. + +//! Schema compliance tests for crJSON format + +use c2pa::{CrJsonReader, Result}; +use std::io::Cursor; + +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("fixtures/CA.jpg"); + +#[test] +fn test_validation_status_schema_compliance() -> Result<()> { + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Compute asset hash + let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); + reader.compute_asset_hash(&mut stream)?; + + let json_value = reader.to_json_value()?; + + // Verify extras:validation_status exists + let validation_status = json_value + .get("extras:validation_status") + .expect("extras:validation_status should exist"); + + // Verify required fields + assert!( + validation_status.get("isValid").is_some(), + "isValid field should exist" + ); + assert!( + validation_status.get("isValid").unwrap().is_boolean(), + "isValid should be boolean" + ); + + // Verify error field (should be null or string) + let error = validation_status + .get("error") + .expect("error field should exist"); + assert!( + error.is_null() || error.is_string(), + "error should be null or string" + ); + + // Verify validationErrors is an array + let validation_errors = validation_status + .get("validationErrors") + .expect("validationErrors should exist"); + assert!( + validation_errors.is_array(), + "validationErrors should be an array" + ); + + // Verify each validationError object has required fields + for error_obj in validation_errors.as_array().unwrap() { + assert!(error_obj.is_object(), "Each error should be an object"); + let obj = error_obj.as_object().unwrap(); + + // Required: code + assert!(obj.contains_key("code"), "Error should have code field"); + assert!( + obj.get("code").unwrap().is_string(), + "code should be string" + ); + + // Optional: message + if let Some(message) = obj.get("message") { + assert!(message.is_string(), "message should be string"); + } + + // Required: severity + assert!( + obj.contains_key("severity"), + "Error should have severity field" + ); + let severity = obj.get("severity").unwrap().as_str().unwrap(); + assert!( + severity == "error" || severity == "warning" || severity == "info", + "severity should be error, warning, or info" + ); + } + + // Verify entries array + let entries = validation_status + .get("entries") + .expect("entries should exist"); + assert!(entries.is_array(), "entries should be an array"); + + // Verify each entry object has required fields + for entry_obj in entries.as_array().unwrap() { + assert!(entry_obj.is_object(), "Each entry should be an object"); + let obj = entry_obj.as_object().unwrap(); + + // Required: code + assert!(obj.contains_key("code"), "Entry should have code field"); + assert!( + obj.get("code").unwrap().is_string(), + "code should be string" + ); + + // Optional: url + if let Some(url) = obj.get("url") { + assert!(url.is_string(), "url should be string"); + } + + // Optional: explanation + if let Some(explanation) = obj.get("explanation") { + assert!(explanation.is_string(), "explanation should be string"); + } + + // Required: severity + assert!( + obj.contains_key("severity"), + "Entry should have severity field" + ); + let severity = obj.get("severity").unwrap().as_str().unwrap(); + assert!( + severity == "error" || severity == "warning" || severity == "info", + "severity should be error, warning, or info" + ); + } + + Ok(()) +} + +#[test] +fn test_manifest_status_schema_compliance() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Get manifests array + let manifests = json_value + .get("manifests") + .expect("manifests should exist") + .as_array() + .expect("manifests should be an array"); + + // Check first manifest for status + if let Some(manifest) = manifests.first() { + if let Some(status) = manifest.get("status") { + assert!(status.is_object(), "status should be an object"); + let status_obj = status.as_object().unwrap(); + + // Per-manifest status can have: signature, trust, content, assertion + // All should be strings or objects + + if let Some(signature) = status_obj.get("signature") { + assert!(signature.is_string(), "signature status should be string"); + } + + if let Some(trust) = status_obj.get("trust") { + assert!(trust.is_string(), "trust status should be string"); + } + + if let Some(content) = status_obj.get("content") { + assert!(content.is_string(), "content status should be string"); + } + + if let Some(assertion) = status_obj.get("assertion") { + assert!(assertion.is_object(), "assertion status should be object"); + // Each assertion status value should be a string + for (_key, value) in assertion.as_object().unwrap() { + assert!( + value.is_string(), + "assertion status values should be strings" + ); + } + } + } + } + + Ok(()) +} + +#[test] +fn test_asset_info_schema_compliance() -> Result<()> { + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Compute hash to populate asset_info + let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); + reader.compute_asset_hash(&mut stream)?; + + let json_value = reader.to_json_value()?; + + // Verify asset_info exists + let asset_info = json_value + .get("asset_info") + .expect("asset_info should exist when hash is computed"); + + assert!(asset_info.is_object(), "asset_info should be an object"); + let asset_info_obj = asset_info.as_object().unwrap(); + + // Required: alg + assert!( + asset_info_obj.contains_key("alg"), + "asset_info should have alg field" + ); + assert!( + asset_info_obj.get("alg").unwrap().is_string(), + "alg should be string" + ); + + // Required: hash + assert!( + asset_info_obj.contains_key("hash"), + "asset_info should have hash field" + ); + assert!( + asset_info_obj.get("hash").unwrap().is_string(), + "hash should be string" + ); + + Ok(()) +} + +#[test] +fn test_context_schema_compliance() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Verify @context exists + let context = json_value.get("@context").expect("@context should exist"); + + // @context can be array of URIs or object + assert!( + context.is_object() || context.is_array(), + "@context should be object or array" + ); + + // If object, should have @vocab property + if let Some(context_obj) = context.as_object() { + if let Some(vocab) = context_obj.get("@vocab") { + assert!(vocab.is_string(), "@vocab should be string"); + } + } + + Ok(()) +} + +#[test] +fn test_manifests_array_schema_compliance() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // Verify manifests is an array + let manifests = json_value.get("manifests").expect("manifests should exist"); + + assert!(manifests.is_array(), "manifests should be an array"); + + // Check each manifest + for manifest in manifests.as_array().unwrap() { + assert!(manifest.is_object(), "Each manifest should be an object"); + let manifest_obj = manifest.as_object().unwrap(); + + // Should have label + if let Some(label) = manifest_obj.get("label") { + assert!(label.is_string(), "label should be string"); + } + + // Should have claim.v2 + if let Some(claim) = manifest_obj.get("claim.v2") { + assert!(claim.is_object(), "claim.v2 should be object"); + } + + // Should have assertions as object (not array) + if let Some(assertions) = manifest_obj.get("assertions") { + assert!( + assertions.is_object(), + "assertions should be object, not array" + ); + } + } + + Ok(()) +} + +#[test] +fn test_content_object_exists() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + // content object should exist (can be empty) + let content = json_value.get("content").expect("content should exist"); + assert!(content.is_object(), "content should be an object"); + + Ok(()) +} + +#[test] +fn test_complete_schema_structure() -> Result<()> { + let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + // Compute hash for complete output + let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); + reader.compute_asset_hash(&mut stream)?; + + let json_value = reader.to_json_value()?; + + // Verify all top-level required/expected fields + assert!(json_value.get("@context").is_some(), "@context missing"); + assert!( + json_value.get("asset_info").is_some(), + "asset_info missing (with hash)" + ); + assert!(json_value.get("manifests").is_some(), "manifests missing"); + assert!(json_value.get("content").is_some(), "content missing"); + assert!( + json_value.get("extras:validation_status").is_some(), + "extras:validation_status missing" + ); + + // Verify types + assert!(json_value["@context"].is_object()); + assert!(json_value["asset_info"].is_object()); + assert!(json_value["manifests"].is_array()); + assert!(json_value["content"].is_object()); + assert!(json_value["extras:validation_status"].is_object()); + + Ok(()) +} From 165c21247401b1df550b9bc20c25c3dab286d104 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Sun, 15 Feb 2026 09:26:10 -0500 Subject: [PATCH 02/18] removed all refs to jpegtrust and its specializations, cleaned up tests, etc. --- docs/crjson-format.adoc | 65 +- export_schema/crJSON-schema.json | 924 ++++++++++++++++++ sdk/src/cr_json_reader.rs | 351 +------ sdk/tests/crjson/asset_hash.rs | 63 ++ .../created_gathered.rs} | 35 +- .../hash_assertions.rs} | 2 +- .../hash_encoding.rs} | 2 +- .../ingredients.rs} | 2 +- sdk/tests/crjson/mod.rs | 19 + .../schema_compliance.rs} | 156 +-- sdk/tests/crjson_tests.rs | 17 + sdk/tests/test_cr_json_asset_hash.rs | 207 ---- 12 files changed, 1145 insertions(+), 698 deletions(-) create mode 100644 export_schema/crJSON-schema.json create mode 100644 sdk/tests/crjson/asset_hash.rs rename sdk/tests/{test_cr_json_created_gathered.rs => crjson/created_gathered.rs} (88%) rename sdk/tests/{test_cr_json_hash_assertions.rs => crjson/hash_assertions.rs} (99%) rename sdk/tests/{test_cr_json_hash_encoding.rs => crjson/hash_encoding.rs} (99%) rename sdk/tests/{test_cr_json_ingredients.rs => crjson/ingredients.rs} (99%) create mode 100644 sdk/tests/crjson/mod.rs rename sdk/tests/{test_cr_json_schema_compliance.rs => crjson/schema_compliance.rs} (71%) create mode 100644 sdk/tests/crjson_tests.rs delete mode 100644 sdk/tests/test_cr_json_asset_hash.rs diff --git a/docs/crjson-format.adoc b/docs/crjson-format.adoc index 59273a1ba..c20bb7d1b 100644 --- a/docs/crjson-format.adoc +++ b/docs/crjson-format.adoc @@ -11,7 +11,7 @@ This document describes a JSON serialization for Content Credentials (aka a C2PA == 2. Normative References * C2PA Technical Specification v2.3: https://c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html -* Local CrJSON JSON Schema: `/Users/lrosenth/Development/c2pa-rs/export_schema/indicators-schema.json` +* CrJSON JSON Schema: `export_schema/crJSON-schema.json` (in this repository) == 3. Relationship to C2PA v2.3 @@ -34,15 +34,12 @@ C2PA Asset/Manifest Store -> CrJSON transformation -> Output JSON object |- @context - |- asset_info (optional) |- manifests[] | |- label | |- claim.v2 | |- assertions{...} | |- claim_signature (optional) | |- status (optional) - |- content - |- metadata (optional) |- extras:validation_status (optional) ---- @@ -62,22 +59,10 @@ The following top-level properties are used: |REQUIRED |JSON-LD context. Implementation emits an object with `@vocab` and `extras`. -|`asset_info` -|OPTIONAL -|Asset-level hash descriptor containing `alg` and `hash`. - |`manifests` |REQUIRED |Array of manifest objects. -|`content` -|REQUIRED -|Content metadata object. Current implementation emits `{}`. - -|`metadata` -|OPTIONAL -|Extended metadata object. Currently not populated by implementation. - |`extras:validation_status` |OPTIONAL |Overall validation report for the active manifest. @@ -90,24 +75,12 @@ Implementation output: [source,json] ---- "@context": { - "@vocab": "https://jpeg.org/jpegtrust", - "extras": "https://jpeg.org/jpegtrust/extras" + "@vocab": "https://https://contentcredentials.org/crjson", + "extras": "https://https://contentcredentials.org/crjson/extras" } ---- -=== 5.3 `asset_info` - -`asset_info` SHALL contain: - -* `alg`: hash algorithm identifier (for computed hash, `sha256`) -* `hash`: Base64-encoded digest bytes - -The implementation provides two paths: - -* `compute_asset_hash(...)` / `compute_asset_hash_from_file(...)`: computes SHA-256 and stores result. -* `set_asset_hash(algorithm, hash)`: caller supplies precomputed values. - -=== 5.4 `manifests` +=== 5.3 `manifests` `manifests` SHALL be an array. Ordering rules: @@ -122,7 +95,7 @@ Each manifest object includes: * `claim_signature` (optional) * `status` (optional) -=== 5.5 `claim.v2` +=== 5.4 `claim.v2` `claim.v2` is a normalized object built from C2PA manifest/claim data. Typical fields: @@ -138,11 +111,11 @@ Each manifest object includes: All `hash` values in assertion references SHALL be Base64 strings. -=== 5.6 `assertions` +=== 5.5 `assertions` `assertions` SHALL be an object keyed by assertion label. -==== 5.6.1 Included assertion sources +==== 5.5.1 Included assertion sources CrJSON aggregates assertions from multiple C2PA sources: @@ -151,7 +124,7 @@ CrJSON aggregates assertions from multiple C2PA sources: * Ingredient assertions from `manifest.ingredients()` * Gathered assertions not present in regular list (including binary/UUID payload cases) -==== 5.6.2 Labeling and instance rules +==== 5.5.2 Labeling and instance rules * Base label is the assertion label string. * For repeated hash/gathered assertion instances, suffix `_N` is used where `N = instance + 1`. @@ -164,7 +137,7 @@ Examples: * `c2pa.ingredient` * `c2pa.ingredient.v3__2` -==== 5.6.3 Binary normalization and hash encoding +==== 5.5.3 Binary normalization and hash encoding CrJSON normalizes byte-array encodings into Base64 string encodings. @@ -178,7 +151,7 @@ If fields are serialized as integer arrays, they are converted to Base64 strings This rule applies recursively to nested objects/arrays. -==== 5.6.4 Gathered binary assertion representation +==== 5.5.4 Gathered binary assertion representation For gathered binary/UUID assertions, CrJSON emits a reference form: @@ -191,7 +164,7 @@ For gathered binary/UUID assertions, CrJSON emits a reference form: } ---- -=== 5.7 `claim_signature` +=== 5.6 `claim_signature` When signature information is available, `claim_signature` includes: @@ -204,7 +177,7 @@ When signature information is available, `claim_signature` includes: The implementation parses the first certificate in the chain and serializes times as RFC 3339 strings. -=== 5.8 `status` (per manifest) +=== 5.7 `status` (per manifest) Per-manifest `status` is derived from active-manifest validation results and may include: @@ -213,7 +186,7 @@ Per-manifest `status` is derived from active-manifest validation results and may * `content`: first code prefixed by `assertion.dataHash` * `assertion`: map of assertion label -> validation code (when found) -=== 5.9 `extras:validation_status` (global) +=== 5.8 `extras:validation_status` (global) Global validation object includes: @@ -231,8 +204,7 @@ Global validation object includes: == 6. Constraints and Current Implementation Limits -* `content` is currently emitted as an empty object. -* `metadata` extraction is currently not implemented by `JpegTrustReader` and is omitted. +* CrJSON does not include `asset_info`, `content`, or `metadata`; the root object contains only `@context`, `manifests`, and optionally `extras:validation_status`. * CrJSON is export-oriented; it is not the canonical source of cryptographic truth. Canonical validation remains bound to C2PA/JUMBF/COSE data structures per C2PA v2.3. == 7. Minimal Example @@ -241,12 +213,8 @@ Global validation object includes: ---- { "@context": { - "@vocab": "https://jpeg.org/jpegtrust", - "extras": "https://jpeg.org/jpegtrust/extras" - }, - "asset_info": { - "alg": "sha256", - "hash": "" + "@vocab": "https://https://contentcredentials.org/crjson", + "extras": "https://https://contentcredentials.org/crjson/extras" }, "manifests": [ { @@ -268,7 +236,6 @@ Global validation object includes: } } ], - "content": {}, "extras:validation_status": { "isValid": true, "error": null, diff --git a/export_schema/crJSON-schema.json b/export_schema/crJSON-schema.json new file mode 100644 index 000000000..8300605d2 --- /dev/null +++ b/export_schema/crJSON-schema.json @@ -0,0 +1,924 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://contentcredentials.org/crjson/crJSON-schema.json", + "title": "Content Credential JSON (CrJSON) Document Schema", + "description": "JSON Schema for Content Credential JSON (CrJSON) documents. CrJSON does not include asset_info, content, or metadata.", + "type": "object", + "required": [ + "@context" + ], + "properties": { + "@context": { + "description": "JSON-LD context defining namespaces and vocabularies", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "minItems": 1 + }, + { + "type": "object", + "properties": { + "@vocab": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "manifests": { + "description": "Array of C2PA manifests embedded in the asset", + "type": "array", + "items": { + "$ref": "#/definitions/manifest" + } + }, + "validation_status": { + "description": "Validation status without namespace prefix", + "$ref": "#/definitions/validationStatus" + }, + "extras:validation_status": { + "description": "Validation status with extras namespace prefix", + "$ref": "#/definitions/validationStatus" + } + }, + "patternProperties": { + "^[a-zA-Z0-9]+:validation_status$": { + "description": "Validation status with any namespace prefix", + "$ref": "#/definitions/validationStatus" + } + }, + "additionalProperties": true, + "definitions": { + "manifest": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Manifest label or URN identifier" + }, + "claim": { + "$ref": "#/definitions/claim" + }, + "claim.v2": { + "$ref": "#/definitions/claim" + }, + "assertions": { + "$ref": "#/definitions/assertions" + }, + "created_assertions": { + "type": "object", + "description": "Created assertions at manifest level (alternate location)", + "additionalProperties": true + }, + "generated_assertions": { + "type": "object", + "description": "Generated assertions", + "additionalProperties": true + }, + "redacted_assertions": { + "type": "object", + "description": "Redacted assertions", + "additionalProperties": true + }, + "signature": { + "$ref": "#/definitions/signature" + }, + "claim_signature": { + "$ref": "#/definitions/signature" + }, + "claimSignature": { + "$ref": "#/definitions/signature" + }, + "status": { + "$ref": "#/definitions/status" + } + }, + "additionalProperties": true + }, + "claim": { + "type": "object", + "properties": { + "version": { + "type": [ + "integer", + "null" + ], + "description": "Claim version number" + }, + "title": { + "type": "string", + "description": "Title of the claim" + }, + "instanceID": { + "type": "string", + "description": "Unique identifier for this claim instance" + }, + "alg": { + "type": "string", + "description": "Algorithm used for hashing (e.g., sha256, SHA-256)" + }, + "claim_generator": { + "type": "string", + "description": "String identifier of the claim generator" + }, + "claimGenerator": { + "type": "string", + "description": "Alternate field name for claim generator" + }, + "claim_generator_info": { + "description": "Detailed information about the claim generator", + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/softwareAgent" + } + }, + { + "$ref": "#/definitions/softwareAgent" + } + ] + }, + "signature": { + "type": "string", + "description": "Reference to signature location (e.g., self#jumbf=...)" + }, + "signatureRef": { + "type": "string", + "description": "Alternate field name for signature reference" + }, + "default_algorithm": { + "type": "string", + "description": "Default hashing algorithm" + }, + "defaultAlgorithm": { + "type": "string", + "description": "Alternate field name for default algorithm" + }, + "dc:format": { + "type": "string", + "description": "MIME type of the content" + }, + "dc:title": { + "type": "string", + "description": "Title of the claim (Dublin Core)" + }, + "created_assertions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": [ + "url", + "hash" + ] + } + }, + "gathered_assertions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "hash": { + "type": "string" + } + } + } + }, + "redacted_assertions": { + "type": "array", + "items": { + "type": "object" + } + }, + "signature_status": { + "type": "string", + "description": "Status of the signature validation" + }, + "assertion_status": { + "type": "object", + "description": "Status of each assertion by assertion label", + "additionalProperties": { + "type": "string" + } + }, + "content_status": { + "type": "string", + "description": "Status of content hash validation" + }, + "trust_status": { + "type": "string", + "description": "Trust validation status" + } + }, + "additionalProperties": true + }, + "assertions": { + "type": "object", + "properties": { + "c2pa.hash.data": { + "$ref": "#/definitions/hashDataAssertion" + }, + "c2pa.hash.bmff.v2": { + "$ref": "#/definitions/hashBmffAssertion" + }, + "c2pa.actions": { + "$ref": "#/definitions/actionsAssertion" + }, + "c2pa.actions.v2": { + "$ref": "#/definitions/actionsAssertion" + }, + "c2pa.ingredient": { + "$ref": "#/definitions/ingredientAssertion" + }, + "c2pa.ingredient.v3": { + "$ref": "#/definitions/ingredientAssertion" + }, + "c2pa.thumbnail.claim.jpeg": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "c2pa.thumbnail.ingredient.jpeg": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "c2pa.training-mining": { + "type": "object", + "description": "Training and mining assertion", + "properties": { + "isCAWG": { + "type": "boolean" + }, + "entries": { + "type": "object" + } + }, + "additionalProperties": true + }, + "c2pa.soft-binding": { + "type": "object", + "description": "Soft binding assertion", + "additionalProperties": true + }, + "cawg.metadata": { + "$ref": "#/definitions/cawgMetadataAssertion" + }, + "stds.schema-org.CreativeWork": { + "$ref": "#/definitions/creativeWorkAssertion" + }, + "com.adobe.generative-ai": { + "type": "object", + "description": "Adobe generative AI assertion", + "additionalProperties": true + }, + "adobe.crypto.addresses": { + "type": "object", + "description": "Adobe crypto addresses assertion", + "additionalProperties": true + }, + "jpt.mod-extent": { + "type": "object", + "description": "JPEG Trust modification extent assertion", + "additionalProperties": true + }, + "jpt.rights": { + "type": "object", + "description": "JPEG Trust rights assertion", + "additionalProperties": true + } + }, + "patternProperties": { + "^c2pa\\.thumbnail\\.ingredient": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "^c2pa\\.ingredient": { + "$ref": "#/definitions/ingredientAssertion" + } + }, + "additionalProperties": true + }, + "hashDataAssertion": { + "type": "object", + "properties": { + "alg": { + "type": "string", + "description": "Hash algorithm" + }, + "algorithm": { + "type": "string", + "description": "Alternative field name for hash algorithm" + }, + "hash": { + "type": "string", + "description": "Base64-encoded hash value" + }, + "pad": { + "type": "string", + "description": "Padding bytes" + }, + "paddingLength": { + "type": "integer", + "description": "Length of padding" + }, + "padding2Length": { + "type": "integer", + "description": "Length of secondary padding" + }, + "name": { + "type": "string", + "description": "Name of the hashed data" + }, + "exclusions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "description": "Starting byte position of exclusion" + }, + "length": { + "type": "integer", + "description": "Length of exclusion in bytes" + } + }, + "required": [ + "start", + "length" + ] + } + } + }, + "additionalProperties": true + }, + "actionsAssertion": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/definitions/action" + } + } + }, + "required": [ + "actions" + ] + }, + "action": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Action type (e.g., c2pa.created, c2pa.edited, c2pa.converted)" + }, + "when": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the action occurred" + }, + "softwareAgent": { + "description": "Software that performed the action", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/softwareAgent" + } + ] + }, + "digitalSourceType": { + "type": "string", + "format": "uri", + "description": "IPTC digital source type URI" + }, + "parameters": { + "type": "object", + "description": "Additional parameters for the action", + "additionalProperties": true + } + }, + "required": [ + "action" + ], + "additionalProperties": true + }, + "softwareAgent": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the software" + }, + "version": { + "type": "string", + "description": "Version of the software" + }, + "schema.org.SoftwareApplication.operatingSystem": { + "type": "string", + "description": "Operating system the software runs on" + } + }, + "additionalProperties": true + }, + "signature": { + "type": "object", + "properties": { + "signature_algorithm": { + "type": "string", + "description": "Algorithm used for signing (e.g., SHA256withECDSA)" + }, + "algorithm": { + "description": "Signature algorithm - can be string or object", + "oneOf": [ + { + "type": "string", + "description": "Simple algorithm name (e.g., ES256, PS256)" + }, + { + "type": "object", + "description": "Detailed algorithm specification", + "properties": { + "alg": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "saltLength": { + "type": "integer" + } + } + }, + "coseIdentifier": { + "type": "integer" + } + } + } + ] + }, + "serial_number": { + "type": "string", + "description": "Certificate serial number" + }, + "certificate": { + "type": "object", + "description": "Certificate details", + "properties": { + "serial_number": { + "type": "string" + }, + "serialNumber": { + "type": "string" + }, + "issuer": { + "$ref": "#/definitions/distinguishedName" + }, + "subject": { + "$ref": "#/definitions/distinguishedName" + }, + "validity": { + "type": "object", + "properties": { + "not_before": { + "type": "string", + "format": "date-time" + }, + "notBefore": { + "type": "string", + "format": "date-time" + }, + "not_after": { + "type": "string", + "format": "date-time" + }, + "notAfter": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "subject": { + "$ref": "#/definitions/distinguishedName" + }, + "issuer": { + "$ref": "#/definitions/distinguishedName" + }, + "validity": { + "type": "object", + "properties": { + "not_before": { + "type": "string", + "format": "date-time", + "description": "Certificate validity start date" + }, + "notBefore": { + "type": "string", + "format": "date-time", + "description": "Alternate field name for validity start" + }, + "not_after": { + "type": "string", + "format": "date-time", + "description": "Certificate validity end date" + }, + "notAfter": { + "type": "string", + "format": "date-time", + "description": "Alternate field name for validity end" + } + } + } + }, + "additionalProperties": true + }, + "distinguishedName": { + "type": "object", + "description": "X.509 Distinguished Name components", + "properties": { + "C": { + "type": "string", + "description": "Country" + }, + "ST": { + "type": "string", + "description": "State or province" + }, + "L": { + "type": "string", + "description": "Locality" + }, + "O": { + "type": "string", + "description": "Organization" + }, + "OU": { + "type": "string", + "description": "Organizational unit" + }, + "CN": { + "type": "string", + "description": "Common name" + }, + "E": { + "type": "string", + "description": "Email address" + }, + "2.5.4.97": { + "type": "string", + "description": "Organization identifier OID" + } + }, + "additionalProperties": true + }, + "status": { + "type": "object", + "description": "Validation status information", + "properties": { + "signature": { + "type": "string", + "description": "Signature validation status" + }, + "assertion": { + "type": "object", + "description": "Status of each assertion", + "additionalProperties": { + "type": "string" + } + }, + "content": { + "type": "string", + "description": "Content validation status" + }, + "trust": { + "type": "string", + "description": "Trust validation status" + } + }, + "additionalProperties": true + }, + "ingredientAssertion": { + "type": "object", + "description": "Ingredient assertion for provenance chain", + "properties": { + "_version": { + "type": "integer", + "description": "Ingredient assertion version" + }, + "title": { + "type": "string", + "description": "Title of the ingredient" + }, + "format": { + "type": "string", + "description": "Format of the ingredient" + }, + "instanceID": { + "type": "string", + "description": "Instance ID of the ingredient" + }, + "documentID": { + "type": "string", + "description": "Document ID of the ingredient" + }, + "relationship": { + "type": "string", + "description": "Relationship type (e.g., parentOf, componentOf)" + }, + "labelSuffix": { + "type": "integer", + "description": "Label suffix number for multiple ingredients" + }, + "thumbnail": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "c2pa_manifest": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + }, + "activeManifest": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + }, + "claimSignature": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + }, + "validationResults": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "thumbnailAssertion": { + "type": "object", + "description": "Thumbnail assertion", + "properties": { + "thumbnailType": { + "type": "integer", + "description": "Thumbnail type (0=claim, 1=ingredient)" + }, + "mimeType": { + "type": "string", + "description": "MIME type of thumbnail" + } + }, + "additionalProperties": true + }, + "cawgMetadataAssertion": { + "type": "object", + "description": "Camera-Aware Working Group metadata assertion", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "namespace": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": {} + } + } + }, + "namespacePrefixes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": true + }, + "creativeWorkAssertion": { + "type": "object", + "description": "Schema.org CreativeWork assertion", + "properties": { + "item": { + "type": "object", + "additionalProperties": true + }, + "creativeWork": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "hashBmffAssertion": { + "type": "object", + "description": "BMFF (ISO Base Media File Format) hash assertion", + "properties": { + "_version": { + "type": "integer" + }, + "exclusions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "xpath": { + "type": "string" + }, + "length": { + "type": [ + "integer", + "null" + ] + }, + "data": { + "type": [ + "array", + "null" + ] + }, + "subset": { + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "object", + "null" + ] + }, + "flags": { + "type": [ + "object", + "null" + ] + }, + "exact": { + "type": [ + "boolean", + "null" + ] + } + } + } + }, + "algorithm": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": true + }, + "validationStatus": { + "type": "object", + "description": "Validation status information", + "properties": { + "isValid": { + "type": "boolean", + "description": "Overall validation result" + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "Error message if validation failed" + }, + "code": { + "type": "string", + "description": "Error code" + }, + "explanation": { + "type": "string", + "description": "Explanation of the error" + }, + "uri": { + "type": "string", + "description": "URI where error occurred" + }, + "validationErrors": { + "type": "array", + "description": "List of validation errors", + "items": { + "type": "object", + "description": "Validation error object with code, message, and severity", + "properties": { + "code": { + "type": "string", + "description": "Error code" + }, + "message": { + "type": "string", + "description": "Error message" + }, + "severity": { + "type": "string", + "description": "Error severity level", + "enum": [ + "error", + "warning", + "info" + ] + } + } + } + }, + "entries": { + "type": "array", + "description": "List of validation entries", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "url": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "explanation": { + "type": "string" + } + } + } + } + }, + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index f3be642d2..9f5c6a016 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -36,7 +36,6 @@ use crate::{ error::{Error, Result}, jumbf::labels::to_absolute_uri, reader::{AsyncPostValidator, MaybeSend, PostValidator, Reader}, - utils::hash_utils::hash_stream_by_alg, validation_results::{ validation_codes::{ SIGNING_CREDENTIAL_EXPIRED, SIGNING_CREDENTIAL_INVALID, SIGNING_CREDENTIAL_TRUSTED, @@ -54,17 +53,6 @@ use crate::{ pub struct CrJsonReader { #[serde(skip)] inner: Reader, - - /// Optional asset hash computed from the original asset - #[serde(skip)] - asset_hash: Option, -} - -/// Represents the hash of an asset -#[derive(Debug, Clone)] -struct AssetHash { - algorithm: String, - hash: String, } impl CrJsonReader { @@ -72,7 +60,6 @@ impl CrJsonReader { pub fn from_context(context: Context) -> Self { Self { inner: Reader::from_context(context), - asset_hash: None, } } @@ -80,7 +67,6 @@ impl CrJsonReader { pub fn from_shared_context(context: &Arc) -> Self { Self { inner: Reader::from_shared_context(context), - asset_hash: None, } } @@ -105,12 +91,10 @@ impl CrJsonReader { if _sync { Ok(Self { inner: Reader::from_stream(format, stream)?, - asset_hash: None, }) } else { Ok(Self { inner: Reader::from_stream_async(format, stream).await?, - asset_hash: None, }) } } @@ -134,12 +118,10 @@ impl CrJsonReader { if _sync { Ok(Self { inner: Reader::from_file(path)?, - asset_hash: None, }) } else { Ok(Self { inner: Reader::from_file_async(path).await?, - asset_hash: None, }) } } @@ -175,13 +157,11 @@ impl CrJsonReader { if _sync { Ok(Self { inner: Reader::from_manifest_data_and_stream(c2pa_data, format, stream)?, - asset_hash: None, }) } else { Ok(Self { inner: Reader::from_manifest_data_and_stream_async(c2pa_data, format, stream) .await?, - asset_hash: None, }) } } @@ -218,28 +198,15 @@ impl CrJsonReader { pub fn to_json_value(&self) -> Result { let mut result = json!({ "@context": { - "@vocab": "https://jpeg.org/jpegtrust", - "extras": "https://jpeg.org/jpegtrust/extras" + "@vocab": "https://contentcredentials.org/crjson", + "extras": "https://contentcredentials.org/crjson/extras" } }); - // Add asset_info if we have computed the hash - if let Some(asset_info) = self.get_asset_hash_json() { - result["asset_info"] = asset_info; - } - // Convert manifests from HashMap to Array let manifests_array = self.convert_manifests_to_array()?; result["manifests"] = manifests_array; - // Add content (typically empty) - result["content"] = json!({}); - - // Add metadata if available - if let Some(metadata) = self.extract_metadata()? { - result["metadata"] = metadata; - } - // Add extras:validation_status if let Some(validation_status) = self.build_validation_status()? { result["extras:validation_status"] = validation_status; @@ -256,127 +223,6 @@ impl CrJsonReader { } } - /// Compute and store the asset hash from a stream. - /// - /// This method computes the SHA-256 hash of the asset and stores it for inclusion - /// in the crJSON format output. The stream will be rewound to the beginning - /// before computing the hash. - /// - /// # Arguments - /// * `stream` - A readable and seekable stream containing the asset data - /// - /// # Returns - /// The computed hash as a base64-encoded string - /// - /// # Example - /// ```no_run - /// # use c2pa::{CrJsonReader, Result}; - /// # fn main() -> Result<()> { - /// use std::fs::File; - /// - /// let mut reader = CrJsonReader::from_file("image.jpg")?; - /// - /// // Compute hash from the same file - /// let mut file = File::open("image.jpg")?; - /// let hash = reader.compute_asset_hash(&mut file)?; - /// - /// // Now the JSON output will include asset_info - /// let json = reader.json(); - /// # Ok(()) - /// # } - /// ``` - pub fn compute_asset_hash(&mut self, stream: &mut (impl Read + Seek)) -> Result { - // Rewind to the beginning - stream.rewind()?; - - // Compute SHA-256 hash of the entire stream - let hash = hash_stream_by_alg("sha256", stream, None, true)?; - let hash_b64 = base64::encode(&hash); - - // Store for later use - self.asset_hash = Some(AssetHash { - algorithm: "sha256".to_string(), - hash: hash_b64.clone(), - }); - - Ok(hash_b64) - } - - /// Compute and store the asset hash from a file. - /// - /// This is a convenience method that opens the file and computes its hash. - /// - /// # Arguments - /// * `path` - Path to the asset file - /// - /// # Returns - /// The computed hash as a base64-encoded string - /// - /// # Example - /// ```no_run - /// # use c2pa::{CrJsonReader, Result}; - /// # fn main() -> Result<()> { - /// let mut reader = CrJsonReader::from_file("image.jpg")?; - /// let hash = reader.compute_asset_hash_from_file("image.jpg")?; - /// println!("Asset hash: {}", hash); - /// # Ok(()) - /// # } - /// ``` - #[cfg(feature = "file_io")] - pub fn compute_asset_hash_from_file>( - &mut self, - path: P, - ) -> Result { - let mut file = std::fs::File::open(path)?; - self.compute_asset_hash(&mut file) - } - - /// Set the asset hash directly without computing it. - /// - /// This method allows you to provide a pre-computed hash, which can be useful - /// if you've already computed the hash elsewhere or want to use a different - /// algorithm. - /// - /// # Arguments - /// * `algorithm` - The hash algorithm used (e.g., "sha256") - /// * `hash` - The base64-encoded hash value - /// - /// # Example - /// ```no_run - /// # use c2pa::{CrJsonReader, Result}; - /// # fn main() -> Result<()> { - /// let mut reader = CrJsonReader::from_file("image.jpg")?; - /// reader.set_asset_hash("sha256", "JPkcXXC5DfT9IUUBPK5UaKxGsJ8YIE67BayL+ei3ats="); - /// # Ok(()) - /// # } - /// ``` - pub fn set_asset_hash(&mut self, algorithm: &str, hash: &str) { - self.asset_hash = Some(AssetHash { - algorithm: algorithm.to_string(), - hash: hash.to_string(), - }); - } - - /// Get the currently stored asset hash, if any. - /// - /// # Returns - /// A tuple of (algorithm, hash) if the hash has been set, or None - pub fn asset_hash(&self) -> Option<(&str, &str)> { - self.asset_hash - .as_ref() - .map(|h| (h.algorithm.as_str(), h.hash.as_str())) - } - - /// Get asset hash info for JSON output - fn get_asset_hash_json(&self) -> Option { - self.asset_hash.as_ref().map(|h| { - json!({ - "alg": h.algorithm, - "hash": h.hash - }) - }) - } - /// Convert manifests from HashMap to Array format. /// The first element is always the active manifest (if any); the rest are in manifest store order (most recent first, earliest last). fn convert_manifests_to_array(&self) -> Result { @@ -1137,13 +983,6 @@ impl CrJsonReader { .map(|s| s.code().to_string()) } - /// Extract metadata from manifest (placeholder - not fully available) - fn extract_metadata(&self) -> Result> { - // TODO: This would require extracting EXIF/XMP metadata from the asset - // which is not currently available from the Reader API. - Ok(None) - } - /// Build extras:validation_status from validation results fn build_validation_status(&self) -> Result> { let validation_results = match self.inner.validation_results() { @@ -1314,7 +1153,7 @@ mod tests { #[test] #[cfg(feature = "file_io")] - fn test_jpeg_trust_reader_from_file() -> Result<()> { + fn test_cr_json_reader_from_file() -> Result<()> { let reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; assert_eq!(reader.validation_state(), ValidationState::Trusted); @@ -1325,184 +1164,11 @@ mod tests { Ok(()) } - #[test] - fn test_compute_asset_hash_from_stream() -> Result<()> { - // Create reader - let mut reader = - CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Initially no asset hash - assert!(reader.asset_hash().is_none()); - - // Compute hash from stream - let mut stream = std::io::Cursor::new(IMAGE_WITH_MANIFEST); - let hash = reader.compute_asset_hash(&mut stream)?; - - // Verify hash was computed - assert!(!hash.is_empty()); - assert!(reader.asset_hash().is_some()); - - // Verify hash is accessible - let (alg, stored_hash) = reader.asset_hash().unwrap(); - assert_eq!(alg, "sha256"); - assert_eq!(stored_hash, hash); - - // Verify JSON output includes asset_info - let json_value = reader.to_json_value()?; - assert!(json_value.get("asset_info").is_some()); - - let asset_info = &json_value["asset_info"]; - assert_eq!(asset_info["alg"], "sha256"); - assert_eq!(asset_info["hash"], hash); - - Ok(()) - } - - #[test] - #[cfg(feature = "file_io")] - fn test_compute_asset_hash_from_file() -> Result<()> { - // Create reader - let mut reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; - - // Compute hash from same file - let hash = reader.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; - - // Verify hash was computed - assert!(!hash.is_empty()); - assert!(reader.asset_hash().is_some()); - - // Verify JSON includes asset_info - let json_value = reader.to_json_value()?; - assert!(json_value.get("asset_info").is_some()); - - let asset_info = &json_value["asset_info"]; - assert_eq!(asset_info["alg"], "sha256"); - assert_eq!(asset_info["hash"], hash); - - Ok(()) - } - - #[test] - fn test_set_asset_hash_directly() -> Result<()> { - // Create reader - let mut reader = - CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Set hash directly - let test_hash = "JPkcXXC5DfT9IUUBPK5UaKxGsJ8YIE67BayL+ei3ats="; - reader.set_asset_hash("sha256", test_hash); - - // Verify hash is set - let (alg, hash) = reader.asset_hash().unwrap(); - assert_eq!(alg, "sha256"); - assert_eq!(hash, test_hash); - - // Verify JSON includes asset_info - let json_value = reader.to_json_value()?; - let asset_info = &json_value["asset_info"]; - assert_eq!(asset_info["alg"], "sha256"); - assert_eq!(asset_info["hash"], test_hash); - - Ok(()) - } - - #[test] - fn test_asset_hash_consistency() -> Result<()> { - // Create two readers from the same data - let mut reader1 = - CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; - - let mut reader2 = - CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Compute hashes - let mut stream1 = std::io::Cursor::new(IMAGE_WITH_MANIFEST); - let hash1 = reader1.compute_asset_hash(&mut stream1)?; - - let mut stream2 = std::io::Cursor::new(IMAGE_WITH_MANIFEST); - let hash2 = reader2.compute_asset_hash(&mut stream2)?; - - // Hashes should be identical - assert_eq!(hash1, hash2); - - Ok(()) - } - - #[test] - fn test_json_without_asset_hash() -> Result<()> { - // Create reader without computing hash - let reader = - CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; - - // JSON should not include asset_info - let json_value = reader.to_json_value()?; - assert!(json_value.get("asset_info").is_none()); - - Ok(()) - } - - #[test] - fn test_json_with_asset_hash() -> Result<()> { - // Create reader and compute hash - let mut reader = - CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; - - let mut stream = std::io::Cursor::new(IMAGE_WITH_MANIFEST); - reader.compute_asset_hash(&mut stream)?; - - // JSON should include asset_info - let json_value = reader.to_json_value()?; - assert!(json_value.get("asset_info").is_some()); - - // Verify structure - let asset_info = &json_value["asset_info"]; - assert!(asset_info.get("alg").is_some()); - assert!(asset_info.get("hash").is_some()); - - Ok(()) - } - - #[test] - fn test_asset_hash_update() -> Result<()> { - // Create reader - let mut reader = - CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Set initial hash - reader.set_asset_hash("sha256", "hash1"); - assert_eq!(reader.asset_hash().unwrap().1, "hash1"); - - // Update hash - reader.set_asset_hash("sha512", "hash2"); - let (alg, hash) = reader.asset_hash().unwrap(); - assert_eq!(alg, "sha512"); - assert_eq!(hash, "hash2"); - - Ok(()) - } - - #[test] - #[cfg(feature = "file_io")] - fn test_asset_hash_with_different_files() -> Result<()> { - // Test with two different files - let mut reader1 = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; - let hash1 = reader1.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; - - let mut reader2 = CrJsonReader::from_file("tests/fixtures/C.jpg")?; - let hash2 = reader2.compute_asset_hash_from_file("tests/fixtures/C.jpg")?; - - // Different files should have different hashes - assert_ne!(hash1, hash2); - - Ok(()) - } - #[test] #[cfg(feature = "file_io")] fn test_claim_signature_decoding() -> Result<()> { // Test that claim_signature is decoded with full certificate details - let mut reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; - reader.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; + let reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; let json_value = reader.to_json_value()?; let manifests = json_value["manifests"].as_array().unwrap(); @@ -1542,8 +1208,7 @@ mod tests { #[cfg(feature = "file_io")] fn test_cawg_identity_x509_signature_decoding() -> Result<()> { // Test that cawg.identity with X.509 signature is fully decoded - let mut reader = CrJsonReader::from_file("tests/fixtures/C_with_CAWG_data.jpg")?; - reader.compute_asset_hash_from_file("tests/fixtures/C_with_CAWG_data.jpg")?; + let reader = CrJsonReader::from_file("tests/fixtures/C_with_CAWG_data.jpg")?; let json_value = reader.to_json_value()?; let manifests = json_value["manifests"].as_array().unwrap(); @@ -1607,10 +1272,8 @@ mod tests { // Test that cawg.identity with ICA signature extracts VC information // Note: This test uses a fixture from the identity tests let test_image = include_bytes!("identity/tests/fixtures/claim_aggregation/adobe_connected_identities.jpg"); - - let mut reader = CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(&test_image[..]))?; - let mut stream = std::io::Cursor::new(&test_image[..]); - reader.compute_asset_hash(&mut stream)?; + + let reader = CrJsonReader::from_stream("image/jpeg", std::io::Cursor::new(&test_image[..]))?; let json_value = reader.to_json_value()?; let manifests = json_value["manifests"].as_array().unwrap(); diff --git a/sdk/tests/crjson/asset_hash.rs b/sdk/tests/crjson/asset_hash.rs new file mode 100644 index 000000000..f4818d6eb --- /dev/null +++ b/sdk/tests/crjson/asset_hash.rs @@ -0,0 +1,63 @@ +// 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. + +//! Integration tests for CrJsonReader output structure. +//! CrJSON format does not include asset_info, content, or metadata. + +use c2pa::{CrJsonReader, Result}; +use std::io::Cursor; + +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/CA.jpg"); + +#[test] +fn test_cr_json_omits_asset_info_content_metadata() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + + let json_value = reader.to_json_value()?; + + // CrJSON does not include these top-level properties + assert!( + json_value.get("asset_info").is_none(), + "asset_info should not be present in CrJSON output" + ); + assert!( + json_value.get("content").is_none(), + "content should not be present in CrJSON output" + ); + assert!( + json_value.get("metadata").is_none(), + "metadata should not be present in CrJSON output" + ); + + // Required CrJSON fields should still be present + assert!(json_value.get("@context").is_some()); + assert!(json_value.get("manifests").is_some()); + + Ok(()) +} + +#[test] +#[cfg(feature = "file_io")] +fn test_cr_json_from_file_omits_asset_info_content_metadata() -> Result<()> { + let reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; + + let json_value = reader.to_json_value()?; + + assert!(json_value.get("asset_info").is_none()); + assert!(json_value.get("content").is_none()); + assert!(json_value.get("metadata").is_none()); + assert!(json_value.get("@context").is_some()); + assert!(json_value.get("manifests").is_some()); + + Ok(()) +} diff --git a/sdk/tests/test_cr_json_created_gathered.rs b/sdk/tests/crjson/created_gathered.rs similarity index 88% rename from sdk/tests/test_cr_json_created_gathered.rs rename to sdk/tests/crjson/created_gathered.rs index 160fa2b17..01e4d2bfc 100644 --- a/sdk/tests/test_cr_json_created_gathered.rs +++ b/sdk/tests/crjson/created_gathered.rs @@ -17,8 +17,8 @@ use std::io::Cursor; use c2pa::{Builder, Context, CrJsonReader, Result, Settings}; -const TEST_IMAGE: &[u8] = include_bytes!("fixtures/CA.jpg"); -const TEST_SETTINGS: &str = include_str!("../tests/fixtures/test_settings.toml"); +const TEST_IMAGE: &[u8] = include_bytes!("../fixtures/CA.jpg"); +const TEST_SETTINGS: &str = include_str!("../fixtures/test_settings.toml"); #[test] fn test_created_and_gathered_assertions_separated() -> Result<()> { @@ -70,28 +70,20 @@ fn test_created_and_gathered_assertions_separated() -> Result<()> { .as_array() .expect("manifests should be array"); - // We need to find the manifest with our test assertions (the newly created one) - // This is the most recent manifest with claim version 2 (which has created_assertions/gathered_assertions) + // Find the manifest that contains our test assertions (org.test.created, org.test.gathered, org.test.regular) let active_manifest = manifests .iter() - .filter(|m| { - // Filter for claim v2 manifests (which have non-empty created_assertions or gathered_assertions) - if let Some(claim_v2) = m.get("claim.v2") { - if let Some(created) = claim_v2.get("created_assertions") { - if let Some(arr) = created.as_array() { - return !arr.is_empty(); - } - } - if let Some(gathered) = claim_v2.get("gathered_assertions") { - if let Some(arr) = gathered.as_array() { - return !arr.is_empty(); - } - } - } - false + .find(|m| { + m.get("assertions") + .and_then(|a| a.as_object()) + .map(|obj| { + obj.contains_key("org.test.created") + && obj.contains_key("org.test.gathered") + && obj.contains_key("org.test.regular") + }) + .unwrap_or(false) }) - .last() - .expect("should have at least one claim v2 manifest"); + .expect("should have a manifest with our test assertions (org.test.created, org.test.gathered, org.test.regular)"); // Print the label for debugging println!("Manifest label: {:?}", active_manifest.get("label")); @@ -283,4 +275,3 @@ fn test_hash_assertions_in_created() -> Result<()> { Ok(()) } - diff --git a/sdk/tests/test_cr_json_hash_assertions.rs b/sdk/tests/crjson/hash_assertions.rs similarity index 99% rename from sdk/tests/test_cr_json_hash_assertions.rs rename to sdk/tests/crjson/hash_assertions.rs index 190e9d3d9..d2cba1a95 100644 --- a/sdk/tests/test_cr_json_hash_assertions.rs +++ b/sdk/tests/crjson/hash_assertions.rs @@ -9,7 +9,7 @@ use std::io::Cursor; use c2pa::{CrJsonReader, Result}; // Test image with manifest -const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("fixtures/C.jpg"); +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/C.jpg"); #[test] fn test_hash_data_assertion_included() -> Result<()> { diff --git a/sdk/tests/test_cr_json_hash_encoding.rs b/sdk/tests/crjson/hash_encoding.rs similarity index 99% rename from sdk/tests/test_cr_json_hash_encoding.rs rename to sdk/tests/crjson/hash_encoding.rs index 23ac20160..180dc73a6 100644 --- a/sdk/tests/test_cr_json_hash_encoding.rs +++ b/sdk/tests/crjson/hash_encoding.rs @@ -20,7 +20,7 @@ use c2pa::{CrJsonReader, Result}; use serde_json::Value; use std::io::Cursor; -const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("fixtures/CA.jpg"); +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/CA.jpg"); /// Recursively check that all "hash" fields in the JSON are strings (base64), /// not arrays of integers. diff --git a/sdk/tests/test_cr_json_ingredients.rs b/sdk/tests/crjson/ingredients.rs similarity index 99% rename from sdk/tests/test_cr_json_ingredients.rs rename to sdk/tests/crjson/ingredients.rs index 7df3af49d..65fe31984 100644 --- a/sdk/tests/test_cr_json_ingredients.rs +++ b/sdk/tests/crjson/ingredients.rs @@ -16,7 +16,7 @@ use c2pa::{CrJsonReader, Result}; use std::io::Cursor; -const IMAGE_WITH_INGREDIENT: &[u8] = include_bytes!("fixtures/CA.jpg"); +const IMAGE_WITH_INGREDIENT: &[u8] = include_bytes!("../fixtures/CA.jpg"); #[test] fn test_ingredient_assertions_included() -> Result<()> { diff --git a/sdk/tests/crjson/mod.rs b/sdk/tests/crjson/mod.rs new file mode 100644 index 000000000..4af4cc595 --- /dev/null +++ b/sdk/tests/crjson/mod.rs @@ -0,0 +1,19 @@ +// 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. + +mod schema_compliance; +mod asset_hash; +mod created_gathered; +mod hash_assertions; +mod hash_encoding; +mod ingredients; diff --git a/sdk/tests/test_cr_json_schema_compliance.rs b/sdk/tests/crjson/schema_compliance.rs similarity index 71% rename from sdk/tests/test_cr_json_schema_compliance.rs rename to sdk/tests/crjson/schema_compliance.rs index e1e969a81..de8100bab 100644 --- a/sdk/tests/test_cr_json_schema_compliance.rs +++ b/sdk/tests/crjson/schema_compliance.rs @@ -11,20 +11,20 @@ // specific language governing permissions and limitations under // each license. -//! Schema compliance tests for crJSON format +//! Schema compliance tests for crJSON format. +//! These tests validate CrJSON output structure and alignment with `export_schema/crJSON-schema.json`. use c2pa::{CrJsonReader, Result}; use std::io::Cursor; -const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("fixtures/CA.jpg"); +const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/CA.jpg"); + +/// CrJSON schema (export_schema/crJSON-schema.json) - used to verify output structure. +const CRJSON_SCHEMA: &str = include_str!("../../../export_schema/crJSON-schema.json"); #[test] fn test_validation_status_schema_compliance() -> Result<()> { - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Compute asset hash - let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); - reader.compute_asset_hash(&mut stream)?; + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; let json_value = reader.to_json_value()?; @@ -182,47 +182,6 @@ fn test_manifest_status_schema_compliance() -> Result<()> { Ok(()) } -#[test] -fn test_asset_info_schema_compliance() -> Result<()> { - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Compute hash to populate asset_info - let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); - reader.compute_asset_hash(&mut stream)?; - - let json_value = reader.to_json_value()?; - - // Verify asset_info exists - let asset_info = json_value - .get("asset_info") - .expect("asset_info should exist when hash is computed"); - - assert!(asset_info.is_object(), "asset_info should be an object"); - let asset_info_obj = asset_info.as_object().unwrap(); - - // Required: alg - assert!( - asset_info_obj.contains_key("alg"), - "asset_info should have alg field" - ); - assert!( - asset_info_obj.get("alg").unwrap().is_string(), - "alg should be string" - ); - - // Required: hash - assert!( - asset_info_obj.contains_key("hash"), - "asset_info should have hash field" - ); - assert!( - asset_info_obj.get("hash").unwrap().is_string(), - "hash should be string" - ); - - Ok(()) -} - #[test] fn test_context_schema_compliance() -> Result<()> { let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; @@ -284,47 +243,98 @@ fn test_manifests_array_schema_compliance() -> Result<()> { Ok(()) } -#[test] -fn test_content_object_exists() -> Result<()> { - let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - let json_value = reader.to_json_value()?; - - // content object should exist (can be empty) - let content = json_value.get("content").expect("content should exist"); - assert!(content.is_object(), "content should be an object"); - - Ok(()) -} - #[test] fn test_complete_schema_structure() -> Result<()> { - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Compute hash for complete output - let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); - reader.compute_asset_hash(&mut stream)?; + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; let json_value = reader.to_json_value()?; - // Verify all top-level required/expected fields + // Verify all top-level required fields (no asset_info, content, or metadata) assert!(json_value.get("@context").is_some(), "@context missing"); - assert!( - json_value.get("asset_info").is_some(), - "asset_info missing (with hash)" - ); assert!(json_value.get("manifests").is_some(), "manifests missing"); - assert!(json_value.get("content").is_some(), "content missing"); assert!( json_value.get("extras:validation_status").is_some(), "extras:validation_status missing" ); + // CrJSON does not include asset_info, content, or metadata + assert!(json_value.get("asset_info").is_none()); + assert!(json_value.get("content").is_none()); + assert!(json_value.get("metadata").is_none()); + // Verify types assert!(json_value["@context"].is_object()); - assert!(json_value["asset_info"].is_object()); assert!(json_value["manifests"].is_array()); - assert!(json_value["content"].is_object()); assert!(json_value["extras:validation_status"].is_object()); Ok(()) } + +/// Load and parse the CrJSON schema file; ensure it defines the expected root properties +/// and does not include declaration, asset_info, content, or metadata. +#[test] +fn test_cr_json_schema_file_valid_and_matches_format() -> Result<()> { + let schema_value: serde_json::Value = + serde_json::from_str(CRJSON_SCHEMA).expect("crJSON-schema.json must be valid JSON"); + + let props = schema_value + .get("properties") + .and_then(|p| p.as_object()) + .expect("schema must have properties"); + + // CrJSON schema must define these root properties + assert!(props.contains_key("@context"), "schema must define @context"); + assert!(props.contains_key("manifests"), "schema must define manifests"); + assert!( + props.contains_key("extras:validation_status") || props.contains_key("validation_status"), + "schema must define validation_status or extras:validation_status" + ); + + // CrJSON schema must NOT include removed sections + assert!(!props.contains_key("declaration"), "schema must not include declaration"); + assert!(!props.contains_key("asset_info"), "schema must not include asset_info"); + assert!(!props.contains_key("content"), "schema must not include content"); + assert!(!props.contains_key("metadata"), "schema must not include metadata"); + + // Schema $id should reference crJSON-schema + let id = schema_value.get("$id").and_then(|i| i.as_str()).unwrap_or(""); + assert!( + id.contains("crJSON-schema"), + "schema $id should reference crJSON-schema.json, got: {}", + id + ); + + Ok(()) +} + +/// Verify CrJSON output from the reader conforms to the schema's root shape +/// (no declaration, asset_info, content, metadata). +#[test] +fn test_cr_json_output_matches_schema_root() -> Result<()> { + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + + let schema_value: serde_json::Value = + serde_json::from_str(CRJSON_SCHEMA).expect("crJSON-schema.json must be valid JSON"); + let props = schema_value + .get("properties") + .and_then(|p| p.as_object()) + .expect("schema must have properties"); + + // Every top-level key in output should be allowed by the schema (or be additionalProperties) + for key in json_value.as_object().unwrap().keys() { + assert!( + props.contains_key(key), + "CrJSON output key {:?} is not in crJSON-schema.json properties (schema may allow via additionalProperties)", + key + ); + } + + // Output must not contain removed root keys + assert!(json_value.get("declaration").is_none()); + assert!(json_value.get("asset_info").is_none()); + assert!(json_value.get("content").is_none()); + assert!(json_value.get("metadata").is_none()); + + Ok(()) +} diff --git a/sdk/tests/crjson_tests.rs b/sdk/tests/crjson_tests.rs new file mode 100644 index 000000000..cbb724292 --- /dev/null +++ b/sdk/tests/crjson_tests.rs @@ -0,0 +1,17 @@ +// 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. + +//! CrJSON integration tests. +//! Tests are organized in the crjson/ subfolder. + +mod crjson; diff --git a/sdk/tests/test_cr_json_asset_hash.rs b/sdk/tests/test_cr_json_asset_hash.rs deleted file mode 100644 index 8c80292cf..000000000 --- a/sdk/tests/test_cr_json_asset_hash.rs +++ /dev/null @@ -1,207 +0,0 @@ -// 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. - -//! Integration tests for CrJsonReader asset hash functionality - -use c2pa::{CrJsonReader, Result}; -use std::io::Cursor; - -const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("fixtures/CA.jpg"); - -#[test] -fn test_asset_hash_in_json_output() -> Result<()> { - // Create reader and compute hash - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Initially no asset_info in output - let json_without_hash = reader.json(); - assert!(!json_without_hash.contains("asset_info")); - - // Compute hash - let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); - let computed_hash = reader.compute_asset_hash(&mut stream)?; - - // Now asset_info should be present - let json_with_hash = reader.json(); - assert!(json_with_hash.contains("asset_info")); - assert!(json_with_hash.contains(&computed_hash)); - assert!(json_with_hash.contains("\"alg\": \"sha256\"")); - - Ok(()) -} - -#[test] -fn test_multiple_hash_computations() -> Result<()> { - // Test that computing hash multiple times gives consistent results - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // First computation - let mut stream1 = Cursor::new(IMAGE_WITH_MANIFEST); - let hash1 = reader.compute_asset_hash(&mut stream1)?; - - // Second computation (should overwrite) - let mut stream2 = Cursor::new(IMAGE_WITH_MANIFEST); - let hash2 = reader.compute_asset_hash(&mut stream2)?; - - // Hashes should be identical - assert_eq!(hash1, hash2); - - // JSON should contain the hash - let json = reader.json(); - assert!(json.contains(&hash2)); - - Ok(()) -} - -#[test] -fn test_set_hash_directly() -> Result<()> { - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Set a custom hash - let custom_hash = "AAABBBCCCDDDEEEFFF111222333444555666777888999==="; - reader.set_asset_hash("sha512", custom_hash); - - // Verify it appears in JSON with correct algorithm - let json = reader.json(); - assert!(json.contains(custom_hash)); - assert!(json.contains("\"alg\": \"sha512\"")); - - Ok(()) -} - -#[test] -fn test_accessor_methods() -> Result<()> { - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Initially None - assert!(reader.asset_hash().is_none()); - - // Set hash - reader.set_asset_hash("sha256", "test_hash_value"); - - // Should be accessible - let (alg, hash) = reader.asset_hash().expect("Hash should be set"); - assert_eq!(alg, "sha256"); - assert_eq!(hash, "test_hash_value"); - - Ok(()) -} - -#[test] -#[cfg(feature = "file_io")] -fn test_compute_from_file() -> Result<()> { - let mut reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; - - // Compute hash from file - let hash = reader.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; - - // Verify it's not empty - assert!(!hash.is_empty()); - - // Verify it's accessible - assert!(reader.asset_hash().is_some()); - - // Verify JSON includes it - let json = reader.json(); - assert!(json.contains("asset_info")); - assert!(json.contains(&hash)); - - Ok(()) -} - -#[test] -#[cfg(feature = "file_io")] -fn test_different_files_different_hashes() -> Result<()> { - // Read two different files - let mut reader1 = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; - let hash1 = reader1.compute_asset_hash_from_file("tests/fixtures/CA.jpg")?; - - let mut reader2 = CrJsonReader::from_file("tests/fixtures/C.jpg")?; - let hash2 = reader2.compute_asset_hash_from_file("tests/fixtures/C.jpg")?; - - // Different files should have different hashes - assert_ne!(hash1, hash2); - - Ok(()) -} - -#[test] -fn test_hash_persistence_across_json_calls() -> Result<()> { - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Compute hash once - let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); - let hash = reader.compute_asset_hash(&mut stream)?; - - // Get JSON multiple times - let json1 = reader.json(); - let json2 = reader.json(); - - // Both should contain the hash - assert!(json1.contains(&hash)); - assert!(json2.contains(&hash)); - assert_eq!(json1, json2); - - Ok(()) -} - -#[test] -fn test_hash_format_is_base64() -> Result<()> { - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); - let hash = reader.compute_asset_hash(&mut stream)?; - - // Base64 should only contain valid characters - let is_valid_base64 = hash - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='); - - assert!(is_valid_base64, "Hash should be valid base64: {}", hash); - - Ok(()) -} - -#[test] -fn test_complete_cr_json_format_with_asset_info() -> Result<()> { - let mut reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; - - // Compute hash - let mut stream = Cursor::new(IMAGE_WITH_MANIFEST); - reader.compute_asset_hash(&mut stream)?; - - // Get JSON value - let json_value = reader.to_json_value()?; - - // Verify complete structure - assert!(json_value.get("@context").is_some()); - assert!(json_value.get("asset_info").is_some()); - assert!(json_value.get("manifests").is_some()); - assert!(json_value.get("content").is_some()); - - // Verify asset_info structure - let asset_info = json_value["asset_info"] - .as_object() - .expect("asset_info should be an object"); - assert!(asset_info.contains_key("alg")); - assert!(asset_info.contains_key("hash")); - assert_eq!(asset_info["alg"], "sha256"); - - // Verify hash is a non-empty string - let hash = asset_info["hash"] - .as_str() - .expect("hash should be a string"); - assert!(!hash.is_empty()); - - Ok(()) -} From ba7c3b095282160f468c0e3dd14322a83e95e20b Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Sun, 15 Feb 2026 13:26:25 -0500 Subject: [PATCH 03/18] MAJOR cleanup of the schema and the documentation & tests to reflect it --- docs/crjson-format.adoc | 147 +++++---- export_schema/crJSON-schema.json | 354 +++++++++------------- sdk/src/cr_json_reader.rs | 64 ++-- sdk/target/crjson_test_output/CA.jpg.json | 227 ++++++++++++++ sdk/tests/crjson/schema_compliance.rs | 69 ++++- 5 files changed, 561 insertions(+), 300 deletions(-) create mode 100644 sdk/target/crjson_test_output/CA.jpg.json diff --git a/docs/crjson-format.adoc b/docs/crjson-format.adoc index c20bb7d1b..d0c74ea74 100644 --- a/docs/crjson-format.adoc +++ b/docs/crjson-format.adoc @@ -4,16 +4,16 @@ :toclevels: 3 :sectnums: -== 1. Scope +== Scope This document describes a JSON serialization for Content Credentials (aka a C2PA manifest store) known as the *Content Credential JSON* format (abbreviated *CrJSON*). It's purpose is to provide a JSON-based representation of a C2PA manifest store for profile evaluation, interoperability testing, and validation reporting. -== 2. Normative References +== Normative References * C2PA Technical Specification v2.3: https://c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html * CrJSON JSON Schema: `export_schema/crJSON-schema.json` (in this repository) -== 3. Relationship to C2PA v2.3 +== Relationship to C2PA v2.3 CrJSON does not replace C2PA claim stores, JUMBF, or COSE structures. Instead, it is a *derived JSON view* over C2PA data. @@ -22,10 +22,10 @@ The following C2PA concepts are directly represented: * C2PA manifests -> `manifests[]` * C2PA assertions -> `manifests[].assertions` (object keyed by assertion label) * C2PA claim data -> `manifests[].claim.v2` -* C2PA claim signature and credential details -> `manifests[].claim_signature` +* C2PA claim signature and credential details -> `manifests[].signature` * C2PA validation results -> `manifests[].status` and `extras:validation_status` -== 4. Data Model Overview +== Data Model Overview [source,text] ---- @@ -36,16 +36,16 @@ C2PA Asset/Manifest Store |- @context |- manifests[] | |- label - | |- claim.v2 | |- assertions{...} - | |- claim_signature (optional) - | |- status (optional) + | |- signature (required) + | |- status (required) + | |- claim or claim.v2 (one required) |- extras:validation_status (optional) ---- -== 5. Serialization Requirements +== Serialization Requirements -=== 5.1 Root Object +=== Root Object A CrJSON document SHALL be a JSON object. @@ -57,65 +57,93 @@ The following top-level properties are used: |`@context` |REQUIRED -|JSON-LD context. Implementation emits an object with `@vocab` and `extras`. +|JSON-LD context. Implementation emits an object with `@vocab` and `extras`. Schema allows object or array of URI strings. |`manifests` |REQUIRED -|Array of manifest objects. +|Array of manifest objects. Each item conforms to the manifest definition (required: label, assertions, signature, status; one of claim or claim.v2). |`extras:validation_status` |OPTIONAL |Overall validation report for the active manifest. + +|`validation_status` (no prefix) +|OPTIONAL +|Same structure as extras:validation_status; schema allows this alternate key. + +|Namespaced `*:validation_status` +|OPTIONAL +|Schema allows any key matching the pattern for validation status (e.g. extras:validation_status). |=== -=== 5.2 `@context` +=== `@context` Implementation output: [source,json] ---- "@context": { - "@vocab": "https://https://contentcredentials.org/crjson", - "extras": "https://https://contentcredentials.org/crjson/extras" + "@vocab": "https://contentcredentials.org/crjson", + "extras": "https://contentcredentials.org/crjson/extras" } ---- -=== 5.3 `manifests` +=== `manifests` -`manifests` SHALL be an array. Ordering rules: +`manifests` SHALL be an array with at least one entry. Ordering rules: -1. Active manifest first, if present. -2. Remaining manifests in store order, most recent first. +1. Active manifest first. +2. Remaining manifests in (reverse) store order, most recent first. -Each manifest object includes: +Each manifest object SHALL include the following properties (per schema: required `label`, `assertions`, `signature`, `status`; exactly one of `claim` or `claim.v2`): * `label` (manifest label/URN) -* `assertions` (object) -* `claim.v2` (object) -* `claim_signature` (optional) -* `status` (optional) +* `assertions` (object keyed by assertion label) +* `signature` (required; signature and credential details object) +* `status` (required; per-manifest validation results object) +* either `claim` (v1, per claimV1 / C2PA claim-map) or `claim.v2` (v2, per C2PA claim-map-v2; implementation emits `claim.v2`) + +The manifest object does not allow additional properties (schema `additionalProperties: false`). + +=== `claim.v2` (v2 claim, claim-map-v2) + +`claim.v2` conforms to the C2PA CDDL claim-map-v2. Required properties: -=== 5.4 `claim.v2` +* `instanceID` — uniquely identifies a specific version of an asset +* `claim_generator_info` — single generator-info map (object with e.g. `name`, `version`, optional `icon`, `operating_system`) +* `signature` — JUMBF URI reference to the signature of this claim (e.g. `self#jumbf=/c2pa/{label}/c2pa.signature`) +* `created_assertions` — array of one or more hashed URI maps; each entry has `url`, `hash`, and optionally `alg` -`claim.v2` is a normalized object built from C2PA manifest/claim data. Typical fields: +Optional properties: -* `dc:title` -* `instanceID` -* `claim_generator` -* `claim_generator_info` -* `alg` (implementation currently emits `SHA-256`) -* `signature` (`self#jumbf=/c2pa/{label}/c2pa.signature`) -* `created_assertions[]` (`{url, hash}`) -* `gathered_assertions[]` (`{url, hash}`) -* `redacted_assertions[]` (currently empty array) +* `gathered_assertions` — array of hashed URI maps (same structure as created_assertions) +* `dc:title` — name of the asset +* `redacted_assertions` — array of JUMBF URI strings (references to redacted ingredient manifest assertions) +* `alg` — cryptographic hash algorithm for data hash assertions (e.g. `SHA-256`) +* `alg_soft` — algorithm for soft binding assertions +* `specVersion` — specification version (SemVer) +* `metadata` — (DEPRECATED) additional information -All `hash` values in assertion references SHALL be Base64 strings. +All `hash` values in hashed URI maps SHALL be Base64 strings. -=== 5.5 `assertions` +=== `claim` (v1 claim, claim-map) + +When a manifest uses `claim` instead of `claim.v2`, it conforms to the C2PA CDDL claim-map (claimV1). Required properties: + +* `claim_generator` — User-Agent string for the claim generator +* `claim_generator_info` — array of one or more generator-info maps +* `signature` — JUMBF URI reference to the signature +* `assertions` — array of one or more hashed URI maps (`url`, `hash`, optional `alg`) +* `dc:format` — media type of the asset +* `instanceID` — uniquely identifies a specific version of an asset + +Optional: `dc:title`, `redacted_assertions` (JUMBF URI strings), `alg`, `alg_soft`, `metadata`. + +=== `assertions` `assertions` SHALL be an object keyed by assertion label. -==== 5.5.1 Included assertion sources +==== Included assertion sources CrJSON aggregates assertions from multiple C2PA sources: @@ -124,7 +152,7 @@ CrJSON aggregates assertions from multiple C2PA sources: * Ingredient assertions from `manifest.ingredients()` * Gathered assertions not present in regular list (including binary/UUID payload cases) -==== 5.5.2 Labeling and instance rules +==== Labeling and instance rules * Base label is the assertion label string. * For repeated hash/gathered assertion instances, suffix `_N` is used where `N = instance + 1`. @@ -137,7 +165,7 @@ Examples: * `c2pa.ingredient` * `c2pa.ingredient.v3__2` -==== 5.5.3 Binary normalization and hash encoding +==== Binary normalization and hash encoding CrJSON normalizes byte-array encodings into Base64 string encodings. @@ -151,7 +179,7 @@ If fields are serialized as integer arrays, they are converted to Base64 strings This rule applies recursively to nested objects/arrays. -==== 5.5.4 Gathered binary assertion representation +==== Gathered binary assertion representation For gathered binary/UUID assertions, CrJSON emits a reference form: @@ -164,9 +192,9 @@ For gathered binary/UUID assertions, CrJSON emits a reference form: } ---- -=== 5.6 `claim_signature` +=== `signature` (per manifest) -When signature information is available, `claim_signature` includes: +Each manifest SHALL include `signature`. When signature information is available, it includes: * `algorithm` * `serial_number` @@ -175,18 +203,20 @@ When signature information is available, `claim_signature` includes: * `validity.not_before` * `validity.not_after` -The implementation parses the first certificate in the chain and serializes times as RFC 3339 strings. +The implementation parses the first certificate in the chain and serializes times as RFC 3339 strings. When signature information is unavailable, `signature` SHALL be present as an empty object `{}`. -=== 5.7 `status` (per manifest) +=== `status` (per manifest) -Per-manifest `status` is derived from active-manifest validation results and may include: +Each manifest SHALL include `status`. It is derived from active-manifest validation results and may include: * `signature`: first code prefixed by `claimSignature` * `trust`: preferred signing credential code (`trusted`, else `invalid`, `untrusted`, `expired`, then fallback) * `content`: first code prefixed by `assertion.dataHash` * `assertion`: map of assertion label -> validation code (when found) -=== 5.8 `extras:validation_status` (global) +When validation results are unavailable, `status` SHALL be present as an empty object `{}`. + +=== `extras:validation_status` (global) Global validation object includes: @@ -202,27 +232,33 @@ Global validation object includes: ** `explanation` (optional) ** `severity` (`info`, `warning`, or `error`) -== 6. Constraints and Current Implementation Limits +== Constraints and Current Implementation Limits -* CrJSON does not include `asset_info`, `content`, or `metadata`; the root object contains only `@context`, `manifests`, and optionally `extras:validation_status`. +* CrJSON does not include `asset_info`, `content`, or `metadata`. The root object defines `@context` (required), `manifests` (required), and optionally validation-status properties (`extras:validation_status` or other namespaced keys per schema). The schema allows `additionalProperties` at root. +* Manifest objects do not allow additional properties (schema `additionalProperties: false` for manifest). * CrJSON is export-oriented; it is not the canonical source of cryptographic truth. Canonical validation remains bound to C2PA/JUMBF/COSE data structures per C2PA v2.3. -== 7. Minimal Example +== Minimal Example + +The following example conforms to the CrJSON schema. Each manifest SHALL have `label`, `assertions`, `signature`, `status`, and either `claim` or `claim.v2`. For `claim.v2`, required fields are `instanceID`, `claim_generator_info`, `signature`, and `created_assertions` (at least one entry). [source,json] ---- { "@context": { - "@vocab": "https://https://contentcredentials.org/crjson", - "extras": "https://https://contentcredentials.org/crjson/extras" + "@vocab": "https://contentcredentials.org/crjson", + "extras": "https://contentcredentials.org/crjson/extras" }, "manifests": [ { "label": "urn:uuid:...", "claim.v2": { "instanceID": "xmp:iid:...", + "claim_generator_info": {"name": "Example Tool", "version": "1.0"}, "signature": "self#jumbf=/c2pa/urn:uuid:.../c2pa.signature", - "created_assertions": [], + "created_assertions": [ + {"url": "self#jumbf=c2pa.assertions/c2pa.hash.data", "hash": ""} + ], "gathered_assertions": [], "redacted_assertions": [] }, @@ -230,6 +266,7 @@ Global validation object includes: "c2pa.actions.v2": {"actions": []}, "c2pa.hash.data": {"alg": "sha256", "hash": ""} }, + "signature": {}, "status": { "signature": "claimSignature.validated", "trust": "signingCredential.trusted" @@ -245,7 +282,7 @@ Global validation object includes: } ---- -== 8. Conformance Guidance +== Conformance Guidance A CrJSON producer is conformant to this implementation profile when it: @@ -254,4 +291,4 @@ A CrJSON producer is conformant to this implementation profile when it: * Includes hash and ingredient assertions in `assertions`. * Serializes validation output into both per-manifest `status` and global `extras:validation_status` when validation results are available. -A CrJSON consumer should tolerate additional fields and assertion labels not explicitly listed here, consistent with C2PA extensibility. +A CrJSON consumer should tolerate additional root-level fields (schema allows `additionalProperties` at root). Manifest objects do not allow additional properties per schema. Assertion labels and assertion payloads remain extensible per C2PA. diff --git a/export_schema/crJSON-schema.json b/export_schema/crJSON-schema.json index 8300605d2..b43b3b5dd 100644 --- a/export_schema/crJSON-schema.json +++ b/export_schema/crJSON-schema.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://contentcredentials.org/crjson/crJSON-schema.json", "title": "Content Credential JSON (CrJSON) Document Schema", "description": "JSON Schema for Content Credential JSON (CrJSON) documents. CrJSON does not include asset_info, content, or metadata.", @@ -59,177 +59,207 @@ "definitions": { "manifest": { "type": "object", + "required": [ + "label", + "assertions", + "signature", + "status" + ], + "oneOf": [ + { + "required": [ + "claim" + ] + }, + { + "required": [ + "claim.v2" + ] + } + ], "properties": { "label": { "type": "string", "description": "Manifest label or URN identifier" }, "claim": { - "$ref": "#/definitions/claim" + "$ref": "#/definitions/claimV1", + "description": "Claim map (v1) per C2PA CDDL claim-map; alternative to claim.v2. A manifest SHALL contain either claim or claim.v2." }, "claim.v2": { - "$ref": "#/definitions/claim" - }, - "assertions": { - "$ref": "#/definitions/assertions" - }, - "created_assertions": { - "type": "object", - "description": "Created assertions at manifest level (alternate location)", - "additionalProperties": true - }, - "generated_assertions": { - "type": "object", - "description": "Generated assertions", - "additionalProperties": true - }, - "redacted_assertions": { - "type": "object", - "description": "Redacted assertions", - "additionalProperties": true + "$ref": "#/definitions/claim", + "description": "Normalized claim (v2) per C2PA CDDL claim-map-v2; alternative to claim. A manifest SHALL contain either claim or claim.v2." }, "signature": { "$ref": "#/definitions/signature" }, - "claim_signature": { - "$ref": "#/definitions/signature" - }, - "claimSignature": { - "$ref": "#/definitions/signature" - }, "status": { "$ref": "#/definitions/status" } }, - "additionalProperties": true + "additionalProperties": false }, - "claim": { + "claimV1": { "type": "object", + "description": "Claim map (v1) per C2PA CDDL claim-map. Alternative to claim.v2; each manifest may contain either claim or claim.v2.", + "required": [ + "claim_generator", + "claim_generator_info", + "signature", + "assertions", + "dc:format", + "instanceID" + ], "properties": { - "version": { - "type": [ - "integer", - "null" - ], - "description": "Claim version number" - }, - "title": { + "claim_generator": { "type": "string", - "description": "Title of the claim" + "description": "User-Agent string for the claim generator that created the claim (RFC 7231 §5.5.3)" }, - "instanceID": { + "claim_generator_info": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/softwareAgent" + }, + "description": "Detailed information about the claim generator (one or more generator-info maps)" + }, + "signature": { "type": "string", - "description": "Unique identifier for this claim instance" + "description": "JUMBF URI reference to the signature of this claim" }, - "alg": { + "assertions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to assertions in this claim" + }, + "dc:format": { "type": "string", - "description": "Algorithm used for hashing (e.g., sha256, SHA-256)" + "description": "Media type of the asset" }, - "claim_generator": { + "instanceID": { "type": "string", - "description": "String identifier of the claim generator" + "description": "Uniquely identifies a specific version of an asset" }, - "claimGenerator": { + "dc:title": { "type": "string", - "description": "Alternate field name for claim generator" + "description": "Name of the asset (Dublin Core)" }, - "claim_generator_info": { - "description": "Detailed information about the claim generator", - "oneOf": [ - { - "type": "array", - "items": { - "$ref": "#/definitions/softwareAgent" - } - }, - { - "$ref": "#/definitions/softwareAgent" - } - ] + "redacted_assertions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "JUMBF URI references to redacted ingredient manifest assertions" }, - "signature": { + "alg": { "type": "string", - "description": "Reference to signature location (e.g., self#jumbf=...)" + "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" }, - "signatureRef": { + "alg_soft": { "type": "string", - "description": "Alternate field name for signature reference" + "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" }, - "default_algorithm": { + "metadata": { + "type": "object", + "description": "Additional information about the assertion", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "hashedUriMap": { + "type": "object", + "description": "Hashed URI reference (url and hash) per C2PA CDDL $hashed-uri-map", + "required": [ + "url", + "hash" + ], + "properties": { + "url": { "type": "string", - "description": "Default hashing algorithm" + "description": "JUMBF URI reference" }, - "defaultAlgorithm": { + "hash": { "type": "string", - "description": "Alternate field name for default algorithm" + "description": "Hash value (e.g. Base64-encoded)" }, - "dc:format": { + "alg": { "type": "string", - "description": "MIME type of the content" + "description": "Hash algorithm identifier; if absent, taken from enclosing structure" + } + }, + "additionalProperties": false + }, + "claim": { + "type": "object", + "description": "Claim (v2) per C2PA CDDL claim-map-v2.", + "required": [ + "instanceID", + "claim_generator_info", + "signature", + "created_assertions" + ], + "properties": { + "instanceID": { + "type": "string", + "description": "Uniquely identifies a specific version of an asset" }, - "dc:title": { + "claim_generator_info": { + "$ref": "#/definitions/softwareAgent", + "description": "The claim generator of this claim (single generator-info map)" + }, + "signature": { "type": "string", - "description": "Title of the claim (Dublin Core)" + "description": "JUMBF URI reference to the signature of this claim" }, "created_assertions": { "type": "array", + "minItems": 1, "items": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "hash": { - "type": "string" - } - }, - "required": [ - "url", - "hash" - ] - } + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to created assertions" }, "gathered_assertions": { "type": "array", "items": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "hash": { - "type": "string" - } - } - } + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to gathered assertions" + }, + "dc:title": { + "type": "string", + "description": "Name of the asset (Dublin Core)" }, "redacted_assertions": { "type": "array", "items": { - "type": "object" - } + "type": "string" + }, + "description": "JUMBF URI references to the assertions of ingredient manifests being redacted" }, - "signature_status": { + "alg": { "type": "string", - "description": "Status of the signature validation" + "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" }, - "assertion_status": { - "type": "object", - "description": "Status of each assertion by assertion label", - "additionalProperties": { - "type": "string" - } - }, - "content_status": { + "alg_soft": { "type": "string", - "description": "Status of content hash validation" + "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" }, - "trust_status": { + "specVersion": { "type": "string", - "description": "Trust validation status" + "description": "The version of the specification used to produce this claim (SemVer)" + }, + "metadata": { + "type": "object", + "description": "Additional information about the assertion (DEPRECATED)", + "additionalProperties": true } }, - "additionalProperties": true + "additionalProperties": false }, "assertions": { "type": "object", @@ -258,49 +288,10 @@ "c2pa.thumbnail.ingredient.jpeg": { "$ref": "#/definitions/thumbnailAssertion" }, - "c2pa.training-mining": { - "type": "object", - "description": "Training and mining assertion", - "properties": { - "isCAWG": { - "type": "boolean" - }, - "entries": { - "type": "object" - } - }, - "additionalProperties": true - }, "c2pa.soft-binding": { "type": "object", "description": "Soft binding assertion", "additionalProperties": true - }, - "cawg.metadata": { - "$ref": "#/definitions/cawgMetadataAssertion" - }, - "stds.schema-org.CreativeWork": { - "$ref": "#/definitions/creativeWorkAssertion" - }, - "com.adobe.generative-ai": { - "type": "object", - "description": "Adobe generative AI assertion", - "additionalProperties": true - }, - "adobe.crypto.addresses": { - "type": "object", - "description": "Adobe crypto addresses assertion", - "additionalProperties": true - }, - "jpt.mod-extent": { - "type": "object", - "description": "JPEG Trust modification extent assertion", - "additionalProperties": true - }, - "jpt.rights": { - "type": "object", - "description": "JPEG Trust rights assertion", - "additionalProperties": true } }, "patternProperties": { @@ -365,7 +356,7 @@ } } }, - "additionalProperties": true + "additionalProperties": false }, "actionsAssertion": { "type": "object", @@ -418,7 +409,7 @@ "required": [ "action" ], - "additionalProperties": true + "additionalProperties": false }, "softwareAgent": { "type": "object", @@ -436,7 +427,7 @@ "description": "Operating system the software runs on" } }, - "additionalProperties": true + "additionalProperties": false }, "signature": { "type": "object", @@ -552,7 +543,7 @@ } } }, - "additionalProperties": true + "additionalProperties": false }, "distinguishedName": { "type": "object", @@ -591,7 +582,7 @@ "description": "Organization identifier OID" } }, - "additionalProperties": true + "additionalProperties": false }, "status": { "type": "object", @@ -617,7 +608,7 @@ "description": "Trust validation status" } }, - "additionalProperties": true + "additionalProperties": false }, "ingredientAssertion": { "type": "object", @@ -716,7 +707,7 @@ "additionalProperties": true } }, - "additionalProperties": true + "additionalProperties": false }, "thumbnailAssertion": { "type": "object", @@ -731,50 +722,7 @@ "description": "MIME type of thumbnail" } }, - "additionalProperties": true - }, - "cawgMetadataAssertion": { - "type": "object", - "description": "Camera-Aware Working Group metadata assertion", - "properties": { - "entries": { - "type": "array", - "items": { - "type": "object", - "properties": { - "namespace": { - "type": "string" - }, - "name": { - "type": "string" - }, - "value": {} - } - } - }, - "namespacePrefixes": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "additionalProperties": true - }, - "creativeWorkAssertion": { - "type": "object", - "description": "Schema.org CreativeWork assertion", - "properties": { - "item": { - "type": "object", - "additionalProperties": true - }, - "creativeWork": { - "type": "object", - "additionalProperties": true - } - }, - "additionalProperties": true + "additionalProperties": false }, "hashBmffAssertion": { "type": "object", @@ -840,7 +788,7 @@ "type": "string" } }, - "additionalProperties": true + "additionalProperties": false }, "validationStatus": { "type": "object", @@ -918,7 +866,7 @@ } } }, - "additionalProperties": true + "additionalProperties": false } } } \ No newline at end of file diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index 9f5c6a016..f14fbd2d9 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -241,15 +241,17 @@ impl CrJsonReader { let claim_v2 = self.build_claim_v2(manifest, label)?; manifest_obj.insert("claim.v2".to_string(), claim_v2); - // Build claim_signature object - if let Some(claim_signature) = self.build_claim_signature(manifest)? { - manifest_obj.insert("claim_signature".to_string(), claim_signature); - } - - // Build status object - if let Some(status) = self.build_manifest_status(manifest, label)? { - manifest_obj.insert("status".to_string(), status); - } + // Build signature object (required per manifest schema; use empty object when no signature info) + let signature = self + .build_claim_signature(manifest)? + .unwrap_or_else(|| Value::Object(Map::new())); + manifest_obj.insert("signature".to_string(), signature); + + // Build status object (required; use empty object when no validation results) + let status = self + .build_manifest_status(manifest, label)? + .unwrap_or_else(|| Value::Object(Map::new())); + manifest_obj.insert("status".to_string(), status); labeled.push((label.clone(), Value::Object(manifest_obj))); } @@ -1132,10 +1134,12 @@ mod tests { // Verify manifests is an array assert!(json_value["manifests"].is_array()); - // Verify first manifest structure + // Verify first manifest structure (required: label, assertions, signature, status; oneOf: claim or claim.v2) if let Some(manifest) = json_value["manifests"].as_array().and_then(|a| a.first()) { assert!(manifest.get("label").is_some()); assert!(manifest.get("assertions").is_some()); + assert!(manifest.get("signature").is_some()); + assert!(manifest.get("status").is_some()); assert!(manifest.get("claim.v2").is_some()); // Verify assertions is an object (not array) @@ -1167,38 +1171,42 @@ mod tests { #[test] #[cfg(feature = "file_io")] fn test_claim_signature_decoding() -> Result<()> { - // Test that claim_signature is decoded with full certificate details + // Test that signature (manifest-level) is decoded with full certificate details let reader = CrJsonReader::from_file("tests/fixtures/CA.jpg")?; let json_value = reader.to_json_value()?; let manifests = json_value["manifests"].as_array().unwrap(); - - // Find a manifest with claim_signature - let manifest = manifests.iter().find(|m| m.get("claim_signature").is_some()); - assert!(manifest.is_some(), "Should have a manifest with claim_signature"); - - let claim_sig = &manifest.unwrap()["claim_signature"]; - + // Every manifest has required "signature"; find one with decoded certificate details + let manifest = manifests + .iter() + .find(|m| m.get("signature").and_then(|s| s.get("algorithm")).is_some()); + assert!( + manifest.is_some(), + "Should have a manifest with signature containing algorithm" + ); + + let sig = &manifest.unwrap()["signature"]; + // Verify algorithm is present - assert!(claim_sig.get("algorithm").is_some(), "claim_signature should have algorithm"); - + assert!(sig.get("algorithm").is_some(), "signature should have algorithm"); + // Verify certificate details are decoded (not just algorithm) // Should have serial_number, issuer, subject, and validity for X.509 certificates assert!( - claim_sig.get("serial_number").is_some(), - "claim_signature should have serial_number from decoded certificate" + sig.get("serial_number").is_some(), + "signature should have serial_number from decoded certificate" ); assert!( - claim_sig.get("issuer").is_some(), - "claim_signature should have issuer from decoded certificate" + sig.get("issuer").is_some(), + "signature should have issuer from decoded certificate" ); assert!( - claim_sig.get("subject").is_some(), - "claim_signature should have subject from decoded certificate" + sig.get("subject").is_some(), + "signature should have subject from decoded certificate" ); assert!( - claim_sig.get("validity").is_some(), - "claim_signature should have validity from decoded certificate" + sig.get("validity").is_some(), + "signature should have validity from decoded certificate" ); Ok(()) diff --git a/sdk/target/crjson_test_output/CA.jpg.json b/sdk/target/crjson_test_output/CA.jpg.json new file mode 100644 index 000000000..defce45e1 --- /dev/null +++ b/sdk/target/crjson_test_output/CA.jpg.json @@ -0,0 +1,227 @@ +{ + "@context": { + "@vocab": "https://contentcredentials.org/crjson", + "extras": "https://contentcredentials.org/crjson/extras" + }, + "manifests": [ + { + "label": "contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf", + "assertions": { + "stds.schema-org.CreativeWork": { + "@context": "http://schema.org/", + "@type": "CreativeWork", + "author": [ + { + "name": "John Doe", + "@type": "Person" + } + ] + }, + "c2pa.actions.v2": { + "actions": [ + { + "action": "c2pa.opened", + "parameters": { + "ingredient": { + "url": "self#jumbf=c2pa.assertions/c2pa.ingredient", + "hash": "5dNlxTKe4afGAicpJa1hF1R3mBZKE+Bl0xmh0McXuO4=" + } + } + }, + { + "action": "c2pa.color_adjustments", + "parameters": { + "name": "brightnesscontrast" + } + } + ] + }, + "c2pa.hash.data": { + "exclusions": [ + { + "start": 20, + "length": 117273 + } + ], + "name": "jumbf manifest", + "alg": "sha256", + "hash": "hrHkEQU/Ib6/1/hVlU4Ak9dMqTLnWqyM6I3pLGRYHHI=", + "pad": "AAAAAAAAAA==" + }, + "c2pa.ingredient": { + "title": "A.jpg", + "format": "image/jpeg", + "document_id": "xmp.did:813ee422-9736-4cdc-9be6-4e35ed8e41cb", + "instance_id": "xmp.iid:813ee422-9736-4cdc-9be6-4e35ed8e41cb", + "thumbnail": { + "format": "image/jpeg", + "identifier": "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpeg" + }, + "relationship": "parentOf" + } + }, + "claim.v2": { + "dc:title": "CA.jpg", + "instanceID": "xmp:iid:ba572347-db0e-4619-b6eb-d38e487da238", + "claim_generator": "make_test_images/0.33.1 c2pa-rs/0.33.1", + "claim_generator_info": [ + { + "name": "make_test_images", + "version": "0.33.1" + }, + { + "name": "c2pa-rs", + "version": "0.33.1" + } + ], + "alg": "SHA-256", + "signature": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", + "created_assertions": [ + { + "url": "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg", + "hash": "Tz+TZh0TJI1DhH2CB6ZMQ1CkEvfa5if6riBRAyqcOUk=" + }, + { + "url": "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpeg", + "hash": "GfgeiV34aTy+zThtyInZH+NP0E/NPWAUfwvJoG0ZDzM=" + }, + { + "url": "self#jumbf=c2pa.assertions/c2pa.ingredient", + "hash": "5dNlxTKe4afGAicpJa1hF1R3mBZKE+Bl0xmh0McXuO4=" + }, + { + "url": "self#jumbf=c2pa.assertions/stds.schema-org.CreativeWork", + "hash": "2uusTnr+Tm81nTxzeqy0kcHOPR88vqYfDbBDQ1hIuro=" + }, + { + "url": "self#jumbf=c2pa.assertions/c2pa.actions", + "hash": "AM+4ImIIQWpq2obgFY7h32+Gqkpuoi0aMJ7KqGdXKeY=" + }, + { + "url": "self#jumbf=c2pa.assertions/c2pa.hash.data", + "hash": "xqoYs91+LQbKWXVfiEkYaWpiq18cm+NUUVpgi2U2Pcw=" + } + ], + "gathered_assertions": [], + "redacted_assertions": [] + }, + "signature": { + "algorithm": "ps256", + "serial_number": "7e3e629adccfe7d99710135b5a48056972df8199", + "issuer": { + "C": "US", + "ST": "CA", + "L": "Somewhere", + "O": "C2PA Test Intermediate Root CA", + "OU": "FOR TESTING_ONLY", + "CN": "Intermediate CA" + }, + "subject": { + "C": "US", + "ST": "CA", + "L": "Somewhere", + "O": "C2PA Test Signing Cert", + "OU": "FOR TESTING_ONLY", + "CN": "C2PA Signer" + }, + "validity": { + "not_before": "2022-06-10T18:46:28+00:00", + "not_after": "2030-08-26T18:46:28+00:00" + } + }, + "status": { + "signature": "claimSignature.insideValidity", + "trust": "signingCredential.untrusted", + "content": "assertion.dataHash.match", + "assertion": { + "stds.schema-org.CreativeWork": "assertion.hashedURI.match" + } + } + } + ], + "extras:validation_status": { + "isValid": true, + "error": null, + "validationErrors": [ + { + "code": "signingCredential.untrusted", + "message": "signing certificate untrusted", + "severity": "error" + } + ], + "entries": [ + { + "code": "timeStamp.validated", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", + "explanation": "timestamp message digest matched: DigiCert Timestamp 2023", + "severity": "info" + }, + { + "code": "timeStamp.trusted", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", + "explanation": "timestamp cert trusted: DigiCert Timestamp 2023", + "severity": "info" + }, + { + "code": "claimSignature.insideValidity", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", + "explanation": "claim signature valid", + "severity": "info" + }, + { + "code": "claimSignature.validated", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", + "explanation": "claim signature valid", + "severity": "info" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.thumbnail.claim.jpeg", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg", + "severity": "info" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.thumbnail.ingredient.jpeg", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpeg", + "severity": "info" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.ingredient", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient", + "severity": "info" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/stds.schema-org.CreativeWork", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/stds.schema-org.CreativeWork", + "severity": "info" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.actions", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions", + "severity": "info" + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.hash.data", + "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data", + "severity": "info" + }, + { + "code": "assertion.dataHash.match", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.hash.data", + "explanation": "data hash valid", + "severity": "info" + }, + { + "code": "signingCredential.untrusted", + "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", + "explanation": "signing certificate untrusted", + "severity": "error" + } + ] + } +} \ No newline at end of file diff --git a/sdk/tests/crjson/schema_compliance.rs b/sdk/tests/crjson/schema_compliance.rs index de8100bab..d76a9846a 100644 --- a/sdk/tests/crjson/schema_compliance.rs +++ b/sdk/tests/crjson/schema_compliance.rs @@ -13,6 +13,18 @@ //! Schema compliance tests for crJSON format. //! These tests validate CrJSON output structure and alignment with `export_schema/crJSON-schema.json`. +//! +//! **Reviewing generated crJSON when tests run:** set the environment variable +//! `C2PA_WRITE_CRJSON=1` (or any value), then run the crjson tests. Generated crJSON +//! for the fixture (CA.jpg) will be written to `target/crjson_test_output/` under +//! the build target directory (e.g. `target/crjson_test_output/CA.jpg.json` when +//! running from the workspace root, or `sdk/target/crjson_test_output/CA.jpg.json` +//! when building from the sdk directory). Example: +//! +//! ```sh +//! C2PA_WRITE_CRJSON=1 cargo test crjson +//! # then open target/crjson_test_output/CA.jpg.json +//! ``` use c2pa::{CrJsonReader, Result}; use std::io::Cursor; @@ -22,9 +34,22 @@ const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/CA.jpg"); /// CrJSON schema (export_schema/crJSON-schema.json) - used to verify output structure. const CRJSON_SCHEMA: &str = include_str!("../../../export_schema/crJSON-schema.json"); +/// When C2PA_WRITE_CRJSON is set, write generated crJSON to target/crjson_test_output/ +/// so you can review the exact output. Called at the start of tests that build CrJsonReader. +fn maybe_write_crjson_output(name: &str, json: &str) { + if std::env::var("C2PA_WRITE_CRJSON").is_ok() { + let out_dir = std::path::PathBuf::from("target/crjson_test_output"); + let _ = std::fs::create_dir_all(&out_dir); + let path = out_dir.join(name); + let _ = std::fs::write(&path, json); + eprintln!("CrJSON written to {:?} (C2PA_WRITE_CRJSON=1)", path); + } +} + #[test] fn test_validation_status_schema_compliance() -> Result<()> { let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + maybe_write_crjson_output("CA.jpg.json", &reader.json()); let json_value = reader.to_json_value()?; @@ -216,28 +241,44 @@ fn test_manifests_array_schema_compliance() -> Result<()> { assert!(manifests.is_array(), "manifests should be an array"); - // Check each manifest + // Check each manifest (schema required: label, assertions, signature, status; oneOf: claim or claim.v2) for manifest in manifests.as_array().unwrap() { assert!(manifest.is_object(), "Each manifest should be an object"); let manifest_obj = manifest.as_object().unwrap(); - // Should have label - if let Some(label) = manifest_obj.get("label") { - assert!(label.is_string(), "label should be string"); - } + // Required: label + let label = manifest_obj.get("label").expect("manifest should have label"); + assert!(label.is_string(), "label should be string"); + + // Required: assertions (object, not array) + let assertions = manifest_obj + .get("assertions") + .expect("manifest should have assertions"); + assert!( + assertions.is_object(), + "assertions should be object, not array" + ); - // Should have claim.v2 + // Required: signature (object with optional algorithm, issuer, etc.) + let signature = manifest_obj + .get("signature") + .expect("manifest should have signature"); + assert!(signature.is_object(), "signature should be object"); + + // Required: status (object) + let status = manifest_obj.get("status").expect("manifest should have status"); + assert!(status.is_object(), "status should be object"); + + // oneOf: either claim or claim.v2 (implementation emits claim.v2) + let has_claim = manifest_obj.get("claim").is_some(); + let has_claim_v2 = manifest_obj.get("claim.v2").is_some(); + assert!( + has_claim || has_claim_v2, + "manifest should have either claim or claim.v2" + ); if let Some(claim) = manifest_obj.get("claim.v2") { assert!(claim.is_object(), "claim.v2 should be object"); } - - // Should have assertions as object (not array) - if let Some(assertions) = manifest_obj.get("assertions") { - assert!( - assertions.is_object(), - "assertions should be object, not array" - ); - } } Ok(()) From b6993264cd870f7752a2fa729a2508fedcf82ea9 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Sun, 15 Feb 2026 13:37:12 -0500 Subject: [PATCH 04/18] clean up the text a bit more --- docs/crjson-format.adoc | 45 +++++++---------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/docs/crjson-format.adoc b/docs/crjson-format.adoc index d0c74ea74..3ee20e79e 100644 --- a/docs/crjson-format.adoc +++ b/docs/crjson-format.adoc @@ -99,9 +99,9 @@ Each manifest object SHALL include the following properties (per schema: require * `label` (manifest label/URN) * `assertions` (object keyed by assertion label) -* `signature` (required; signature and credential details object) -* `status` (required; per-manifest validation results object) -* either `claim` (v1, per claimV1 / C2PA claim-map) or `claim.v2` (v2, per C2PA claim-map-v2; implementation emits `claim.v2`) +* `signature` (signature and credential details object) +* `status` (per-manifest validation results object) +* either `claim` (v1, per C2PA `claim-map`) or `claim.v2` (v2, per C2PA `claim-map-v2`) The manifest object does not allow additional properties (schema `additionalProperties: false`). @@ -139,31 +139,12 @@ When a manifest uses `claim` instead of `claim.v2`, it conforms to the C2PA CDDL Optional: `dc:title`, `redacted_assertions` (JUMBF URI strings), `alg`, `alg_soft`, `metadata`. -=== `assertions` - -`assertions` SHALL be an object keyed by assertion label. - -==== Included assertion sources - -CrJSON aggregates assertions from multiple C2PA sources: - -* Regular manifest assertions (`manifest.assertions()`) -* Hash assertions from claim store (`c2pa.hash.data`, `c2pa.hash.bmff`, `c2pa.hash.boxes`, including versioned variants) -* Ingredient assertions from `manifest.ingredients()` -* Gathered assertions not present in regular list (including binary/UUID payload cases) - -==== Labeling and instance rules +All `hash` values in hashed URI maps SHALL be Base64 strings. -* Base label is the assertion label string. -* For repeated hash/gathered assertion instances, suffix `_N` is used where `N = instance + 1`. -* For multiple ingredient assertions, suffix `__N` is used where `N` is 1-based index. +=== `assertions` -Examples: +`assertions` SHALL be an object keyed by assertion label. The contents of the object are the assertion payloads, as defined by the C2PA specification. -* `c2pa.hash.data` -* `c2pa.hash.bmff.v2` -* `c2pa.ingredient` -* `c2pa.ingredient.v3__2` ==== Binary normalization and hash encoding @@ -234,13 +215,11 @@ Global validation object includes: == Constraints and Current Implementation Limits -* CrJSON does not include `asset_info`, `content`, or `metadata`. The root object defines `@context` (required), `manifests` (required), and optionally validation-status properties (`extras:validation_status` or other namespaced keys per schema). The schema allows `additionalProperties` at root. -* Manifest objects do not allow additional properties (schema `additionalProperties: false` for manifest). * CrJSON is export-oriented; it is not the canonical source of cryptographic truth. Canonical validation remains bound to C2PA/JUMBF/COSE data structures per C2PA v2.3. == Minimal Example -The following example conforms to the CrJSON schema. Each manifest SHALL have `label`, `assertions`, `signature`, `status`, and either `claim` or `claim.v2`. For `claim.v2`, required fields are `instanceID`, `claim_generator_info`, `signature`, and `created_assertions` (at least one entry). +The following example conforms to the CrJSON schema. In many of the example values, a `...` placeholder is used for a value that is not relevant to the example. Also, any values which would be Base64-encoded are represented as ``. [source,json] ---- @@ -282,13 +261,3 @@ The following example conforms to the CrJSON schema. Each manifest SHALL have `l } ---- -== Conformance Guidance - -A CrJSON producer is conformant to this implementation profile when it: - -* Emits the root structure and field types described above. -* Emits Base64 strings for hash-bearing binary fields. -* Includes hash and ingredient assertions in `assertions`. -* Serializes validation output into both per-manifest `status` and global `extras:validation_status` when validation results are available. - -A CrJSON consumer should tolerate additional root-level fields (schema allows `additionalProperties` at root). Manifest objects do not allow additional properties per schema. Assertion labels and assertion payloads remain extensible per C2PA. From 7e9835522c886a941a3ac796fb22b9e27b19ac4d Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Sun, 15 Feb 2026 14:49:34 -0500 Subject: [PATCH 05/18] replaced current validation_status with validationResults --- docs/crjson-format.adoc | 51 ++++----- export_schema/crJSON-schema.json | 128 +++++++++------------- sdk/src/cr_json_reader.rs | 90 +--------------- sdk/tests/crjson/schema_compliance.rs | 147 +++++++++----------------- 4 files changed, 121 insertions(+), 295 deletions(-) diff --git a/docs/crjson-format.adoc b/docs/crjson-format.adoc index 3ee20e79e..d4da2bf83 100644 --- a/docs/crjson-format.adoc +++ b/docs/crjson-format.adoc @@ -23,7 +23,7 @@ The following C2PA concepts are directly represented: * C2PA assertions -> `manifests[].assertions` (object keyed by assertion label) * C2PA claim data -> `manifests[].claim.v2` * C2PA claim signature and credential details -> `manifests[].signature` -* C2PA validation results -> `manifests[].status` and `extras:validation_status` +* C2PA validation results -> `manifests[].status` and `validationResults` == Data Model Overview @@ -40,7 +40,7 @@ C2PA Asset/Manifest Store | |- signature (required) | |- status (required) | |- claim or claim.v2 (one required) - |- extras:validation_status (optional) + |- validationResults (optional) ---- == Serialization Requirements @@ -63,17 +63,9 @@ The following top-level properties are used: |REQUIRED |Array of manifest objects. Each item conforms to the manifest definition (required: label, assertions, signature, status; one of claim or claim.v2). -|`extras:validation_status` +|`validationResults` |OPTIONAL -|Overall validation report for the active manifest. - -|`validation_status` (no prefix) -|OPTIONAL -|Same structure as extras:validation_status; schema allows this alternate key. - -|Namespaced `*:validation_status` -|OPTIONAL -|Schema allows any key matching the pattern for validation status (e.g. extras:validation_status). +|Output of `validation_results()`: active manifest status codes and ingredient deltas (success, informational, failure arrays). |=== === `@context` @@ -197,21 +189,19 @@ Each manifest SHALL include `status`. It is derived from active-manifest validat When validation results are unavailable, `status` SHALL be present as an empty object `{}`. -=== `extras:validation_status` (global) +=== `validationResults` (global) + +Optional top-level property containing the output of the Reader's `validation_results()` method. When present, it includes: -Global validation object includes: +* `activeManifest` (optional): status codes for the active manifest: +** `success`: array of validation status entries (code, optional url, optional explanation) +** `informational`: array of validation status entries +** `failure`: array of validation status entries +* `ingredientDeltas` (optional): array of per-ingredient validation deltas: +** `ingredientAssertionURI`: JUMBF URI of the ingredient assertion +** `validationDeltas`: object with `success`, `informational`, and `failure` arrays (same entry shape as above) -* `isValid` (boolean) -* `error` (`null` or first failure explanation) -* `validationErrors[]` objects with: -** `code` -** `message` (optional) -** `severity` (`error`) -* `entries[]` objects with: -** `code` -** `url` (optional) -** `explanation` (optional) -** `severity` (`info`, `warning`, or `error`) +Each validation status entry is an object with `code` (required), and optionally `url` and `explanation`. == Constraints and Current Implementation Limits @@ -252,11 +242,12 @@ The following example conforms to the CrJSON schema. In many of the example valu } } ], - "extras:validation_status": { - "isValid": true, - "error": null, - "validationErrors": [], - "entries": [] + "validationResults": { + "activeManifest": { + "success": [], + "informational": [], + "failure": [] + } } } ---- diff --git a/export_schema/crJSON-schema.json b/export_schema/crJSON-schema.json index b43b3b5dd..051ee0450 100644 --- a/export_schema/crJSON-schema.json +++ b/export_schema/crJSON-schema.json @@ -40,19 +40,9 @@ "$ref": "#/definitions/manifest" } }, - "validation_status": { - "description": "Validation status without namespace prefix", - "$ref": "#/definitions/validationStatus" - }, - "extras:validation_status": { - "description": "Validation status with extras namespace prefix", - "$ref": "#/definitions/validationStatus" - } - }, - "patternProperties": { - "^[a-zA-Z0-9]+:validation_status$": { - "description": "Validation status with any namespace prefix", - "$ref": "#/definitions/validationStatus" + "validationResults": { + "description": "Validation results from validation_results() (active manifest and ingredient deltas)", + "$ref": "#/definitions/validationResults" } }, "additionalProperties": true, @@ -790,83 +780,63 @@ }, "additionalProperties": false }, - "validationStatus": { + "validationResults": { "type": "object", - "description": "Validation status information", + "description": "Output of validation_results(): status codes for active manifest and ingredient deltas", "properties": { - "isValid": { - "type": "boolean", - "description": "Overall validation result" - }, - "error": { - "type": [ - "string", - "null" - ], - "description": "Error message if validation failed" - }, - "code": { - "type": "string", - "description": "Error code" - }, - "explanation": { - "type": "string", - "description": "Explanation of the error" - }, - "uri": { - "type": "string", - "description": "URI where error occurred" + "activeManifest": { + "$ref": "#/definitions/statusCodes", + "description": "Validation status codes for the active manifest" }, - "validationErrors": { + "ingredientDeltas": { "type": "array", - "description": "List of validation errors", + "description": "Validation deltas per ingredient assertion", "items": { - "type": "object", - "description": "Validation error object with code, message, and severity", - "properties": { - "code": { - "type": "string", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "severity": { - "type": "string", - "description": "Error severity level", - "enum": [ - "error", - "warning", - "info" - ] - } - } + "$ref": "#/definitions/ingredientDeltaValidationResult" } + } + }, + "additionalProperties": false + }, + "statusCodes": { + "type": "object", + "description": "Success, informational, and failure validation status codes", + "properties": { + "success": { + "type": "array", + "items": { "$ref": "#/definitions/validationStatusEntry" } }, - "entries": { + "informational": { "type": "array", - "description": "List of validation entries", - "items": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "url": { - "type": "string" - }, - "severity": { - "type": "string" - }, - "explanation": { - "type": "string" - } - } - } + "items": { "$ref": "#/definitions/validationStatusEntry" } + }, + "failure": { + "type": "array", + "items": { "$ref": "#/definitions/validationStatusEntry" } } }, "additionalProperties": false + }, + "validationStatusEntry": { + "type": "object", + "description": "Single validation status (code, optional url and explanation)", + "properties": { + "code": { "type": "string", "description": "Validation status code" }, + "url": { "type": "string", "description": "JUMBF URI where status applies" }, + "explanation": { "type": "string", "description": "Human-readable explanation" } + }, + "required": [ "code" ], + "additionalProperties": false + }, + "ingredientDeltaValidationResult": { + "type": "object", + "description": "Validation deltas for one ingredient's manifest", + "properties": { + "ingredientAssertionURI": { "type": "string" }, + "validationDeltas": { "$ref": "#/definitions/statusCodes" } + }, + "required": [ "ingredientAssertionURI", "validationDeltas" ], + "additionalProperties": false } } } \ No newline at end of file diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index f14fbd2d9..f299dcfdb 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -207,9 +207,9 @@ impl CrJsonReader { let manifests_array = self.convert_manifests_to_array()?; result["manifests"] = manifests_array; - // Add extras:validation_status - if let Some(validation_status) = self.build_validation_status()? { - result["extras:validation_status"] = validation_status; + // Add validationResults (output of validation_results() method) + if let Some(validation_results) = self.inner.validation_results() { + result["validationResults"] = serde_json::to_value(validation_results)?; } Ok(result) @@ -985,90 +985,6 @@ impl CrJsonReader { .map(|s| s.code().to_string()) } - /// Build extras:validation_status from validation results - fn build_validation_status(&self) -> Result> { - let validation_results = match self.inner.validation_results() { - Some(results) => results, - None => return Ok(None), - }; - - let mut validation_status = Map::new(); - - // Determine overall validity - let is_valid = validation_results.validation_state() != ValidationState::Invalid; - validation_status.insert("isValid".to_string(), json!(is_valid)); - - // Add error field (null if valid, or first error message if not) - let error_message = if !is_valid { - if let Some(active_manifest) = validation_results.active_manifest() { - active_manifest - .failure - .first() - .and_then(|s| s.explanation()) - .map(|e| Value::String(e.to_string())) - .unwrap_or(Value::Null) - } else { - Value::Null - } - } else { - Value::Null - }; - validation_status.insert("error".to_string(), error_message); - - // Build validationErrors array from failures (as objects with code, message, severity) - let mut errors = Vec::new(); - if let Some(active_manifest) = validation_results.active_manifest() { - for status in active_manifest.failure.iter() { - let mut error_obj = Map::new(); - error_obj.insert("code".to_string(), json!(status.code())); - if let Some(explanation) = status.explanation() { - error_obj.insert("message".to_string(), json!(explanation)); - } - error_obj.insert("severity".to_string(), json!("error")); - errors.push(Value::Object(error_obj)); - } - } - validation_status.insert("validationErrors".to_string(), json!(errors)); - - // Build entries array from all validation statuses - let mut entries = Vec::new(); - - if let Some(active_manifest) = validation_results.active_manifest() { - // Add success entries - for status in active_manifest.success.iter() { - entries.push(self.build_validation_entry(status, "info")?); - } - - // Add informational entries - for status in active_manifest.informational.iter() { - entries.push(self.build_validation_entry(status, "warning")?); - } - - // Add failure entries - for status in active_manifest.failure.iter() { - entries.push(self.build_validation_entry(status, "error")?); - } - } - - validation_status.insert("entries".to_string(), json!(entries)); - - Ok(Some(Value::Object(validation_status))) - } - - /// Build a single validation entry for the entries array - fn build_validation_entry(&self, status: &ValidationStatus, severity: &str) -> Result { - let mut entry = Map::new(); - entry.insert("code".to_string(), json!(status.code())); - if let Some(url) = status.url() { - entry.insert("url".to_string(), json!(url)); - } - if let Some(explanation) = status.explanation() { - entry.insert("explanation".to_string(), json!(explanation)); - } - entry.insert("severity".to_string(), json!(severity)); - Ok(Value::Object(entry)) - } - /// Get a reference to the underlying Reader pub fn inner(&self) -> &Reader { &self.inner diff --git a/sdk/tests/crjson/schema_compliance.rs b/sdk/tests/crjson/schema_compliance.rs index d76a9846a..9c776e2b0 100644 --- a/sdk/tests/crjson/schema_compliance.rs +++ b/sdk/tests/crjson/schema_compliance.rs @@ -47,112 +47,61 @@ fn maybe_write_crjson_output(name: &str, json: &str) { } #[test] -fn test_validation_status_schema_compliance() -> Result<()> { +fn test_validation_results_schema_compliance() -> Result<()> { let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; maybe_write_crjson_output("CA.jpg.json", &reader.json()); let json_value = reader.to_json_value()?; - // Verify extras:validation_status exists - let validation_status = json_value - .get("extras:validation_status") - .expect("extras:validation_status should exist"); - - // Verify required fields - assert!( - validation_status.get("isValid").is_some(), - "isValid field should exist" - ); - assert!( - validation_status.get("isValid").unwrap().is_boolean(), - "isValid should be boolean" - ); - - // Verify error field (should be null or string) - let error = validation_status - .get("error") - .expect("error field should exist"); - assert!( - error.is_null() || error.is_string(), - "error should be null or string" - ); - - // Verify validationErrors is an array - let validation_errors = validation_status - .get("validationErrors") - .expect("validationErrors should exist"); + // Verify validationResults exists + let validation_results = json_value + .get("validationResults") + .expect("validationResults should exist"); assert!( - validation_errors.is_array(), - "validationErrors should be an array" + validation_results.is_object(), + "validationResults should be an object" ); - // Verify each validationError object has required fields - for error_obj in validation_errors.as_array().unwrap() { - assert!(error_obj.is_object(), "Each error should be an object"); - let obj = error_obj.as_object().unwrap(); - - // Required: code - assert!(obj.contains_key("code"), "Error should have code field"); - assert!( - obj.get("code").unwrap().is_string(), - "code should be string" - ); - - // Optional: message - if let Some(message) = obj.get("message") { - assert!(message.is_string(), "message should be string"); + let vr = validation_results.as_object().unwrap(); + + // Optional: activeManifest with success, informational, failure arrays + if let Some(active_manifest) = vr.get("activeManifest") { + assert!(active_manifest.is_object(), "activeManifest should be object"); + let am = active_manifest.as_object().unwrap(); + for key in &["success", "informational", "failure"] { + if let Some(arr) = am.get(*key) { + assert!(arr.is_array(), "{} should be array", key); + for entry in arr.as_array().unwrap() { + assert!(entry.is_object(), "Each entry should be object"); + let obj = entry.as_object().unwrap(); + assert!(obj.contains_key("code"), "Entry should have code"); + assert!(obj.get("code").unwrap().is_string(), "code should be string"); + if let Some(url) = obj.get("url") { + assert!(url.is_string(), "url should be string"); + } + if let Some(explanation) = obj.get("explanation") { + assert!(explanation.is_string(), "explanation should be string"); + } + } + } } - - // Required: severity - assert!( - obj.contains_key("severity"), - "Error should have severity field" - ); - let severity = obj.get("severity").unwrap().as_str().unwrap(); - assert!( - severity == "error" || severity == "warning" || severity == "info", - "severity should be error, warning, or info" - ); } - // Verify entries array - let entries = validation_status - .get("entries") - .expect("entries should exist"); - assert!(entries.is_array(), "entries should be an array"); - - // Verify each entry object has required fields - for entry_obj in entries.as_array().unwrap() { - assert!(entry_obj.is_object(), "Each entry should be an object"); - let obj = entry_obj.as_object().unwrap(); - - // Required: code - assert!(obj.contains_key("code"), "Entry should have code field"); - assert!( - obj.get("code").unwrap().is_string(), - "code should be string" - ); - - // Optional: url - if let Some(url) = obj.get("url") { - assert!(url.is_string(), "url should be string"); - } - - // Optional: explanation - if let Some(explanation) = obj.get("explanation") { - assert!(explanation.is_string(), "explanation should be string"); + // Optional: ingredientDeltas array + if let Some(deltas) = vr.get("ingredientDeltas") { + assert!(deltas.is_array(), "ingredientDeltas should be array"); + for item in deltas.as_array().unwrap() { + assert!(item.is_object(), "Each delta should be object"); + let obj = item.as_object().unwrap(); + assert!( + obj.contains_key("ingredientAssertionURI"), + "Delta should have ingredientAssertionURI" + ); + assert!( + obj.contains_key("validationDeltas"), + "Delta should have validationDeltas" + ); } - - // Required: severity - assert!( - obj.contains_key("severity"), - "Entry should have severity field" - ); - let severity = obj.get("severity").unwrap().as_str().unwrap(); - assert!( - severity == "error" || severity == "warning" || severity == "info", - "severity should be error, warning, or info" - ); } Ok(()) @@ -294,8 +243,8 @@ fn test_complete_schema_structure() -> Result<()> { assert!(json_value.get("@context").is_some(), "@context missing"); assert!(json_value.get("manifests").is_some(), "manifests missing"); assert!( - json_value.get("extras:validation_status").is_some(), - "extras:validation_status missing" + json_value.get("validationResults").is_some(), + "validationResults missing" ); // CrJSON does not include asset_info, content, or metadata @@ -306,7 +255,7 @@ fn test_complete_schema_structure() -> Result<()> { // Verify types assert!(json_value["@context"].is_object()); assert!(json_value["manifests"].is_array()); - assert!(json_value["extras:validation_status"].is_object()); + assert!(json_value["validationResults"].is_object()); Ok(()) } @@ -327,8 +276,8 @@ fn test_cr_json_schema_file_valid_and_matches_format() -> Result<()> { assert!(props.contains_key("@context"), "schema must define @context"); assert!(props.contains_key("manifests"), "schema must define manifests"); assert!( - props.contains_key("extras:validation_status") || props.contains_key("validation_status"), - "schema must define validation_status or extras:validation_status" + props.contains_key("validationResults"), + "schema must define validationResults" ); // CrJSON schema must NOT include removed sections From bf127e5107f0aa56e4494a0cf37860c62b1bb87f Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Fri, 20 Feb 2026 10:18:45 -0500 Subject: [PATCH 06/18] updated to latest schema and made sure that the code outputs compliant json --- cli/schemas/crJSON-schema.json | 858 ++++++++++++++++++++++++++ docs/crjson-format.adoc | 352 +++++++++-- export_schema/crJSON-schema.json | 842 ------------------------- sdk/src/claim_generator_info.rs | 5 +- sdk/src/cr_json_reader.rs | 167 ++++- sdk/src/validation_results.rs | 5 + sdk/tests/crjson/schema_compliance.rs | 70 ++- 7 files changed, 1363 insertions(+), 936 deletions(-) create mode 100644 cli/schemas/crJSON-schema.json delete mode 100644 export_schema/crJSON-schema.json diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json new file mode 100644 index 000000000..cec796cab --- /dev/null +++ b/cli/schemas/crJSON-schema.json @@ -0,0 +1,858 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contentcredentials.org/crjson/crJSON-schema.json", + "title": "Content Credential JSON (CrJSON) Document Schema", + "description": "JSON Schema for Content Credential JSON (CrJSON) documents. CrJSON does not include asset_info, content, or metadata.", + "type": "object", + "required": [ + "@context" + ], + "properties": { + "@context": { + "description": "JSON-LD context defining namespaces and vocabularies", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "minItems": 1 + }, + { + "type": "object", + "properties": { + "@vocab": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "manifests": { + "description": "Array of C2PA manifests embedded in the asset", + "type": "array", + "items": { + "$ref": "#/definitions/manifest" + } + }, + "validationResults": { + "description": "Validation results from validation (active manifest and ingredient deltas)", + "$ref": "#/definitions/validationResults" + } + }, + "additionalProperties": true, + "definitions": { + "manifest": { + "type": "object", + "required": [ + "label", + "assertions", + "signature", + "status" + ], + "oneOf": [ + { + "required": [ + "claim" + ] + }, + { + "required": [ + "claim.v2" + ] + } + ], + "properties": { + "label": { + "type": "string", + "description": "Manifest label or URN identifier" + }, + "assertions": { + "type": "object", + "description": "Assertions contained within the manifest, keyed by the assertion's label", + "additionalProperties": true + }, + "claim": { + "$ref": "#/definitions/claimV1", + "description": "Claim map (v1) per C2PA CDDL claim-map; alternative to claim.v2. A manifest SHALL contain either claim or claim.v2." + }, + "claim.v2": { + "$ref": "#/definitions/claim", + "description": "Normalized claim (v2) per C2PA CDDL claim-map-v2; alternative to claim. A manifest SHALL contain either claim or claim.v2." + }, + "signature": { + "$ref": "#/definitions/signature" + }, + "status": { + "$ref": "#/definitions/status" + } + }, + "additionalProperties": false + }, + "claimV1": { + "type": "object", + "description": "Claim map (v1) per C2PA CDDL claim-map. Alternative to claim.v2; each manifest may contain either claim or claim.v2.", + "required": [ + "claim_generator", + "claim_generator_info", + "signature", + "assertions", + "dc:format", + "instanceID" + ], + "properties": { + "claim_generator": { + "type": "string", + "description": "User-Agent string for the claim generator that created the claim (RFC 7231 §5.5.3)" + }, + "claim_generator_info": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/softwareAgent" + }, + "description": "Detailed information about the claim generator (one or more generator-info maps)" + }, + "signature": { + "type": "string", + "description": "JUMBF URI reference to the signature of this claim" + }, + "assertions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to assertions in this claim" + }, + "dc:format": { + "type": "string", + "description": "Media type of the asset" + }, + "instanceID": { + "type": "string", + "description": "Uniquely identifies a specific version of an asset" + }, + "dc:title": { + "type": "string", + "description": "Name of the asset (Dublin Core)" + }, + "redacted_assertions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "JUMBF URI references to redacted ingredient manifest assertions" + }, + "alg": { + "type": "string", + "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" + }, + "alg_soft": { + "type": "string", + "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" + }, + "metadata": { + "type": "object", + "description": "Additional information about the assertion", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "hashedUriMap": { + "type": "object", + "description": "Hashed URI reference (url and hash) per C2PA CDDL $hashed-uri-map", + "required": [ + "url", + "hash" + ], + "properties": { + "url": { + "type": "string", + "description": "JUMBF URI reference" + }, + "hash": { + "type": "string", + "description": "Hash value (e.g. Base64-encoded)" + }, + "alg": { + "type": "string", + "description": "Hash algorithm identifier; if absent, taken from enclosing structure" + } + }, + "additionalProperties": false + }, + "claim": { + "type": "object", + "description": "Claim (v2) per C2PA CDDL claim-map-v2.", + "required": [ + "instanceID", + "claim_generator_info", + "signature", + "created_assertions" + ], + "properties": { + "instanceID": { + "type": "string", + "description": "Uniquely identifies a specific version of an asset" + }, + "claim_generator_info": { + "$ref": "#/definitions/softwareAgent", + "description": "The claim generator of this claim (single generator-info map)" + }, + "signature": { + "type": "string", + "description": "JUMBF URI reference to the signature of this claim" + }, + "created_assertions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to created assertions" + }, + "gathered_assertions": { + "type": "array", + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to gathered assertions" + }, + "dc:title": { + "type": "string", + "description": "Name of the asset (Dublin Core)" + }, + "redacted_assertions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "JUMBF URI references to the assertions of ingredient manifests being redacted" + }, + "alg": { + "type": "string", + "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" + }, + "alg_soft": { + "type": "string", + "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" + }, + "specVersion": { + "type": "string", + "description": "The version of the specification used to produce this claim (SemVer)" + }, + "metadata": { + "type": "object", + "description": "Additional information about the assertion (DEPRECATED)", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "assertions": { + "type": "object", + "properties": { + "c2pa.hash.data": { + "$ref": "#/definitions/hashDataAssertion" + }, + "c2pa.hash.bmff.v2": { + "$ref": "#/definitions/hashBmffAssertion" + }, + "c2pa.actions": { + "$ref": "#/definitions/actionsAssertion" + }, + "c2pa.actions.v2": { + "$ref": "#/definitions/actionsAssertion" + }, + "c2pa.ingredient": { + "$ref": "#/definitions/ingredientAssertion" + }, + "c2pa.ingredient.v3": { + "$ref": "#/definitions/ingredientAssertion" + }, + "c2pa.thumbnail.claim.jpeg": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "c2pa.thumbnail.ingredient.jpeg": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "c2pa.soft-binding": { + "type": "object", + "description": "Soft binding assertion", + "additionalProperties": true + } + }, + "patternProperties": { + "^c2pa\\.thumbnail\\.ingredient": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "^c2pa\\.ingredient": { + "$ref": "#/definitions/ingredientAssertion" + } + }, + "additionalProperties": true + }, + "hashDataAssertion": { + "type": "object", + "properties": { + "alg": { + "type": "string", + "description": "Hash algorithm" + }, + "algorithm": { + "type": "string", + "description": "Alternative field name for hash algorithm" + }, + "hash": { + "type": "string", + "description": "Base64-encoded hash value" + }, + "pad": { + "type": "string", + "description": "Padding bytes" + }, + "paddingLength": { + "type": "integer", + "description": "Length of padding" + }, + "padding2Length": { + "type": "integer", + "description": "Length of secondary padding" + }, + "name": { + "type": "string", + "description": "Name of the hashed data" + }, + "exclusions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "description": "Starting byte position of exclusion" + }, + "length": { + "type": "integer", + "description": "Length of exclusion in bytes" + } + }, + "required": [ + "start", + "length" + ] + } + } + }, + "additionalProperties": false + }, + "actionsAssertion": { + "type": "object", + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/definitions/action" + } + } + }, + "required": [ + "actions" + ] + }, + "action": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Action type (e.g., c2pa.created, c2pa.edited, c2pa.converted)" + }, + "when": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the action occurred" + }, + "softwareAgent": { + "description": "Software that performed the action", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/softwareAgent" + } + ] + }, + "digitalSourceType": { + "type": "string", + "format": "uri", + "description": "IPTC digital source type URI" + }, + "parameters": { + "type": "object", + "description": "Additional parameters for the action", + "additionalProperties": true + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "softwareAgent": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the software" + }, + "version": { + "type": "string", + "description": "Version of the software" + }, + "icon": { + "$ref": "#/definitions/hashedUriMap", + "description": "Icon representing the software/hardware" + }, + "operating_system": { + "type": "string", + "description": "Operating system the software runs on" + } + }, + "required": ["name"], + "additionalProperties": true + }, + "signature": { + "type": "object", + "properties": { + "signature_algorithm": { + "type": "string", + "description": "Algorithm used for signing (e.g., SHA256withECDSA)" + }, + "algorithm": { + "description": "Signature algorithm - can be string or object", + "oneOf": [ + { + "type": "string", + "description": "Simple algorithm name (e.g., ES256, PS256)" + }, + { + "type": "object", + "description": "Detailed algorithm specification", + "properties": { + "alg": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "saltLength": { + "type": "integer" + } + } + }, + "coseIdentifier": { + "type": "integer" + } + } + } + ] + }, + "serial_number": { + "type": "string", + "description": "Certificate serial number" + }, + "certificate": { + "type": "object", + "description": "Certificate details", + "properties": { + "serial_number": { + "type": "string" + }, + "serialNumber": { + "type": "string" + }, + "issuer": { + "$ref": "#/definitions/distinguishedName" + }, + "subject": { + "$ref": "#/definitions/distinguishedName" + }, + "validity": { + "type": "object", + "properties": { + "not_before": { + "type": "string", + "format": "date-time" + }, + "notBefore": { + "type": "string", + "format": "date-time" + }, + "not_after": { + "type": "string", + "format": "date-time" + }, + "notAfter": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "subject": { + "$ref": "#/definitions/distinguishedName" + }, + "issuer": { + "$ref": "#/definitions/distinguishedName" + }, + "validity": { + "type": "object", + "properties": { + "not_before": { + "type": "string", + "format": "date-time", + "description": "Certificate validity start date" + }, + "notBefore": { + "type": "string", + "format": "date-time", + "description": "Alternate field name for validity start" + }, + "not_after": { + "type": "string", + "format": "date-time", + "description": "Certificate validity end date" + }, + "notAfter": { + "type": "string", + "format": "date-time", + "description": "Alternate field name for validity end" + } + } + } + }, + "additionalProperties": false + }, + "distinguishedName": { + "type": "object", + "description": "X.509 Distinguished Name components", + "properties": { + "C": { + "type": "string", + "description": "Country" + }, + "ST": { + "type": "string", + "description": "State or province" + }, + "L": { + "type": "string", + "description": "Locality" + }, + "O": { + "type": "string", + "description": "Organization" + }, + "OU": { + "type": "string", + "description": "Organizational unit" + }, + "CN": { + "type": "string", + "description": "Common name" + }, + "E": { + "type": "string", + "description": "Email address" + }, + "2.5.4.97": { + "type": "string", + "description": "Organization identifier OID" + } + }, + "additionalProperties": false + }, + "status": { + "type": "object", + "description": "Validation status information", + "properties": { + "signature": { + "type": "string", + "description": "Signature validation status" + }, + "assertion": { + "type": "object", + "description": "Status of each assertion", + "additionalProperties": { + "type": "string" + } + }, + "content": { + "type": "string", + "description": "Content validation status" + }, + "trust": { + "type": "string", + "description": "Trust validation status" + } + }, + "additionalProperties": false + }, + "ingredientAssertion": { + "type": "object", + "description": "Ingredient assertion for provenance chain", + "properties": { + "_version": { + "type": "integer", + "description": "Ingredient assertion version" + }, + "title": { + "type": "string", + "description": "Title of the ingredient" + }, + "format": { + "type": "string", + "description": "Format of the ingredient" + }, + "instanceID": { + "type": "string", + "description": "Instance ID of the ingredient" + }, + "documentID": { + "type": "string", + "description": "Document ID of the ingredient" + }, + "relationship": { + "type": "string", + "description": "Relationship type (e.g., parentOf, componentOf)" + }, + "labelSuffix": { + "type": "integer", + "description": "Label suffix number for multiple ingredients" + }, + "thumbnail": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "c2pa_manifest": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + }, + "activeManifest": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + }, + "claimSignature": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "algorithm": { + "type": "string" + } + } + }, + "validationResults": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "thumbnailAssertion": { + "type": "object", + "description": "Thumbnail assertion", + "properties": { + "thumbnailType": { + "type": "integer", + "description": "Thumbnail type (0=claim, 1=ingredient)" + }, + "mimeType": { + "type": "string", + "description": "MIME type of thumbnail" + } + }, + "additionalProperties": false + }, + "hashBmffAssertion": { + "type": "object", + "description": "BMFF (ISO Base Media File Format) hash assertion", + "properties": { + "_version": { + "type": "integer" + }, + "exclusions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "xpath": { + "type": "string" + }, + "length": { + "type": [ + "integer", + "null" + ] + }, + "data": { + "type": [ + "array", + "null" + ] + }, + "subset": { + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "object", + "null" + ] + }, + "flags": { + "type": [ + "object", + "null" + ] + }, + "exact": { + "type": [ + "boolean", + "null" + ] + } + } + } + }, + "algorithm": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "validationResults": { + "type": "object", + "description": "Output of validation_results(): status codes for active manifest and ingredient deltas", + "properties": { + "activeManifest": { + "$ref": "#/definitions/statusCodes", + "description": "Validation status codes for the active manifest" + }, + "ingredientDeltas": { + "type": "array", + "description": "Validation deltas per ingredient assertion", + "items": { + "$ref": "#/definitions/ingredientDeltaValidationResult" + } + } + }, + "required": ["activeManifest"], + "additionalProperties": false + }, + "statusCodes": { + "type": "object", + "description": "Success, informational, and failure validation status codes", + "properties": { + "success": { + "type": "array", + "items": { "$ref": "#/definitions/validationStatusEntry" } + }, + "informational": { + "type": "array", + "items": { "$ref": "#/definitions/validationStatusEntry" } + }, + "failure": { + "type": "array", + "items": { "$ref": "#/definitions/validationStatusEntry" } + } + }, + "required": [ + "success", + "informational", + "failure" + ], + "additionalProperties": false + }, + "validationStatusEntry": { + "type": "object", + "description": "Single validation status (code, optional url and explanation)", + "properties": { + "code": { "type": "string", "description": "Validation status code" }, + "url": { "type": "string", "description": "JUMBF URI where status applies" }, + "explanation": { "type": "string", "description": "Human-readable explanation" } + }, + "required": [ "code" ], + "additionalProperties": false + }, + "ingredientDeltaValidationResult": { + "type": "object", + "description": "Validation deltas for one ingredient's manifest", + "properties": { + "ingredientAssertionURI": { "type": "string", "description": "JUMBF URI to the ingredient assertion" }, + "validationDeltas": { "$ref": "#/definitions/statusCodes" } + }, + "required": [ "ingredientAssertionURI", "validationDeltas" ], + "additionalProperties": false + } + } + } \ No newline at end of file diff --git a/docs/crjson-format.adoc b/docs/crjson-format.adoc index d4da2bf83..787afb975 100644 --- a/docs/crjson-format.adoc +++ b/docs/crjson-format.adoc @@ -8,10 +8,13 @@ This document describes a JSON serialization for Content Credentials (aka a C2PA manifest store) known as the *Content Credential JSON* format (abbreviated *CrJSON*). It's purpose is to provide a JSON-based representation of a C2PA manifest store for profile evaluation, interoperability testing, and validation reporting. +// add some more stuff about what this is for & not for! +// lossy visualization, not for input, etc. + == Normative References * C2PA Technical Specification v2.3: https://c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html -* CrJSON JSON Schema: `export_schema/crJSON-schema.json` (in this repository) +* CrJSON JSON Schema: `cli/schemas/crJSON-schema.json` (in this repository) == Relationship to C2PA v2.3 @@ -37,9 +40,9 @@ C2PA Asset/Manifest Store |- manifests[] | |- label | |- assertions{...} + | |- claim or claim.v2 (one required) | |- signature (required) | |- status (required) - | |- claim or claim.v2 (one required) |- validationResults (optional) ---- @@ -47,7 +50,7 @@ C2PA Asset/Manifest Store === Root Object -A CrJSON document SHALL be a JSON object. +A CrJSON document shall be a JSON object. The following top-level properties are used: @@ -57,7 +60,7 @@ The following top-level properties are used: |`@context` |REQUIRED -|JSON-LD context. Implementation emits an object with `@vocab` and `extras`. Schema allows object or array of URI strings. +|JSON-LD context. Schema allows object or array of URI strings. |`manifests` |REQUIRED @@ -70,34 +73,85 @@ The following top-level properties are used: === `@context` -Implementation output: +CrJSON uses a standard https://www.w3.org/TR/json-ld11/[JSON-LD serialisation], and therefore shall contain a JSON-LD standard `@context` field whose value shall be either an object or an array listing terms (also known as namespaces) that are used in the CrJSON. In the case of an object, the terms shall be listed as key-value pairs, where the key is the term name and the value is the URI of the term. As described in clause 4.1.2 of JSON-LD, the `@vocab` key can be used for the default vocabulary, as shown in <>. In the case of an array, only the URI is required since it shall apply to all terms not otherwise identified by a specific term, as shown in <>. +[[json-ld-context-obj-example]] [source,json] +.Example of an object-based JSON-LD `@context` field ---- -"@context": { - "@vocab": "https://contentcredentials.org/crjson", - "extras": "https://contentcredentials.org/crjson/extras" +{ + "@context": { + "@vocab": "https://contentcredentials.org/crjson/", + "extras": "https://contentcredentials.org/crjson/extras/", + } } ---- -=== `manifests` +[[json-ld-context-array-example]] +[source,json] +.Example of an array-based JSON-LD `@context` field +---- +{ + "@context": [ + "https://contentcredentials.org/crjson/" + ] +} +---- + +Since CrJSON may contain values that are specific to a given workflow, it is important that each one of these terms shall be defined in the `@context` field. This allows the CrJSON document to be self-describing and non-conflicting ensuring that any consumer of the CrJSON can understand the meaning of each term. + +Since a `@context` element can appear inside of any object in JSON-LD, it is possible to have custom values, and their associated `@context` elements in multiple places throughout a single JSON-LD document, where the terms are localized to that specific object. -`manifests` SHALL be an array with at least one entry. Ordering rules: -1. Active manifest first. -2. Remaining manifests in (reverse) store order, most recent first. +=== Manifests -Each manifest object SHALL include the following properties (per schema: required `label`, `assertions`, `signature`, `status`; exactly one of `claim` or `claim.v2`): +A C2PA Manifest consists of, at least, a set of Assertions, Claims, and Claim Signatures that are bound together into a single entity. For all C2PA Manifests present in the C2PA Manifest Store, they shall be present in the `manifests` array. The order of the Manifests in the array shall match the reverse order that they are found in the Manifest Store, so that the active manifest is always first (i.e., `manifests[0]`). + +Each manifest object shall include the following properties (per schema: required `label`, `assertions`, `signature`, `status`; exactly one of `claim` or `claim.v2`): * `label` (manifest label/URN) * `assertions` (object keyed by assertion label) +* `claim` (v1, per C2PA `claim-map`) or `claim.v2` (v2, per C2PA `claim-map-v2`) * `signature` (signature and credential details object) * `status` (per-manifest validation results object) -* either `claim` (v1, per C2PA `claim-map`) or `claim.v2` (v2, per C2PA `claim-map-v2`) + +NOTE: The `label` field's value is a string that identifies the C2PA Manifest using the label of its JUMBF box, such as `urn:c2pa:2702fc84-a1ae-44d1-9825-dd86311e980b`. The manifest object does not allow additional properties (schema `additionalProperties: false`). -=== `claim.v2` (v2 claim, claim-map-v2) +=== Claims + +==== General +A Claim shall be serialised in the same manner as a <>. It shall be named based on the label of the claim box (e.g., `claim` or `claim.v2`). An example is found in <>. + +If there are no assertions listed in the claim's `gathered_assertions` or `redacted_assertions` fields, then the corresponding object shall be present, but its value shall be an empty object. + +[[json-ld-claim]] +[source,json] +.A JSON-LD serialised claim +---- +"claim.v2": { + "dc:title": "MIPAMS test image", + "instanceID": "uuid:7b57930e-2f23-47fc-affe-0400d70b738d", + "claim_generator": "MIPAMS GENERATOR 0.1", + "alg": "SHA-256", + "signature": "self#jumbf=c2pa.signature", + "created_assertions": [ + { + "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2", + "hash": "APqpWkPm91k98DD03sIQ+uYGspG+bxdy0c7+FMu8puU=" + }, + { + "url": "self#jumbf=c2pa.assertions/c2pa.hash.data", + "hash": "A8wNdhjiIyOOkGg8+GkJRSYJALG6orPQJRQKMFtq/rc=" + } + ], + "gathered_assertions": [], + "redacted_assertions": [] +}, +---- + +==== `claim.v2` (v2 claim) `claim.v2` conforms to the C2PA CDDL claim-map-v2. Required properties: @@ -116,9 +170,9 @@ Optional properties: * `specVersion` — specification version (SemVer) * `metadata` — (DEPRECATED) additional information -All `hash` values in hashed URI maps SHALL be Base64 strings. +All `hash` values in hashed URI maps shall be Base64 strings. -=== `claim` (v1 claim, claim-map) +=== `claim` (v1 claim) When a manifest uses `claim` instead of `claim.v2`, it conforms to the C2PA CDDL claim-map (claimV1). Required properties: @@ -131,11 +185,119 @@ When a manifest uses `claim` instead of `claim.v2`, it conforms to the C2PA CDDL Optional: `dc:title`, `redacted_assertions` (JUMBF URI strings), `alg`, `alg_soft`, `metadata`. -All `hash` values in hashed URI maps SHALL be Base64 strings. +All `hash` values in hashed URI maps shall be Base64 strings. + +=== Assertions + +[[assertions-fields]] +==== General + +Each manifest object shall contain an `assertions` field whose value is an object keyed by assertion label. Each individual assertion shall be represented as an object, where they key is the label of the Assertion and the value is an object containing the JSON-LD serialization derived from that Assertion. If it is not possible to derive information from an assertion, then the key shall be present in the assertions object, but its value shall be an empty object. + +==== JSON-LD serialised assertions + +For any Assertion which is serialised in the C2PA Manifest as JSON-LD, that exact same JSON-LD shall be used as the value for the assertion. For example, the `c2pa.metadata` assertion is expressed in XMP, that XMP data is serialised as JSON-LD. + +An example `c2pa.metadata` assertion is shown in <>. + +[[json-ld-assertion]] +[source,json] +.A JSON-LD serialised assertion +---- +"c2pa.metadata": { + "@context" : { + "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", + "photoshop" : "http://ns.adobe.com/photoshop/1.0/" + }, + "photoshop:DateCreated": "Aug 31, 2022", + "Iptc4xmpExt:LocationCreated": { + "Iptc4xmpExt:City": "Beijing, China" + } +} +---- + +NOTE: The various terms and context namespaces in <> are defined as part of <>. + +[[cbor_serialised_assertions]] +===== CBOR serialised assertions +For each Assertion, which is serialised in the C2PA Manifest as CBOR, the JSON-LD representation shall be described as the same key name in the CBOR map and its value type shall be determined by <>: + +[[table_cbor_json_mapping]] +.Mapping from CBOR to JSON-LD +[cols="2,1", options="header"] +|==== +|CBOR Type(s) +|JSON-LD Type + +| integer, unsigned integer +| unsigned number + +| negative integer +| integer + +| byte string +| string (Base64 encoded, <>) + +| UTF-8 string +| string + +| array +| array + +| map +| object + +| False, True +| boolean -=== `assertions` +| Null +| null -`assertions` SHALL be an object keyed by assertion label. The contents of the object are the assertion payloads, as defined by the C2PA specification. +| half-precision float, single-precision float, double-precision float +| float + +| date-time +| string (<>) + +|==== + +Since CBOR allows map keys of any type, whereas JSON-LD only allows strings as keys in object values, CBOR maps with keys other than UTF-8 strings shall have those keys converted to UTF-8 strings. An example of a CBOR serialised assertion is shown in <>, and its equivalent JSON-LD representation is shown in <>. + +[[actions-cbordiag]] +[source] +.CBOR Diagnostics for an actions.v2 assertion +---- +"c2pa.actions.v2": { + "actions": [ + { + "action": "c2pa.cropped", + "when": 0("2020-02-11T09:30:00Z") + }, + { + "action": "c2pa.filtered", + "when": 0("2020-02-11T09:00:00Z") + } + ] +} +---- + +[[actions-json]] +[source,json] +.JSON-LD representation of <> +---- +"c2pa.actions.v2": { + "actions": [ + { + "action": "c2pa.cropped", + "when": "2020-02-11T09:30:00Z" + }, + { + "action": "c2pa.filtered", + "when": "2020-02-11T09:00:00Z" + } + ] +} +---- ==== Binary normalization and hash encoding @@ -152,9 +314,9 @@ If fields are serialized as integer arrays, they are converted to Base64 strings This rule applies recursively to nested objects/arrays. -==== Gathered binary assertion representation +==== Binary assertion representation -For gathered binary/UUID assertions, CrJSON emits a reference form: +For binary formatted assertions (e.g., thumbnails), CrJSON emits a reference form: [source,json] ---- @@ -165,9 +327,14 @@ For gathered binary/UUID assertions, CrJSON emits a reference form: } ---- -=== `signature` (per manifest) +=== Signature + +// https://github.com/jcrowgey/x5092json is useful information for this section +// also see https://darutk.medium.com/illustrated-x-509-certificate-84aece2c5c2e -Each manifest SHALL include `signature`. When signature information is available, it includes: +The JSON-LD representation of the X.509 certificate (as defined in <>) from the claim signature is based on a logical mapping of its ASN.1 serialisation as defined in RFC 5280 into a JSON-LD serialized object whose key is `signature`. An example certificate is in <>. Additional mappings, such as the mapping of the distinguished name to JSON-LD should also be done in the most logical fashion possible. + +When signature information is available, it should include: * `algorithm` * `serial_number` @@ -176,32 +343,133 @@ Each manifest SHALL include `signature`. When signature information is available * `validity.not_before` * `validity.not_after` -The implementation parses the first certificate in the chain and serializes times as RFC 3339 strings. When signature information is unavailable, `signature` SHALL be present as an empty object `{}`. +NOTE: An X.509 certificate can contain all sorts of information, and implementations may choose to include additional information in their JSON-LD representation. -=== `status` (per manifest) +Times shall be represented as RFC 3339 strings. When signature information is unavailable, `signature` shall be present as an empty object `{}`. -Each manifest SHALL include `status`. It is derived from active-manifest validation results and may include: +The value of the `signature_algorithm` field shall be one of the strings defined in <>, such as "ES256" or "Ed25519", or "Unknown" if it is not one of the defined values. -* `signature`: first code prefixed by `claimSignature` -* `trust`: preferred signing credential code (`trusted`, else `invalid`, `untrusted`, `expired`, then fallback) -* `content`: first code prefixed by `assertion.dataHash` -* `assertion`: map of assertion label -> validation code (when found) +[[json-ld-x509]] +[source,json] +.Representation of an X.509 Certificate +---- +"signature": { + "signature_algorithm": "ES256", + "subject": { + "ST": "CA", + "CN": "C2PA Signer", + "C": "US", + "L": "Somewhere", + "OU": "FOR TESTING_ONLY", + "O": "C2PA Test Signing Cert" + }, + "issuer": { + "ST": "CA", + "CN": "Intermediate CA", + "C": "US", + "L": "Somewhere", + "OU": "FOR TESTING_ONLY", + "O": "C2PA Test Intermediate Root CA" + }, + "validity": { + "not_after": "2030-08-26T18:46:40Z", + "not_before": "2022-06-10T18:46:40Z" + } +} +---- + +=== Status + +The `manifest` object shall contain a `status` field whose value is an object containing the trust and/or validity status of the various parts of the C2PA Manifest. The status object shall contain the following fields: + +`signature`:: A field whose value is a single <> determined from <>. + +`assertions`:: A field whose value is an object, where each field is the label of an assertion and its value is the <> determined from validation. All assertions in the Claim shall be listed in this object, whether they are in the `created_assertions` or `gathered_assertions` fields of the Claim. + +`trust`:: After the signing certificate is checked against one or more Trust Lists, this field shall contain a single <> that specifies the status of the signing certificate against the Trust Lists. There may also be `trust_list` field that contains a URI that identifies the Trust List used to validate the signing certificate. + +For the active manifest, the following additional fields shall also be present: + +`content`:: A field whose value is a single <> determined from validation of the content bindings. + +NOTE: This is present only in the active manifest, since that is the only one that can be used to check the validity of the content bindings. This applies to both standard and update manifests. + +An example of a status object is shown in <>. + +[[status-example]] +[source,json] +.Example status object +---- +"status": { + "signature": "claimSignature.validated", + "assertion": { + "c2pa.actions.v2": "assertion.hashedURI.match", + "c2pa.hash.data": "assertion.dataHash.match" + }, + "content": "assertion.dataHash.match", + "trust": "signingCredential.trusted", + "trust_list": "https://example.com/trustlists/c2pa-trust-list.json" +} +---- + +When validation results are unavailable, `status` shall be present as an empty object `{}`. -When validation results are unavailable, `status` SHALL be present as an empty object `{}`. +=== Validation Results -=== `validationResults` (global) +The `validationResults` object is modelled on the `validation-results-map` data structure used to store the results of ingredient validation in the ingredient assertion. It is not a required property of crJSON (as it may be used outside of a validation workflow), but when present, it will always include the set of validation status codes for the active manifest. If the active manifest refers to one or more ingredients, there shall also be an `ingredientDeltas` field present that contains the list of validation deltas (if any). -Optional top-level property containing the output of the Reader's `validation_results()` method. When present, it includes: +Each validation status entry is an object with a `code` (required), and optionally `url` and `explanation` fields. -* `activeManifest` (optional): status codes for the active manifest: -** `success`: array of validation status entries (code, optional url, optional explanation) -** `informational`: array of validation status entries -** `failure`: array of validation status entries -* `ingredientDeltas` (optional): array of per-ingredient validation deltas: -** `ingredientAssertionURI`: JUMBF URI of the ingredient assertion -** `validationDeltas`: object with `success`, `informational`, and `failure` arrays (same entry shape as above) +[[results-example]] +[source,json] +---- +"validationResults": { + "activeManifest": { + "success": [], + "informational": [ + { + "code": "claimSignature.insideValidity", + "url": "self#jumbf=/c2pa/urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/c2pa.signature", + "explanation": "claim signature valid", + }, + { + "code": "claimSignature.validated", + "url": "self#jumbf=/c2pa/urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/c2pa.signature", + "explanation": "claim signature valid", + }, + { + "code": "assertion.hashedURI.match", + "url": "self#jumbf=/c2pa/urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/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:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/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:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/c2pa.assertions/c2pa.hash.data", + "explanation": "data hash valid", + }, + + ], + "failure": [ + { + "code": "signingCredential.untrusted", + "url": "self#jumbf=/c2pa/urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/c2pa.signature", + "explanation": "signing certificate untrusted", + }, + { + "code": "assertion.action.malformed", + "url": "urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268", + "explanation": "first action must be created or opened", + } + ] + } +} -Each validation status entry is an object with `code` (required), and optionally `url` and `explanation`. +---- == Constraints and Current Implementation Limits diff --git a/export_schema/crJSON-schema.json b/export_schema/crJSON-schema.json deleted file mode 100644 index 051ee0450..000000000 --- a/export_schema/crJSON-schema.json +++ /dev/null @@ -1,842 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://contentcredentials.org/crjson/crJSON-schema.json", - "title": "Content Credential JSON (CrJSON) Document Schema", - "description": "JSON Schema for Content Credential JSON (CrJSON) documents. CrJSON does not include asset_info, content, or metadata.", - "type": "object", - "required": [ - "@context" - ], - "properties": { - "@context": { - "description": "JSON-LD context defining namespaces and vocabularies", - "oneOf": [ - { - "type": "array", - "items": { - "type": "string", - "format": "uri" - }, - "minItems": 1 - }, - { - "type": "object", - "properties": { - "@vocab": { - "type": "string", - "format": "uri" - } - }, - "additionalProperties": { - "type": "string" - } - } - ] - }, - "manifests": { - "description": "Array of C2PA manifests embedded in the asset", - "type": "array", - "items": { - "$ref": "#/definitions/manifest" - } - }, - "validationResults": { - "description": "Validation results from validation_results() (active manifest and ingredient deltas)", - "$ref": "#/definitions/validationResults" - } - }, - "additionalProperties": true, - "definitions": { - "manifest": { - "type": "object", - "required": [ - "label", - "assertions", - "signature", - "status" - ], - "oneOf": [ - { - "required": [ - "claim" - ] - }, - { - "required": [ - "claim.v2" - ] - } - ], - "properties": { - "label": { - "type": "string", - "description": "Manifest label or URN identifier" - }, - "claim": { - "$ref": "#/definitions/claimV1", - "description": "Claim map (v1) per C2PA CDDL claim-map; alternative to claim.v2. A manifest SHALL contain either claim or claim.v2." - }, - "claim.v2": { - "$ref": "#/definitions/claim", - "description": "Normalized claim (v2) per C2PA CDDL claim-map-v2; alternative to claim. A manifest SHALL contain either claim or claim.v2." - }, - "signature": { - "$ref": "#/definitions/signature" - }, - "status": { - "$ref": "#/definitions/status" - } - }, - "additionalProperties": false - }, - "claimV1": { - "type": "object", - "description": "Claim map (v1) per C2PA CDDL claim-map. Alternative to claim.v2; each manifest may contain either claim or claim.v2.", - "required": [ - "claim_generator", - "claim_generator_info", - "signature", - "assertions", - "dc:format", - "instanceID" - ], - "properties": { - "claim_generator": { - "type": "string", - "description": "User-Agent string for the claim generator that created the claim (RFC 7231 §5.5.3)" - }, - "claim_generator_info": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/softwareAgent" - }, - "description": "Detailed information about the claim generator (one or more generator-info maps)" - }, - "signature": { - "type": "string", - "description": "JUMBF URI reference to the signature of this claim" - }, - "assertions": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/hashedUriMap" - }, - "description": "Hashed URI references to assertions in this claim" - }, - "dc:format": { - "type": "string", - "description": "Media type of the asset" - }, - "instanceID": { - "type": "string", - "description": "Uniquely identifies a specific version of an asset" - }, - "dc:title": { - "type": "string", - "description": "Name of the asset (Dublin Core)" - }, - "redacted_assertions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "JUMBF URI references to redacted ingredient manifest assertions" - }, - "alg": { - "type": "string", - "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" - }, - "alg_soft": { - "type": "string", - "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" - }, - "metadata": { - "type": "object", - "description": "Additional information about the assertion", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - "hashedUriMap": { - "type": "object", - "description": "Hashed URI reference (url and hash) per C2PA CDDL $hashed-uri-map", - "required": [ - "url", - "hash" - ], - "properties": { - "url": { - "type": "string", - "description": "JUMBF URI reference" - }, - "hash": { - "type": "string", - "description": "Hash value (e.g. Base64-encoded)" - }, - "alg": { - "type": "string", - "description": "Hash algorithm identifier; if absent, taken from enclosing structure" - } - }, - "additionalProperties": false - }, - "claim": { - "type": "object", - "description": "Claim (v2) per C2PA CDDL claim-map-v2.", - "required": [ - "instanceID", - "claim_generator_info", - "signature", - "created_assertions" - ], - "properties": { - "instanceID": { - "type": "string", - "description": "Uniquely identifies a specific version of an asset" - }, - "claim_generator_info": { - "$ref": "#/definitions/softwareAgent", - "description": "The claim generator of this claim (single generator-info map)" - }, - "signature": { - "type": "string", - "description": "JUMBF URI reference to the signature of this claim" - }, - "created_assertions": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/hashedUriMap" - }, - "description": "Hashed URI references to created assertions" - }, - "gathered_assertions": { - "type": "array", - "items": { - "$ref": "#/definitions/hashedUriMap" - }, - "description": "Hashed URI references to gathered assertions" - }, - "dc:title": { - "type": "string", - "description": "Name of the asset (Dublin Core)" - }, - "redacted_assertions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "JUMBF URI references to the assertions of ingredient manifests being redacted" - }, - "alg": { - "type": "string", - "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" - }, - "alg_soft": { - "type": "string", - "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" - }, - "specVersion": { - "type": "string", - "description": "The version of the specification used to produce this claim (SemVer)" - }, - "metadata": { - "type": "object", - "description": "Additional information about the assertion (DEPRECATED)", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - "assertions": { - "type": "object", - "properties": { - "c2pa.hash.data": { - "$ref": "#/definitions/hashDataAssertion" - }, - "c2pa.hash.bmff.v2": { - "$ref": "#/definitions/hashBmffAssertion" - }, - "c2pa.actions": { - "$ref": "#/definitions/actionsAssertion" - }, - "c2pa.actions.v2": { - "$ref": "#/definitions/actionsAssertion" - }, - "c2pa.ingredient": { - "$ref": "#/definitions/ingredientAssertion" - }, - "c2pa.ingredient.v3": { - "$ref": "#/definitions/ingredientAssertion" - }, - "c2pa.thumbnail.claim.jpeg": { - "$ref": "#/definitions/thumbnailAssertion" - }, - "c2pa.thumbnail.ingredient.jpeg": { - "$ref": "#/definitions/thumbnailAssertion" - }, - "c2pa.soft-binding": { - "type": "object", - "description": "Soft binding assertion", - "additionalProperties": true - } - }, - "patternProperties": { - "^c2pa\\.thumbnail\\.ingredient": { - "$ref": "#/definitions/thumbnailAssertion" - }, - "^c2pa\\.ingredient": { - "$ref": "#/definitions/ingredientAssertion" - } - }, - "additionalProperties": true - }, - "hashDataAssertion": { - "type": "object", - "properties": { - "alg": { - "type": "string", - "description": "Hash algorithm" - }, - "algorithm": { - "type": "string", - "description": "Alternative field name for hash algorithm" - }, - "hash": { - "type": "string", - "description": "Base64-encoded hash value" - }, - "pad": { - "type": "string", - "description": "Padding bytes" - }, - "paddingLength": { - "type": "integer", - "description": "Length of padding" - }, - "padding2Length": { - "type": "integer", - "description": "Length of secondary padding" - }, - "name": { - "type": "string", - "description": "Name of the hashed data" - }, - "exclusions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "description": "Starting byte position of exclusion" - }, - "length": { - "type": "integer", - "description": "Length of exclusion in bytes" - } - }, - "required": [ - "start", - "length" - ] - } - } - }, - "additionalProperties": false - }, - "actionsAssertion": { - "type": "object", - "properties": { - "actions": { - "type": "array", - "items": { - "$ref": "#/definitions/action" - } - } - }, - "required": [ - "actions" - ] - }, - "action": { - "type": "object", - "properties": { - "action": { - "type": "string", - "description": "Action type (e.g., c2pa.created, c2pa.edited, c2pa.converted)" - }, - "when": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the action occurred" - }, - "softwareAgent": { - "description": "Software that performed the action", - "oneOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/softwareAgent" - } - ] - }, - "digitalSourceType": { - "type": "string", - "format": "uri", - "description": "IPTC digital source type URI" - }, - "parameters": { - "type": "object", - "description": "Additional parameters for the action", - "additionalProperties": true - } - }, - "required": [ - "action" - ], - "additionalProperties": false - }, - "softwareAgent": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the software" - }, - "version": { - "type": "string", - "description": "Version of the software" - }, - "schema.org.SoftwareApplication.operatingSystem": { - "type": "string", - "description": "Operating system the software runs on" - } - }, - "additionalProperties": false - }, - "signature": { - "type": "object", - "properties": { - "signature_algorithm": { - "type": "string", - "description": "Algorithm used for signing (e.g., SHA256withECDSA)" - }, - "algorithm": { - "description": "Signature algorithm - can be string or object", - "oneOf": [ - { - "type": "string", - "description": "Simple algorithm name (e.g., ES256, PS256)" - }, - { - "type": "object", - "description": "Detailed algorithm specification", - "properties": { - "alg": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "saltLength": { - "type": "integer" - } - } - }, - "coseIdentifier": { - "type": "integer" - } - } - } - ] - }, - "serial_number": { - "type": "string", - "description": "Certificate serial number" - }, - "certificate": { - "type": "object", - "description": "Certificate details", - "properties": { - "serial_number": { - "type": "string" - }, - "serialNumber": { - "type": "string" - }, - "issuer": { - "$ref": "#/definitions/distinguishedName" - }, - "subject": { - "$ref": "#/definitions/distinguishedName" - }, - "validity": { - "type": "object", - "properties": { - "not_before": { - "type": "string", - "format": "date-time" - }, - "notBefore": { - "type": "string", - "format": "date-time" - }, - "not_after": { - "type": "string", - "format": "date-time" - }, - "notAfter": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "subject": { - "$ref": "#/definitions/distinguishedName" - }, - "issuer": { - "$ref": "#/definitions/distinguishedName" - }, - "validity": { - "type": "object", - "properties": { - "not_before": { - "type": "string", - "format": "date-time", - "description": "Certificate validity start date" - }, - "notBefore": { - "type": "string", - "format": "date-time", - "description": "Alternate field name for validity start" - }, - "not_after": { - "type": "string", - "format": "date-time", - "description": "Certificate validity end date" - }, - "notAfter": { - "type": "string", - "format": "date-time", - "description": "Alternate field name for validity end" - } - } - } - }, - "additionalProperties": false - }, - "distinguishedName": { - "type": "object", - "description": "X.509 Distinguished Name components", - "properties": { - "C": { - "type": "string", - "description": "Country" - }, - "ST": { - "type": "string", - "description": "State or province" - }, - "L": { - "type": "string", - "description": "Locality" - }, - "O": { - "type": "string", - "description": "Organization" - }, - "OU": { - "type": "string", - "description": "Organizational unit" - }, - "CN": { - "type": "string", - "description": "Common name" - }, - "E": { - "type": "string", - "description": "Email address" - }, - "2.5.4.97": { - "type": "string", - "description": "Organization identifier OID" - } - }, - "additionalProperties": false - }, - "status": { - "type": "object", - "description": "Validation status information", - "properties": { - "signature": { - "type": "string", - "description": "Signature validation status" - }, - "assertion": { - "type": "object", - "description": "Status of each assertion", - "additionalProperties": { - "type": "string" - } - }, - "content": { - "type": "string", - "description": "Content validation status" - }, - "trust": { - "type": "string", - "description": "Trust validation status" - } - }, - "additionalProperties": false - }, - "ingredientAssertion": { - "type": "object", - "description": "Ingredient assertion for provenance chain", - "properties": { - "_version": { - "type": "integer", - "description": "Ingredient assertion version" - }, - "title": { - "type": "string", - "description": "Title of the ingredient" - }, - "format": { - "type": "string", - "description": "Format of the ingredient" - }, - "instanceID": { - "type": "string", - "description": "Instance ID of the ingredient" - }, - "documentID": { - "type": "string", - "description": "Document ID of the ingredient" - }, - "relationship": { - "type": "string", - "description": "Relationship type (e.g., parentOf, componentOf)" - }, - "labelSuffix": { - "type": "integer", - "description": "Label suffix number for multiple ingredients" - }, - "thumbnail": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "algorithm": { - "type": "string" - } - } - }, - "metadata": { - "type": "object", - "additionalProperties": true - }, - "c2pa_manifest": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "algorithm": { - "type": "string" - } - } - }, - "activeManifest": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "algorithm": { - "type": "string" - } - } - }, - "claimSignature": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "algorithm": { - "type": "string" - } - } - }, - "validationResults": { - "type": "object", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - "thumbnailAssertion": { - "type": "object", - "description": "Thumbnail assertion", - "properties": { - "thumbnailType": { - "type": "integer", - "description": "Thumbnail type (0=claim, 1=ingredient)" - }, - "mimeType": { - "type": "string", - "description": "MIME type of thumbnail" - } - }, - "additionalProperties": false - }, - "hashBmffAssertion": { - "type": "object", - "description": "BMFF (ISO Base Media File Format) hash assertion", - "properties": { - "_version": { - "type": "integer" - }, - "exclusions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "xpath": { - "type": "string" - }, - "length": { - "type": [ - "integer", - "null" - ] - }, - "data": { - "type": [ - "array", - "null" - ] - }, - "subset": { - "type": [ - "array", - "null" - ] - }, - "version": { - "type": [ - "object", - "null" - ] - }, - "flags": { - "type": [ - "object", - "null" - ] - }, - "exact": { - "type": [ - "boolean", - "null" - ] - } - } - } - }, - "algorithm": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "additionalProperties": false - }, - "validationResults": { - "type": "object", - "description": "Output of validation_results(): status codes for active manifest and ingredient deltas", - "properties": { - "activeManifest": { - "$ref": "#/definitions/statusCodes", - "description": "Validation status codes for the active manifest" - }, - "ingredientDeltas": { - "type": "array", - "description": "Validation deltas per ingredient assertion", - "items": { - "$ref": "#/definitions/ingredientDeltaValidationResult" - } - } - }, - "additionalProperties": false - }, - "statusCodes": { - "type": "object", - "description": "Success, informational, and failure validation status codes", - "properties": { - "success": { - "type": "array", - "items": { "$ref": "#/definitions/validationStatusEntry" } - }, - "informational": { - "type": "array", - "items": { "$ref": "#/definitions/validationStatusEntry" } - }, - "failure": { - "type": "array", - "items": { "$ref": "#/definitions/validationStatusEntry" } - } - }, - "additionalProperties": false - }, - "validationStatusEntry": { - "type": "object", - "description": "Single validation status (code, optional url and explanation)", - "properties": { - "code": { "type": "string", "description": "Validation status code" }, - "url": { "type": "string", "description": "JUMBF URI where status applies" }, - "explanation": { "type": "string", "description": "Human-readable explanation" } - }, - "required": [ "code" ], - "additionalProperties": false - }, - "ingredientDeltaValidationResult": { - "type": "object", - "description": "Validation deltas for one ingredient's manifest", - "properties": { - "ingredientAssertionURI": { "type": "string" }, - "validationDeltas": { "$ref": "#/definitions/statusCodes" } - }, - "required": [ "ingredientAssertionURI", "validationDeltas" ], - "additionalProperties": false - } - } -} \ No newline at end of file diff --git a/sdk/src/claim_generator_info.rs b/sdk/src/claim_generator_info.rs index be5e459d2..c466475cc 100644 --- a/sdk/src/claim_generator_info.rs +++ b/sdk/src/claim_generator_info.rs @@ -34,8 +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")] + /// A human readable string of the OS the claim generator is running on. + /// CrJSON schema uses `operating_system`; C2PA CBOR may use `schema.org.SoftwareApplication.operatingSystem`. + #[serde(alias = "schema.org.SoftwareApplication.operatingSystem", skip_serializing_if = "Option::is_none")] pub operating_system: Option, // Any other values that are not part of the standard #[serde(flatten)] diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index f299dcfdb..cc9d6dc94 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -207,7 +207,8 @@ impl CrJsonReader { let manifests_array = self.convert_manifests_to_array()?; result["manifests"] = manifests_array; - // Add validationResults (output of validation_results() method) + // Add validationResults (output of validation_results() method). + // When present, validationResults always includes activeManifest (see ValidationResults::from_store). if let Some(validation_results) = self.inner.validation_results() { result["validationResults"] = serde_json::to_value(validation_results)?; } @@ -435,6 +436,14 @@ impl CrJsonReader { fn fix_hash_encoding(value: Value) -> Value { match value { Value::Object(mut map) => { + // Normalize softwareAgent to crJSON schema: use operating_system instead of schema.org.SoftwareApplication.operatingSystem + const SCHEMA_ORG_OS_KEY: &str = "schema.org.SoftwareApplication.operatingSystem"; + if let Some(os_value) = map.remove(SCHEMA_ORG_OS_KEY) { + if !map.contains_key("operating_system") { + map.insert("operating_system".to_string(), os_value); + } + } + // Check if this object has a "hash" field that's an array if let Some(hash_value) = map.get("hash") { if let Some(hash_array) = hash_value.as_array() { @@ -536,6 +545,21 @@ impl CrJsonReader { *val = Self::fix_hash_encoding(val.clone()); } + // Normalize "icon" fields to crJSON hashedUriMap shape (url, hash, optional alg). + // Icon may be HashedUri { url, hash } or ResourceRef { format, identifier }; schema expects hashedUriMap. + if let Some(icon_val) = map.get_mut("icon") { + if let Some(icon_obj) = icon_val.as_object_mut() { + // If icon has "identifier" but no "url", set url from identifier (ResourceRef -> hashedUriMap) + if !icon_obj.contains_key("url") { + if let Some(id_val) = icon_obj.get("identifier") { + if let Some(id_str) = id_val.as_str() { + icon_obj.insert("url".to_string(), json!(id_str)); + } + } + } + } + } + Value::Object(map) } Value::Array(arr) => { @@ -546,6 +570,46 @@ impl CrJsonReader { } } + /// Resolve an icon value (ResourceRef with identifier, or already hashedUriMap) to schema + /// hashedUriMap { url, hash, alg? }. Returns Some(Value) when we can produce a valid hashedUriMap. + fn resolve_icon_to_hashed_uri_map(claim: &Claim, manifest_label: &str, icon_val: &Value) -> Option { + let icon_obj = icon_val.as_object()?; + // Already valid hashedUriMap: url and hash both present and hash is string (not byte array) + let url_str = icon_obj.get("url").and_then(|v| v.as_str()); + let hash_str = icon_obj.get("hash").and_then(|v| v.as_str()); + if url_str.is_some() && hash_str.is_some() { + return None; // keep as-is + } + // Need to resolve: get identifier (ResourceRef) or url, then get hash from claim + let uri = icon_obj + .get("identifier") + .and_then(|v| v.as_str()) + .or_else(|| icon_obj.get("url").and_then(|v| v.as_str()))?; + let (label, instance) = Claim::assertion_label_from_link(uri); + // Prefer assertion_hashed_uri_from_label for correct relative URL; fallback to get_claim_assertion + identifier as url + let (url, hash_b64, alg) = if let Some((hashed_uri, _)) = + claim.assertion_hashed_uri_from_label(&Claim::label_with_instance(&label, instance)) + { + ( + to_absolute_uri(manifest_label, &hashed_uri.url()), + base64::encode(&hashed_uri.hash()), + hashed_uri.alg(), + ) + } else if let Some(ca) = claim.get_claim_assertion(&label, instance) { + // Use identifier/uri as url (already absolute when from ResourceRef) and hash from claim assertion + (uri.to_string(), base64::encode(ca.hash()), None) + } else { + return None; + }; + let mut map = Map::new(); + map.insert("url".to_string(), json!(url)); + map.insert("hash".to_string(), json!(hash_b64)); + if let Some(alg) = alg { + map.insert("alg".to_string(), json!(alg)); + } + Some(Value::Object(map)) + } + /// Build the claim.v2 object from scattered manifest properties fn build_claim_v2(&self, manifest: &Manifest, label: &str) -> Result { let mut claim_v2 = Map::new(); @@ -563,12 +627,31 @@ impl CrJsonReader { claim_v2.insert("claim_generator".to_string(), json!(claim_generator)); } - // Add claim_generator_info (full info including name, version, icon) when present. - // This ensures icons and other generator details are exported to crJSON format. + // Add claim_generator_info (single object per claim.v2 schema, not array). + // claim.v2 has one generator-info map; use the first when present. if let Some(ref info_vec) = manifest.claim_generator_info { - if let Ok(info_value) = serde_json::to_value(info_vec) { - let fixed_info = Self::fix_hash_encoding(info_value); - claim_v2.insert("claim_generator_info".to_string(), fixed_info); + if let Some(first) = info_vec.first() { + if let Ok(info_value) = serde_json::to_value(first) { + let fixed_info = Self::fix_hash_encoding(info_value); + // Ensure we never emit an array: take first element if value is array (e.g. from alternate path) + let mut info_for_claim = match &fixed_info { + Value::Array(arr) if !arr.is_empty() => arr[0].clone(), + _ => fixed_info, + }; + // Resolve claim_generator_info icon to hashedUriMap (url, hash, alg?) per schema + if let Some(claim) = self.inner.store.get_claim(label) { + if let Some(info_obj) = info_for_claim.as_object_mut() { + if let Some(icon_val) = info_obj.get_mut("icon") { + if let Some(resolved) = + Self::resolve_icon_to_hashed_uri_map(claim, label, icon_val) + { + *icon_val = resolved; + } + } + } + } + claim_v2.insert("claim_generator_info".to_string(), info_for_claim); + } } } @@ -587,6 +670,43 @@ impl CrJsonReader { // Add empty array for redacted assertions claim_v2.insert("redacted_assertions".to_string(), json!([])); + // Per crJSON schema, claim.v2.claim_generator_info is a single object, never an array. + if let Some(existing) = claim_v2.get("claim_generator_info") { + if let Some(arr) = existing.as_array() { + if !arr.is_empty() { + claim_v2.insert("claim_generator_info".to_string(), arr[0].clone()); + } + } + } + + // Ensure claim_generator_info.icon is always hashedUriMap (url, hash, alg?) when present. + if let Some(claim) = self.inner.store.get_claim(label) { + if let Some(cgi) = claim_v2.get_mut("claim_generator_info") { + if let Some(info_obj) = cgi.as_object_mut() { + if let Some(icon_val) = info_obj.get_mut("icon") { + let is_hashed_uri_map = icon_val + .as_object() + .and_then(|o| o.get("url").and_then(|v| v.as_str())) + .is_some() + && icon_val + .as_object() + .and_then(|o| o.get("hash").and_then(|v| v.as_str())) + .is_some(); + if !is_hashed_uri_map { + if let Some(resolved) = + Self::resolve_icon_to_hashed_uri_map(claim, label, icon_val) + { + *icon_val = resolved; + } else { + // Cannot resolve: remove icon so output doesn't claim hashedUriMap with wrong shape + info_obj.remove("icon"); + } + } + } + } + } + } + Ok(Value::Object(claim_v2)) } @@ -1287,33 +1407,28 @@ mod tests { .get("claim_generator_info") .expect("claim.v2 should include claim_generator_info when manifest has an icon"); - let info_arr = claim_generator_info - .as_array() - .expect("claim_generator_info should be an array"); - assert!( - !info_arr.is_empty(), - "claim_generator_info should have at least one entry" - ); + // claim.v2: claim_generator_info is a single object, not an array + let info_obj = claim_generator_info + .as_object() + .expect("claim_generator_info in claim.v2 should be a single object"); - // At least one entry should have an icon. Icon may be serialized as: + // Entry may have an icon. Icon may be serialized as: // - ResourceRef { format, identifier } (when resolved from HashedUri in reader), or // - HashedUri { url, alg?, hash } with hash as base64 string (not byte array) - let has_icon = info_arr.iter().any(|entry| { - let icon = match entry.get("icon") { - Some(icon) => icon, - None => return false, - }; - // If icon has "hash" (HashedUri), it must be a base64 string after fix_hash_encoding - if let Some(hash) = icon.get("hash") { - return hash.is_string(); + let has_icon = match info_obj.get("icon") { + Some(icon) => { + if let Some(hash) = icon.get("hash") { + hash.is_string() + } else { + icon.get("format").is_some() && icon.get("identifier").is_some() + } } - // ResourceRef has format and identifier - icon.get("format").is_some() && icon.get("identifier").is_some() - }); + None => false, + }; assert!( has_icon, - "claim_generator_info should include an entry with icon (ResourceRef or HashedUri with base64 hash)" + "claim_generator_info should include icon (ResourceRef or HashedUri with base64 hash)" ); Ok(()) diff --git a/sdk/src/validation_results.rs b/sdk/src/validation_results.rs index a07fc48ed..e9cb5ab4c 100644 --- a/sdk/src/validation_results.rs +++ b/sdk/src/validation_results.rs @@ -128,7 +128,12 @@ impl ValidationResults { .collect(); // Filter out any status that is already captured in an ingredient assertion. + // There is always an active manifest in a manifest store; ensure active_manifest is set + // so serialization (e.g. crJSON) always includes activeManifest when validationResults exist. if let Some(claim) = store.provenance_claim() { + let _ = results + .active_manifest + .get_or_insert_with(StatusCodes::default); let active_manifest = Some(claim.label().to_string()); // This closure returns true if the URI references the store's active manifest. diff --git a/sdk/tests/crjson/schema_compliance.rs b/sdk/tests/crjson/schema_compliance.rs index 9c776e2b0..92d049535 100644 --- a/sdk/tests/crjson/schema_compliance.rs +++ b/sdk/tests/crjson/schema_compliance.rs @@ -12,7 +12,7 @@ // each license. //! Schema compliance tests for crJSON format. -//! These tests validate CrJSON output structure and alignment with `export_schema/crJSON-schema.json`. +//! These tests validate CrJSON output structure and alignment with `cli/schemas/crJSON-schema.json`. //! //! **Reviewing generated crJSON when tests run:** set the environment variable //! `C2PA_WRITE_CRJSON=1` (or any value), then run the crjson tests. Generated crJSON @@ -31,8 +31,8 @@ use std::io::Cursor; const IMAGE_WITH_MANIFEST: &[u8] = include_bytes!("../fixtures/CA.jpg"); -/// CrJSON schema (export_schema/crJSON-schema.json) - used to verify output structure. -const CRJSON_SCHEMA: &str = include_str!("../../../export_schema/crJSON-schema.json"); +/// CrJSON schema (cli/schemas/crJSON-schema.json) - used to verify output structure. +const CRJSON_SCHEMA: &str = include_str!("../../../cli/schemas/crJSON-schema.json"); /// When C2PA_WRITE_CRJSON is set, write generated crJSON to target/crjson_test_output/ /// so you can review the exact output. Called at the start of tests that build CrJsonReader. @@ -64,25 +64,27 @@ fn test_validation_results_schema_compliance() -> Result<()> { let vr = validation_results.as_object().unwrap(); - // Optional: activeManifest with success, informational, failure arrays - if let Some(active_manifest) = vr.get("activeManifest") { - assert!(active_manifest.is_object(), "activeManifest should be object"); - let am = active_manifest.as_object().unwrap(); - for key in &["success", "informational", "failure"] { - if let Some(arr) = am.get(*key) { - assert!(arr.is_array(), "{} should be array", key); - for entry in arr.as_array().unwrap() { - assert!(entry.is_object(), "Each entry should be object"); - let obj = entry.as_object().unwrap(); - assert!(obj.contains_key("code"), "Entry should have code"); - assert!(obj.get("code").unwrap().is_string(), "code should be string"); - if let Some(url) = obj.get("url") { - assert!(url.is_string(), "url should be string"); - } - if let Some(explanation) = obj.get("explanation") { - assert!(explanation.is_string(), "explanation should be string"); - } - } + // Required per schema: activeManifest (statusCodes with success, informational, failure) + let active_manifest = vr + .get("activeManifest") + .expect("validationResults must have activeManifest per crJSON schema"); + assert!(active_manifest.is_object(), "activeManifest should be object"); + let am = active_manifest.as_object().unwrap(); + for key in &["success", "informational", "failure"] { + let arr = am + .get(*key) + .unwrap_or_else(|| panic!("activeManifest must have {} array per schema", key)); + assert!(arr.is_array(), "{} should be array", key); + for entry in arr.as_array().unwrap() { + assert!(entry.is_object(), "Each entry should be object"); + let obj = entry.as_object().unwrap(); + assert!(obj.contains_key("code"), "Entry should have code (validationStatusEntry)"); + assert!(obj.get("code").unwrap().is_string(), "code should be string"); + if let Some(url) = obj.get("url") { + assert!(url.is_string(), "url should be string"); + } + if let Some(explanation) = obj.get("explanation") { + assert!(explanation.is_string(), "explanation should be string"); } } } @@ -225,8 +227,28 @@ fn test_manifests_array_schema_compliance() -> Result<()> { has_claim || has_claim_v2, "manifest should have either claim or claim.v2" ); - if let Some(claim) = manifest_obj.get("claim.v2") { - assert!(claim.is_object(), "claim.v2 should be object"); + if let Some(claim_v2) = manifest_obj.get("claim.v2") { + assert!(claim_v2.is_object(), "claim.v2 should be object"); + // Per crJSON schema, claim.v2.claim_generator_info is a single object, not an array + if let Some(cgi) = claim_v2.get("claim_generator_info") { + assert!( + cgi.is_object(), + "claim.v2.claim_generator_info must be object per schema, got array or other" + ); + // When present, icon must be hashedUriMap (url, hash, optional alg) per schema + if let Some(icon) = cgi.get("icon") { + assert!(icon.is_object(), "claim_generator_info.icon must be object (hashedUriMap)"); + let icon_obj = icon.as_object().unwrap(); + assert!( + icon_obj.get("url").and_then(|v| v.as_str()).is_some(), + "claim_generator_info.icon must have string 'url' (hashedUriMap)" + ); + assert!( + icon_obj.get("hash").and_then(|v| v.as_str()).is_some(), + "claim_generator_info.icon must have string 'hash' (hashedUriMap)" + ); + } + } } } From a2d1d8449e35e4b34d5496ce549460739d5be007 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Fri, 20 Feb 2026 10:36:57 -0500 Subject: [PATCH 07/18] fixed another issue with icons --- sdk/src/cr_json_reader.rs | 59 +++++++++++++++++++++++---------------- sdk/src/resource_store.rs | 6 +++- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index cc9d6dc94..07239a01d 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -545,11 +545,10 @@ impl CrJsonReader { *val = Self::fix_hash_encoding(val.clone()); } - // Normalize "icon" fields to crJSON hashedUriMap shape (url, hash, optional alg). - // Icon may be HashedUri { url, hash } or ResourceRef { format, identifier }; schema expects hashedUriMap. + // Normalize "icon" fields to crJSON hashedUriMap shape (url, hash, optional alg) only. + // Schema hashedUriMap has additionalProperties: false; do not emit format/identifier. if let Some(icon_val) = map.get_mut("icon") { if let Some(icon_obj) = icon_val.as_object_mut() { - // If icon has "identifier" but no "url", set url from identifier (ResourceRef -> hashedUriMap) if !icon_obj.contains_key("url") { if let Some(id_val) = icon_obj.get("identifier") { if let Some(id_str) = id_val.as_str() { @@ -557,6 +556,7 @@ impl CrJsonReader { } } } + Self::icon_retain_only_hashed_uri_map_keys(icon_obj); } } @@ -570,21 +570,35 @@ impl CrJsonReader { } } + /// Retain only hashedUriMap keys (url, hash, alg) on an icon object; remove format, identifier, data_types, etc. + fn icon_retain_only_hashed_uri_map_keys(icon_obj: &mut Map) { + const ALLOWED: &[&str] = &["url", "hash", "alg"]; + icon_obj.retain(|k, _| ALLOWED.contains(&k.as_str())); + } + /// Resolve an icon value (ResourceRef with identifier, or already hashedUriMap) to schema /// hashedUriMap { url, hash, alg? }. Returns Some(Value) when we can produce a valid hashedUriMap. fn resolve_icon_to_hashed_uri_map(claim: &Claim, manifest_label: &str, icon_val: &Value) -> Option { let icon_obj = icon_val.as_object()?; - // Already valid hashedUriMap: url and hash both present and hash is string (not byte array) let url_str = icon_obj.get("url").and_then(|v| v.as_str()); let hash_str = icon_obj.get("hash").and_then(|v| v.as_str()); + let identifier_str = icon_obj.get("identifier").and_then(|v| v.as_str()); + // Already valid hashedUriMap: url and hash both present and hash is string if url_str.is_some() && hash_str.is_some() { return None; // keep as-is } - // Need to resolve: get identifier (ResourceRef) or url, then get hash from claim - let uri = icon_obj - .get("identifier") - .and_then(|v| v.as_str()) - .or_else(|| icon_obj.get("url").and_then(|v| v.as_str()))?; + // ResourceRef with hash preserved (from HashedUri): build hashedUriMap without claim lookup + if let (Some(uri), Some(hash)) = (identifier_str.or(url_str), hash_str) { + let mut map = Map::new(); + map.insert("url".to_string(), json!(uri)); + map.insert("hash".to_string(), json!(hash)); + if let Some(alg) = icon_obj.get("alg").and_then(|v| v.as_str()) { + map.insert("alg".to_string(), json!(alg)); + } + return Some(Value::Object(map)); + } + // Need to resolve from claim: get identifier or url, then get hash from claim + let uri = identifier_str.or(url_str)?; let (label, instance) = Claim::assertion_label_from_link(uri); // Prefer assertion_hashed_uri_from_label for correct relative URL; fallback to get_claim_assertion + identifier as url let (url, hash_b64, alg) = if let Some((hashed_uri, _)) = @@ -679,7 +693,8 @@ impl CrJsonReader { } } - // Ensure claim_generator_info.icon is always hashedUriMap (url, hash, alg?) when present. + // Ensure claim_generator_info.icon is hashedUriMap (url, hash, alg?) when we can resolve it. + // Never remove icon: if present in CBOR it must appear in JSON. Output only hashedUriMap keys (no format/identifier). if let Some(claim) = self.inner.store.get_claim(label) { if let Some(cgi) = claim_v2.get_mut("claim_generator_info") { if let Some(info_obj) = cgi.as_object_mut() { @@ -697,11 +712,11 @@ impl CrJsonReader { Self::resolve_icon_to_hashed_uri_map(claim, label, icon_val) { *icon_val = resolved; - } else { - // Cannot resolve: remove icon so output doesn't claim hashedUriMap with wrong shape - info_obj.remove("icon"); } } + if let Some(icon_obj) = icon_val.as_object_mut() { + Self::icon_retain_only_hashed_uri_map_keys(icon_obj); + } } } } @@ -1412,23 +1427,19 @@ mod tests { .as_object() .expect("claim_generator_info in claim.v2 should be a single object"); - // Entry may have an icon. Icon may be serialized as: - // - ResourceRef { format, identifier } (when resolved from HashedUri in reader), or - // - HashedUri { url, alg?, hash } with hash as base64 string (not byte array) + // Icon must be hashedUriMap only (url, hash, optional alg) per schema; no format/identifier. let has_icon = match info_obj.get("icon") { - Some(icon) => { - if let Some(hash) = icon.get("hash") { - hash.is_string() - } else { - icon.get("format").is_some() && icon.get("identifier").is_some() - } - } + Some(icon) => icon + .get("url") + .and_then(|v| v.as_str()) + .is_some() + && icon.get("hash").and_then(|v| v.as_str()).is_some(), None => false, }; assert!( has_icon, - "claim_generator_info should include icon (ResourceRef or HashedUri with base64 hash)" + "claim_generator_info should include icon as hashedUriMap (url, hash)" ); Ok(()) diff --git a/sdk/src/resource_store.rs b/sdk/src/resource_store.rs index 493a8c31b..292185b13 100644 --- a/sdk/src/resource_store.rs +++ b/sdk/src/resource_store.rs @@ -33,6 +33,7 @@ use crate::{ assertions::{labels, AssetType, EmbeddedData}, asset_io::CAIRead, claim::Claim, + crypto::base64, error::Error, hashed_uri::HashedUri, jumbf::labels::{assertion_label_from_uri, to_absolute_uri, DATABOXES}, @@ -99,7 +100,10 @@ impl UriOrResource { ) }; let url = to_absolute_uri(claim.label(), &h.url()); - let resource_ref = resources.add_with(&url, &format, data)?; + let mut resource_ref = resources.add_uri(&url, &format, data)?; + // Preserve hash and alg so crJSON (and other consumers) can always emit hashedUriMap + resource_ref.hash = Some(base64::encode(&h.hash())); + resource_ref.alg = h.alg(); Ok(UriOrResource::ResourceRef(resource_ref)) } } From 83bd9a9a99e5349f859e65555a95bf620c7dfd97 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Tue, 24 Feb 2026 18:25:20 -0500 Subject: [PATCH 08/18] add support for getting the timestamp out in the JSON --- cli/schemas/crJSON-schema.json | 5 +++++ sdk/src/cr_json_reader.rs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json index cec796cab..93ac63b6a 100644 --- a/cli/schemas/crJSON-schema.json +++ b/cli/schemas/crJSON-schema.json @@ -541,6 +541,11 @@ "description": "Alternate field name for validity end" } } + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Time when the claim was signed (e.g. from TSA timestamp); RFC 3339 format" } }, "additionalProperties": false diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index 07239a01d..8c2bad3a5 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -783,6 +783,11 @@ impl CrJsonReader { claim_signature.insert("algorithm".to_string(), json!(alg.to_string())); } + // Add signing timestamp (e.g. from TSA) when available + if let Some(time) = &sig_info.time { + claim_signature.insert("timestamp".to_string(), json!(time)); + } + // Parse certificate to get detailed DN components and validity if let Some(cert_info) = self.parse_certificate(&sig_info.cert_chain)? { // Add serial number (hex format) From 945f39ee18d7665b4f88baa68b91dbb6ceff506c Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Fri, 27 Feb 2026 07:30:42 -0500 Subject: [PATCH 09/18] fix issue with v1 vs. v2 claims --- sdk/src/cr_json_reader.rs | 165 ++++++++++++++++++++++++++++++++++---- 1 file changed, 150 insertions(+), 15 deletions(-) diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index 8c2bad3a5..5416e2eaa 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -238,9 +238,19 @@ impl CrJsonReader { let assertions_obj = self.convert_assertions(manifest, label)?; manifest_obj.insert("assertions".to_string(), json!(assertions_obj)); - // Build claim.v2 object - let claim_v2 = self.build_claim_v2(manifest, label)?; - manifest_obj.insert("claim.v2".to_string(), claim_v2); + // Emit claim (v1) or claim.v2 per crJSON schema oneOf, based on source claim version + let claim = self + .inner + .store + .get_claim(label) + .ok_or_else(|| Error::ClaimMissing { label: label.to_owned() })?; + if claim.version() == 1 { + let claim_v1 = self.build_claim_v1(manifest, label, claim)?; + manifest_obj.insert("claim".to_string(), claim_v1); + } else { + let claim_v2 = self.build_claim_v2(manifest, label)?; + manifest_obj.insert("claim.v2".to_string(), claim_v2); + } // Build signature object (required per manifest schema; use empty object when no signature info) let signature = self @@ -624,6 +634,120 @@ impl CrJsonReader { Some(Value::Object(map)) } + /// Build the claim (v1) object per crJSON claimV1 / C2PA CDDL claim-map. + /// Used when the source claim is version 1 (has "assertions", not "created_assertions"). + fn build_claim_v1( + &self, + manifest: &Manifest, + label: &str, + claim: &Claim, + ) -> Result { + let mut claim_v1 = Map::new(); + + // Required: claim_generator (v1 only) + claim_v1.insert( + "claim_generator".to_string(), + json!(claim + .claim_generator() + .or_else(|| manifest.claim_generator()) + .unwrap_or("")), + ); + + // Required: claim_generator_info as array (v1 schema) + if let Some(ref info_vec) = manifest.claim_generator_info { + let mut info_array = Vec::new(); + for info in info_vec { + if let Ok(info_value) = serde_json::to_value(info) { + let fixed_info = Self::fix_hash_encoding(info_value); + let mut info_obj = match fixed_info { + Value::Object(m) => m, + _ => continue, + }; + if let Some(icon_val) = info_obj.get_mut("icon") { + if let Some(resolved) = + Self::resolve_icon_to_hashed_uri_map(claim, label, icon_val) + { + *icon_val = resolved; + } + if let Some(icon_obj) = icon_val.as_object_mut() { + Self::icon_retain_only_hashed_uri_map_keys(icon_obj); + } + } + info_array.push(Value::Object(info_obj)); + } + } + if !info_array.is_empty() { + claim_v1.insert("claim_generator_info".to_string(), Value::Array(info_array)); + } + } + if !claim_v1.contains_key("claim_generator_info") { + // Schema requires at least one; use minimal placeholder if missing + claim_v1.insert( + "claim_generator_info".to_string(), + json!([{"name": claim.claim_generator().unwrap_or("")}]), + ); + } + + // Required: signature (JUMBF URI reference) + let signature_ref = format!("self#jumbf=/c2pa/{}/c2pa.signature", label); + claim_v1.insert("signature".to_string(), json!(signature_ref)); + + // Required: assertions (array of hashedUriMap); v1 uses single list + let mut assertions_arr = Vec::new(); + for assertion_ref in claim.assertions() { + let mut ref_obj = Map::new(); + ref_obj.insert( + "url".to_string(), + json!(to_absolute_uri(label, &assertion_ref.url())), + ); + ref_obj.insert( + "hash".to_string(), + json!(base64::encode(&assertion_ref.hash())), + ); + if let Some(alg) = assertion_ref.alg() { + ref_obj.insert("alg".to_string(), json!(alg)); + } + assertions_arr.push(Value::Object(ref_obj)); + } + claim_v1.insert("assertions".to_string(), Value::Array(assertions_arr)); + + // Required: dc:format, instanceID + claim_v1.insert( + "dc:format".to_string(), + json!(manifest + .format() + .or_else(|| claim.format()) + .unwrap_or("")), + ); + claim_v1.insert("instanceID".to_string(), json!(manifest.instance_id())); + + // Optional v1 fields + let title_str = manifest + .title() + .or_else(|| claim.title().map(|s| s.as_str())); + if let Some(title) = title_str { + claim_v1.insert("dc:title".to_string(), json!(title)); + } + if let Some(redacted) = claim.redactions() { + if !redacted.is_empty() { + claim_v1.insert("redacted_assertions".to_string(), json!(redacted)); + } + } + claim_v1.insert("alg".to_string(), json!(claim.alg())); + if let Some(alg_soft) = claim.alg_soft() { + claim_v1.insert("alg_soft".to_string(), json!(alg_soft)); + } + if let Some(metadata) = claim.metadata() { + if !metadata.is_empty() { + if let Ok(v) = serde_json::to_value(metadata) { + claim_v1.insert("metadata".to_string(), v); + } + } + } + + Ok(Value::Object(claim_v1)) + } + /// Build the claim.v2 object from scattered manifest properties fn build_claim_v2(&self, manifest: &Manifest, label: &str) -> Result { let mut claim_v2 = Map::new(); @@ -1196,16 +1320,25 @@ mod tests { assert!(manifest.get("assertions").is_some()); assert!(manifest.get("signature").is_some()); assert!(manifest.get("status").is_some()); - assert!(manifest.get("claim.v2").is_some()); + let has_claim = manifest.get("claim").is_some(); + let has_claim_v2 = manifest.get("claim.v2").is_some(); + assert!(has_claim != has_claim_v2, "manifest must have exactly one of claim (v1) or claim.v2"); // Verify assertions is an object (not array) assert!(manifest["assertions"].is_object()); - // Verify claim.v2 has expected fields - let claim_v2 = &manifest["claim.v2"]; - assert!(claim_v2.get("instanceID").is_some()); - assert!(claim_v2.get("signature").is_some()); - assert!(claim_v2.get("created_assertions").is_some()); + if let Some(claim_v2) = manifest.get("claim.v2") { + assert!(claim_v2.get("instanceID").is_some()); + assert!(claim_v2.get("signature").is_some()); + assert!(claim_v2.get("created_assertions").is_some()); + } else if let Some(claim_v1) = manifest.get("claim") { + assert!(claim_v1.get("claim_generator").is_some()); + assert!(claim_v1.get("claim_generator_info").is_some()); + assert!(claim_v1.get("signature").is_some()); + assert!(claim_v1.get("assertions").is_some()); + assert!(claim_v1.get("dc:format").is_some()); + assert!(claim_v1.get("instanceID").is_some()); + } } Ok(()) @@ -1419,18 +1552,20 @@ mod tests { .first() .expect("should have at least one manifest"); - let claim_v2 = manifest + let claim_block = manifest .get("claim.v2") - .expect("claim.v2 should be present"); + .or_else(|| manifest.get("claim")) + .expect("manifest should have claim or claim.v2"); - let claim_generator_info = claim_v2 + let claim_generator_info = claim_block .get("claim_generator_info") - .expect("claim.v2 should include claim_generator_info when manifest has an icon"); + .expect("claim should include claim_generator_info when manifest has an icon"); - // claim.v2: claim_generator_info is a single object, not an array + // claim.v2: single object; claim (v1): array — get first object for assertion let info_obj = claim_generator_info .as_object() - .expect("claim_generator_info in claim.v2 should be a single object"); + .or_else(|| claim_generator_info.as_array().and_then(|a| a.first()).and_then(|v| v.as_object())) + .expect("claim_generator_info should be an object or array of objects"); // Icon must be hashedUriMap only (url, hash, optional alg) per schema; no format/identifier. let has_icon = match info_obj.get("icon") { From b9fa8d3bd94bbcacc485b704542d92861e7417a4 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Mon, 2 Mar 2026 15:36:00 -0500 Subject: [PATCH 10/18] updates to use camelCase instead of snake_case --- cli/schemas/crJSON-schema.json | 2487 +++++++++++++++++++++----------- sdk/src/cr_json_reader.rs | 6 +- 2 files changed, 1628 insertions(+), 865 deletions(-) diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json index 93ac63b6a..4ee0e09a1 100644 --- a/cli/schemas/crJSON-schema.json +++ b/cli/schemas/crJSON-schema.json @@ -1,863 +1,1626 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://contentcredentials.org/crjson/crJSON-schema.json", - "title": "Content Credential JSON (CrJSON) Document Schema", - "description": "JSON Schema for Content Credential JSON (CrJSON) documents. CrJSON does not include asset_info, content, or metadata.", - "type": "object", - "required": [ - "@context" - ], - "properties": { - "@context": { - "description": "JSON-LD context defining namespaces and vocabularies", - "oneOf": [ - { - "type": "array", - "items": { - "type": "string", - "format": "uri" - }, - "minItems": 1 - }, - { - "type": "object", - "properties": { - "@vocab": { - "type": "string", - "format": "uri" - } - }, - "additionalProperties": { - "type": "string" - } - } - ] - }, - "manifests": { - "description": "Array of C2PA manifests embedded in the asset", - "type": "array", - "items": { - "$ref": "#/definitions/manifest" - } - }, - "validationResults": { - "description": "Validation results from validation (active manifest and ingredient deltas)", - "$ref": "#/definitions/validationResults" - } - }, - "additionalProperties": true, - "definitions": { - "manifest": { - "type": "object", - "required": [ - "label", - "assertions", - "signature", - "status" - ], - "oneOf": [ - { - "required": [ - "claim" - ] - }, - { - "required": [ - "claim.v2" - ] - } - ], - "properties": { - "label": { - "type": "string", - "description": "Manifest label or URN identifier" - }, - "assertions": { - "type": "object", - "description": "Assertions contained within the manifest, keyed by the assertion's label", - "additionalProperties": true - }, - "claim": { - "$ref": "#/definitions/claimV1", - "description": "Claim map (v1) per C2PA CDDL claim-map; alternative to claim.v2. A manifest SHALL contain either claim or claim.v2." - }, - "claim.v2": { - "$ref": "#/definitions/claim", - "description": "Normalized claim (v2) per C2PA CDDL claim-map-v2; alternative to claim. A manifest SHALL contain either claim or claim.v2." - }, - "signature": { - "$ref": "#/definitions/signature" - }, - "status": { - "$ref": "#/definitions/status" - } - }, - "additionalProperties": false - }, - "claimV1": { - "type": "object", - "description": "Claim map (v1) per C2PA CDDL claim-map. Alternative to claim.v2; each manifest may contain either claim or claim.v2.", - "required": [ - "claim_generator", - "claim_generator_info", - "signature", - "assertions", - "dc:format", - "instanceID" - ], - "properties": { - "claim_generator": { - "type": "string", - "description": "User-Agent string for the claim generator that created the claim (RFC 7231 §5.5.3)" - }, - "claim_generator_info": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/softwareAgent" - }, - "description": "Detailed information about the claim generator (one or more generator-info maps)" - }, - "signature": { - "type": "string", - "description": "JUMBF URI reference to the signature of this claim" - }, - "assertions": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/hashedUriMap" - }, - "description": "Hashed URI references to assertions in this claim" - }, - "dc:format": { - "type": "string", - "description": "Media type of the asset" - }, - "instanceID": { - "type": "string", - "description": "Uniquely identifies a specific version of an asset" - }, - "dc:title": { - "type": "string", - "description": "Name of the asset (Dublin Core)" - }, - "redacted_assertions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "JUMBF URI references to redacted ingredient manifest assertions" - }, - "alg": { - "type": "string", - "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" - }, - "alg_soft": { - "type": "string", - "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" - }, - "metadata": { - "type": "object", - "description": "Additional information about the assertion", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - "hashedUriMap": { - "type": "object", - "description": "Hashed URI reference (url and hash) per C2PA CDDL $hashed-uri-map", - "required": [ - "url", - "hash" - ], - "properties": { - "url": { - "type": "string", - "description": "JUMBF URI reference" - }, - "hash": { - "type": "string", - "description": "Hash value (e.g. Base64-encoded)" - }, - "alg": { - "type": "string", - "description": "Hash algorithm identifier; if absent, taken from enclosing structure" - } - }, - "additionalProperties": false - }, - "claim": { - "type": "object", - "description": "Claim (v2) per C2PA CDDL claim-map-v2.", - "required": [ - "instanceID", - "claim_generator_info", - "signature", - "created_assertions" - ], - "properties": { - "instanceID": { - "type": "string", - "description": "Uniquely identifies a specific version of an asset" - }, - "claim_generator_info": { - "$ref": "#/definitions/softwareAgent", - "description": "The claim generator of this claim (single generator-info map)" - }, - "signature": { - "type": "string", - "description": "JUMBF URI reference to the signature of this claim" - }, - "created_assertions": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/hashedUriMap" - }, - "description": "Hashed URI references to created assertions" - }, - "gathered_assertions": { - "type": "array", - "items": { - "$ref": "#/definitions/hashedUriMap" - }, - "description": "Hashed URI references to gathered assertions" - }, - "dc:title": { - "type": "string", - "description": "Name of the asset (Dublin Core)" - }, - "redacted_assertions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "JUMBF URI references to the assertions of ingredient manifests being redacted" - }, - "alg": { - "type": "string", - "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" - }, - "alg_soft": { - "type": "string", - "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" - }, - "specVersion": { - "type": "string", - "description": "The version of the specification used to produce this claim (SemVer)" - }, - "metadata": { - "type": "object", - "description": "Additional information about the assertion (DEPRECATED)", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - "assertions": { - "type": "object", - "properties": { - "c2pa.hash.data": { - "$ref": "#/definitions/hashDataAssertion" - }, - "c2pa.hash.bmff.v2": { - "$ref": "#/definitions/hashBmffAssertion" - }, - "c2pa.actions": { - "$ref": "#/definitions/actionsAssertion" - }, - "c2pa.actions.v2": { - "$ref": "#/definitions/actionsAssertion" - }, - "c2pa.ingredient": { - "$ref": "#/definitions/ingredientAssertion" - }, - "c2pa.ingredient.v3": { - "$ref": "#/definitions/ingredientAssertion" - }, - "c2pa.thumbnail.claim.jpeg": { - "$ref": "#/definitions/thumbnailAssertion" - }, - "c2pa.thumbnail.ingredient.jpeg": { - "$ref": "#/definitions/thumbnailAssertion" - }, - "c2pa.soft-binding": { - "type": "object", - "description": "Soft binding assertion", - "additionalProperties": true - } - }, - "patternProperties": { - "^c2pa\\.thumbnail\\.ingredient": { - "$ref": "#/definitions/thumbnailAssertion" - }, - "^c2pa\\.ingredient": { - "$ref": "#/definitions/ingredientAssertion" - } - }, - "additionalProperties": true - }, - "hashDataAssertion": { - "type": "object", - "properties": { - "alg": { - "type": "string", - "description": "Hash algorithm" - }, - "algorithm": { - "type": "string", - "description": "Alternative field name for hash algorithm" - }, - "hash": { - "type": "string", - "description": "Base64-encoded hash value" - }, - "pad": { - "type": "string", - "description": "Padding bytes" - }, - "paddingLength": { - "type": "integer", - "description": "Length of padding" - }, - "padding2Length": { - "type": "integer", - "description": "Length of secondary padding" - }, - "name": { - "type": "string", - "description": "Name of the hashed data" - }, - "exclusions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "description": "Starting byte position of exclusion" - }, - "length": { - "type": "integer", - "description": "Length of exclusion in bytes" - } - }, - "required": [ - "start", - "length" - ] - } - } - }, - "additionalProperties": false - }, - "actionsAssertion": { - "type": "object", - "properties": { - "actions": { - "type": "array", - "items": { - "$ref": "#/definitions/action" - } - } - }, - "required": [ - "actions" - ] - }, - "action": { - "type": "object", - "properties": { - "action": { - "type": "string", - "description": "Action type (e.g., c2pa.created, c2pa.edited, c2pa.converted)" - }, - "when": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the action occurred" - }, - "softwareAgent": { - "description": "Software that performed the action", - "oneOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/softwareAgent" - } - ] - }, - "digitalSourceType": { - "type": "string", - "format": "uri", - "description": "IPTC digital source type URI" - }, - "parameters": { - "type": "object", - "description": "Additional parameters for the action", - "additionalProperties": true - } - }, - "required": [ - "action" - ], - "additionalProperties": false - }, - "softwareAgent": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the software" - }, - "version": { - "type": "string", - "description": "Version of the software" - }, - "icon": { - "$ref": "#/definitions/hashedUriMap", - "description": "Icon representing the software/hardware" - }, - "operating_system": { - "type": "string", - "description": "Operating system the software runs on" - } - }, - "required": ["name"], - "additionalProperties": true - }, - "signature": { - "type": "object", - "properties": { - "signature_algorithm": { - "type": "string", - "description": "Algorithm used for signing (e.g., SHA256withECDSA)" - }, - "algorithm": { - "description": "Signature algorithm - can be string or object", - "oneOf": [ - { - "type": "string", - "description": "Simple algorithm name (e.g., ES256, PS256)" - }, - { - "type": "object", - "description": "Detailed algorithm specification", - "properties": { - "alg": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "saltLength": { - "type": "integer" - } - } - }, - "coseIdentifier": { - "type": "integer" - } - } - } - ] - }, - "serial_number": { - "type": "string", - "description": "Certificate serial number" - }, - "certificate": { - "type": "object", - "description": "Certificate details", - "properties": { - "serial_number": { - "type": "string" - }, - "serialNumber": { - "type": "string" - }, - "issuer": { - "$ref": "#/definitions/distinguishedName" - }, - "subject": { - "$ref": "#/definitions/distinguishedName" - }, - "validity": { - "type": "object", - "properties": { - "not_before": { - "type": "string", - "format": "date-time" - }, - "notBefore": { - "type": "string", - "format": "date-time" - }, - "not_after": { - "type": "string", - "format": "date-time" - }, - "notAfter": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "subject": { - "$ref": "#/definitions/distinguishedName" - }, - "issuer": { - "$ref": "#/definitions/distinguishedName" - }, - "validity": { - "type": "object", - "properties": { - "not_before": { - "type": "string", - "format": "date-time", - "description": "Certificate validity start date" - }, - "notBefore": { - "type": "string", - "format": "date-time", - "description": "Alternate field name for validity start" - }, - "not_after": { - "type": "string", - "format": "date-time", - "description": "Certificate validity end date" - }, - "notAfter": { - "type": "string", - "format": "date-time", - "description": "Alternate field name for validity end" - } - } - }, - "timestamp": { - "type": "string", - "format": "date-time", - "description": "Time when the claim was signed (e.g. from TSA timestamp); RFC 3339 format" - } - }, - "additionalProperties": false - }, - "distinguishedName": { - "type": "object", - "description": "X.509 Distinguished Name components", - "properties": { - "C": { - "type": "string", - "description": "Country" - }, - "ST": { - "type": "string", - "description": "State or province" - }, - "L": { - "type": "string", - "description": "Locality" - }, - "O": { - "type": "string", - "description": "Organization" - }, - "OU": { - "type": "string", - "description": "Organizational unit" - }, - "CN": { - "type": "string", - "description": "Common name" - }, - "E": { - "type": "string", - "description": "Email address" - }, - "2.5.4.97": { - "type": "string", - "description": "Organization identifier OID" - } - }, - "additionalProperties": false - }, - "status": { - "type": "object", - "description": "Validation status information", - "properties": { - "signature": { - "type": "string", - "description": "Signature validation status" - }, - "assertion": { - "type": "object", - "description": "Status of each assertion", - "additionalProperties": { - "type": "string" - } - }, - "content": { - "type": "string", - "description": "Content validation status" - }, - "trust": { - "type": "string", - "description": "Trust validation status" - } - }, - "additionalProperties": false - }, - "ingredientAssertion": { - "type": "object", - "description": "Ingredient assertion for provenance chain", - "properties": { - "_version": { - "type": "integer", - "description": "Ingredient assertion version" - }, - "title": { - "type": "string", - "description": "Title of the ingredient" - }, - "format": { - "type": "string", - "description": "Format of the ingredient" - }, - "instanceID": { - "type": "string", - "description": "Instance ID of the ingredient" - }, - "documentID": { - "type": "string", - "description": "Document ID of the ingredient" - }, - "relationship": { - "type": "string", - "description": "Relationship type (e.g., parentOf, componentOf)" - }, - "labelSuffix": { - "type": "integer", - "description": "Label suffix number for multiple ingredients" - }, - "thumbnail": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "algorithm": { - "type": "string" - } - } - }, - "metadata": { - "type": "object", - "additionalProperties": true - }, - "c2pa_manifest": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "algorithm": { - "type": "string" - } - } - }, - "activeManifest": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "algorithm": { - "type": "string" - } - } - }, - "claimSignature": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "algorithm": { - "type": "string" - } - } - }, - "validationResults": { - "type": "object", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - "thumbnailAssertion": { - "type": "object", - "description": "Thumbnail assertion", - "properties": { - "thumbnailType": { - "type": "integer", - "description": "Thumbnail type (0=claim, 1=ingredient)" - }, - "mimeType": { - "type": "string", - "description": "MIME type of thumbnail" - } - }, - "additionalProperties": false - }, - "hashBmffAssertion": { - "type": "object", - "description": "BMFF (ISO Base Media File Format) hash assertion", - "properties": { - "_version": { - "type": "integer" - }, - "exclusions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "xpath": { - "type": "string" - }, - "length": { - "type": [ - "integer", - "null" - ] - }, - "data": { - "type": [ - "array", - "null" - ] - }, - "subset": { - "type": [ - "array", - "null" - ] - }, - "version": { - "type": [ - "object", - "null" - ] - }, - "flags": { - "type": [ - "object", - "null" - ] - }, - "exact": { - "type": [ - "boolean", - "null" - ] - } - } - } - }, - "algorithm": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "additionalProperties": false - }, - "validationResults": { - "type": "object", - "description": "Output of validation_results(): status codes for active manifest and ingredient deltas", - "properties": { - "activeManifest": { - "$ref": "#/definitions/statusCodes", - "description": "Validation status codes for the active manifest" - }, - "ingredientDeltas": { - "type": "array", - "description": "Validation deltas per ingredient assertion", - "items": { - "$ref": "#/definitions/ingredientDeltaValidationResult" - } - } - }, - "required": ["activeManifest"], - "additionalProperties": false - }, - "statusCodes": { - "type": "object", - "description": "Success, informational, and failure validation status codes", - "properties": { - "success": { - "type": "array", - "items": { "$ref": "#/definitions/validationStatusEntry" } - }, - "informational": { - "type": "array", - "items": { "$ref": "#/definitions/validationStatusEntry" } - }, - "failure": { - "type": "array", - "items": { "$ref": "#/definitions/validationStatusEntry" } - } - }, - "required": [ - "success", - "informational", - "failure" - ], - "additionalProperties": false - }, - "validationStatusEntry": { - "type": "object", - "description": "Single validation status (code, optional url and explanation)", - "properties": { - "code": { "type": "string", "description": "Validation status code" }, - "url": { "type": "string", "description": "JUMBF URI where status applies" }, - "explanation": { "type": "string", "description": "Human-readable explanation" } - }, - "required": [ "code" ], - "additionalProperties": false - }, - "ingredientDeltaValidationResult": { - "type": "object", - "description": "Validation deltas for one ingredient's manifest", - "properties": { - "ingredientAssertionURI": { "type": "string", "description": "JUMBF URI to the ingredient assertion" }, - "validationDeltas": { "$ref": "#/definitions/statusCodes" } - }, - "required": [ "ingredientAssertionURI", "validationDeltas" ], - "additionalProperties": false - } - } - } \ No newline at end of file + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contentcredentials.org/crjson/crJSON-schema.json", + "title": "Content Credential JSON (CrJSON) Document Schema", + "description": "JSON Schema for Content Credential JSON (CrJSON) documents. CrJSON does not include asset_info, content, or metadata.", + "type": "object", + "required": [ + "@context" + ], + "properties": { + "@context": { + "description": "JSON-LD context defining namespaces and vocabularies", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "minItems": 1 + }, + { + "type": "object", + "properties": { + "@vocab": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "manifests": { + "description": "Array of C2PA manifests embedded in the asset", + "type": "array", + "items": { + "$ref": "#/definitions/manifest" + } + }, + "validationResults": { + "description": "Validation results from validation (active manifest and ingredient deltas)", + "$ref": "#/definitions/validationResults" + } + }, + "additionalProperties": true, + "definitions": { + "manifest": { + "type": "object", + "required": [ + "label", + "assertions", + "signature", + "status" + ], + "oneOf": [ + { + "required": [ + "claim" + ] + }, + { + "required": [ + "claim.v2" + ] + } + ], + "properties": { + "label": { + "type": "string", + "description": "Manifest label or URN identifier" + }, + "assertions": { + "type": "object", + "description": "Assertions contained within the manifest, keyed by the assertion's label", + "additionalProperties": true + }, + "claim": { + "$ref": "#/definitions/claimV1", + "description": "Claim map (v1) per C2PA CDDL claim-map; alternative to claim.v2. A manifest SHALL contain either claim or claim.v2." + }, + "claim.v2": { + "$ref": "#/definitions/claim", + "description": "Normalized claim (v2) per C2PA CDDL claim-map-v2; alternative to claim. A manifest SHALL contain either claim or claim.v2." + }, + "signature": { + "$ref": "#/definitions/signature" + }, + "status": { + "$ref": "#/definitions/status" + } + }, + "additionalProperties": false + }, + "claimV1": { + "type": "object", + "description": "Claim map (v1) per C2PA CDDL claim-map. Alternative to claim.v2; each manifest may contain either claim or claim.v2.", + "required": [ + "claim_generator", + "claim_generator_info", + "signature", + "assertions", + "dc:format", + "instanceID" + ], + "properties": { + "claim_generator": { + "type": "string", + "description": "User-Agent string for the claim generator that created the claim (RFC 7231 §5.5.3)" + }, + "claim_generator_info": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/softwareAgent" + }, + "description": "Detailed information about the claim generator (one or more generator-info maps)" + }, + "signature": { + "type": "string", + "description": "JUMBF URI reference to the signature of this claim" + }, + "assertions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to assertions in this claim" + }, + "dc:format": { + "type": "string", + "description": "Media type of the asset" + }, + "instanceID": { + "type": "string", + "description": "Uniquely identifies a specific version of an asset" + }, + "dc:title": { + "type": "string", + "description": "Name of the asset (Dublin Core)" + }, + "redacted_assertions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "JUMBF URI references to redacted ingredient manifest assertions" + }, + "alg": { + "type": "string", + "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" + }, + "alg_soft": { + "type": "string", + "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "additionalProperties": false + }, + "hashedUriMap": { + "type": "object", + "description": "Hashed URI reference (url and hash) per C2PA CDDL $hashed-uri-map", + "required": [ + "url", + "hash" + ], + "properties": { + "url": { + "type": "string", + "description": "JUMBF URI reference" + }, + "hash": { + "type": "string", + "description": "Hash value (e.g. Base64-encoded)" + }, + "alg": { + "type": "string", + "description": "Hash algorithm identifier; if absent, taken from enclosing structure" + } + }, + "additionalProperties": false + }, + "assertionMetadataMap": { + "type": "object", + "description": "Additional information about an assertion per C2PA CDDL $assertion-metadata-map.", + "properties": { + "dateTime": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 date-time when the assertion was created/generated (tdate)" + }, + "reviewRatings": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/ratingMap" + }, + "description": "Ratings given to the assertion" + }, + "reference": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to another assertion that this review is about" + }, + "dataSource": { + "$ref": "#/definitions/sourceMap", + "description": "Description of the source of the assertion data" + }, + "localizations": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/localizationDataEntry" + }, + "description": "Localizations for strings in the assertion" + }, + "regionOfInterest": { + "$ref": "#/definitions/regionMap", + "description": "Region of the asset where this assertion is relevant ($region-map)" + } + }, + "additionalProperties": true + }, + "sourceMap": { + "type": "object", + "description": "Source of assertion data per C2PA CDDL source-map.", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "Source type; $source-type", + "enum": [ + "signer", + "claimGenerator.REE", + "claimGenerator.TEE", + "localProvider.REE", + "localProvider.TEE", + "remoteProvider.1stParty", + "remoteProvider.3rdParty", + "humanEntry", + "humanEntry.anonymous", + "humanEntry.identified" + ] + }, + "details": { + "type": "string", + "description": "Human-readable details about the source, e.g. URL of the remote server" + } + }, + "additionalProperties": false + }, + "ratingMap": { + "type": "object", + "description": "Rating of an assertion item per C2PA CDDL rating-map.", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Rating from 1 (worst) to 5 (best)" + }, + "code": { + "type": "string", + "description": "Label-formatted reason for the rating; $review-code", + "enum": [ + "actions.unknownActionsPerformed", + "actions.missing", + "actions.possiblyMissing", + "depthMap.sceneMismatch", + "ingredient.modified", + "ingredient.possiblyModified", + "thumbnail.primaryMismatch", + "stds.iptc.location.inaccurate", + "stds.schema-org.CreativeWork.misattributed", + "stds.schema-org.CreativeWork.missingAttribution" + ] + }, + "explanation": { + "type": "string", + "description": "Human-readable explanation for the rating" + } + }, + "additionalProperties": false + }, + "localizationDataEntry": { + "type": "object", + "description": "Localization dictionary per C2PA CDDL localization-data-entry (language keys to string).", + "additionalProperties": { + "type": "string" + } + }, + "claim": { + "type": "object", + "description": "Claim (v2) per C2PA CDDL claim-map-v2.", + "required": [ + "instanceID", + "claim_generator_info", + "signature", + "created_assertions" + ], + "properties": { + "instanceID": { + "type": "string", + "description": "Uniquely identifies a specific version of an asset" + }, + "claim_generator_info": { + "$ref": "#/definitions/softwareAgent", + "description": "The claim generator of this claim (single generator-info map)" + }, + "signature": { + "type": "string", + "description": "JUMBF URI reference to the signature of this claim" + }, + "created_assertions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to created assertions" + }, + "gathered_assertions": { + "type": "array", + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "Hashed URI references to gathered assertions" + }, + "dc:title": { + "type": "string", + "description": "Name of the asset (Dublin Core)" + }, + "redacted_assertions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "JUMBF URI references to the assertions of ingredient manifests being redacted" + }, + "alg": { + "type": "string", + "description": "Cryptographic hash algorithm for data hash assertions in this claim (C2PA data hash algorithm identifier registry)" + }, + "alg_soft": { + "type": "string", + "description": "Algorithm for soft binding assertions in this claim (C2PA soft binding algorithm identifier registry)" + }, + "specVersion": { + "type": "string", + "description": "The version of the specification used to produce this claim (SemVer)" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap", + "description": "Additional information about the assertion (DEPRECATED)" + } + }, + "additionalProperties": false + }, + "assertions": { + "type": "object", + "properties": { + "c2pa.hash.data": { + "$ref": "#/definitions/hashDataAssertion" + }, + "c2pa.hash.bmff.v2": { + "$ref": "#/definitions/hashBmffAssertion" + }, + "c2pa.hash.boxes": { + "$ref": "#/definitions/hashBoxesAssertion" + }, + "c2pa.hash.multi-asset": { + "$ref": "#/definitions/hashMultiAssetAssertion" + }, + "c2pa.actions": { + "$ref": "#/definitions/actionsAssertionV1" + }, + "c2pa.actions.v2": { + "$ref": "#/definitions/actionsAssertionV2" + }, + "c2pa.ingredient": { + "$ref": "#/definitions/ingredientAssertionV1" + }, + "c2pa.ingredient.v2": { + "$ref": "#/definitions/ingredientAssertionV2" + }, + "c2pa.ingredient.v3": { + "$ref": "#/definitions/ingredientAssertionV3" + }, + "c2pa.thumbnail.claim.jpeg": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "c2pa.thumbnail.ingredient.jpeg": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "c2pa.soft-binding": { + "$ref": "#/definitions/softBindingAssertion" + } + }, + "patternProperties": { + "^c2pa\\.thumbnail\\.ingredient": { + "$ref": "#/definitions/thumbnailAssertion" + }, + "^c2pa\\.hash\\.(data|bmff\\.v2|boxes)\\.part(__[0-9]+)?$": { + "description": "Part hash assertion: standard hard binding assertion with .part and optional multiple instance identifier (e.g. c2pa.hash.data.part, c2pa.hash.data.part__2). Byte offsets in the assertion are relative to the part.", + "oneOf": [ + { + "$ref": "#/definitions/hashDataAssertion" + }, + { + "$ref": "#/definitions/hashBmffAssertion" + }, + { + "$ref": "#/definitions/hashBoxesAssertion" + } + ] + } + }, + "additionalProperties": true + }, + "hashDataAssertion": { + "type": "object", + "properties": { + "alg": { + "type": "string", + "description": "Hash algorithm" + }, + "algorithm": { + "type": "string", + "description": "Alternative field name for hash algorithm" + }, + "hash": { + "type": "string", + "description": "Base64-encoded hash value" + }, + "pad": { + "type": "string", + "description": "Padding bytes" + }, + "paddingLength": { + "type": "integer", + "description": "Length of padding" + }, + "padding2Length": { + "type": "integer", + "description": "Length of secondary padding" + }, + "name": { + "type": "string", + "description": "Name of the hashed data" + }, + "exclusions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "description": "Starting byte position of exclusion" + }, + "length": { + "type": "integer", + "description": "Length of exclusion in bytes" + } + }, + "required": [ + "start", + "length" + ] + } + } + }, + "additionalProperties": false + }, + "hashBoxesAssertion": { + "type": "object", + "description": "Boxes hash assertion per C2PA CDDL box-map (c2pa.hash.boxes).", + "required": [ + "boxes" + ], + "properties": { + "boxes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/boxHashMap" + }, + "description": "Array of box-hash-map entries" + }, + "alg": { + "type": "string", + "description": "Cryptographic hash algorithm (C2PA hash algorithm identifier). If absent, taken from enclosing structure." + } + }, + "additionalProperties": false + }, + "boxHashMap": { + "type": "object", + "description": "Single box hash entry per C2PA CDDL box-hash-map.", + "required": [ + "names", + "hash" + ], + "properties": { + "names": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 10, + "description": "Box identifier in order of appearance (e.g. APP0, IHDR); box-name" + }, + "description": "Box identifiers in order of appearance" + }, + "alg": { + "type": "string", + "description": "Hash algorithm for this box; if absent, from enclosing structure" + }, + "hash": { + "type": "string", + "description": "Hash value (byte string; in JSON typically Base64-encoded)" + }, + "excluded": { + "type": "boolean", + "description": "If true, a validator can ignore this box (and associated hash) during validation" + }, + "exclusions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/boxExclusionsMap" + }, + "description": "Hash exclusion ranges; ranges have monotonically increasing start values and do not overlap" + }, + "pad": { + "type": "string", + "description": "Zero-filled byte string used for filling up space (typically Base64 in JSON)" + }, + "pad2": { + "type": "string", + "description": "Zero-filled byte string used for filling up space (typically Base64 in JSON)" + } + }, + "additionalProperties": false + }, + "boxExclusionsMap": { + "type": "object", + "description": "Exclusion range per C2PA CDDL box-exclusions-map.", + "required": [ + "start", + "length" + ], + "properties": { + "start": { + "type": "integer", + "minimum": 0, + "description": "Starting byte offset from the start of the box (uint)" + }, + "length": { + "type": "integer", + "minimum": 0, + "description": "Number of bytes of data to exclude (uint)" + }, + "boxIndex": { + "type": "integer", + "description": "0-based index into the names array in the box-hash-map; can be omitted if the number of boxes is one" + } + }, + "additionalProperties": false + }, + "hashMultiAssetAssertion": { + "type": "object", + "description": "Multi-asset hash assertion per C2PA CDDL multi-asset-hash-map (c2pa.hash.multi-asset).", + "required": [ + "parts" + ], + "properties": { + "parts": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/partHashMap" + }, + "description": "Array of hashes for individual parts of the multi-part file" + } + }, + "additionalProperties": false + }, + "partHashMap": { + "type": "object", + "description": "Part hash entry per C2PA CDDL part-hash-map.", + "required": [ + "location", + "hashAssertion" + ], + "properties": { + "location": { + "$ref": "#/definitions/locatorMap", + "description": "Location of the part within the file (byte range or BMFF box path)" + }, + "hashAssertion": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI to the hash assertion for the part (e.g. c2pa.hash.data.part, c2pa.hash.data.part__2). Byte offsets in that assertion are relative to the part." + }, + "optional": { + "type": "boolean", + "description": "If true, the part is optional and can be discarded" + } + }, + "additionalProperties": false + }, + "locatorMap": { + "type": "object", + "description": "Location of a part per C2PA CDDL locator-map (byte range or BMFF box path).", + "oneOf": [ + { + "required": [ + "byteOffset", + "length" + ], + "properties": { + "byteOffset": { + "type": "integer", + "minimum": 0, + "description": "Byte offset of the part within the file (uint)" + }, + "length": { + "type": "integer", + "minimum": 0, + "description": "Length of the part in bytes (uint)" + } + }, + "additionalProperties": false + }, + { + "required": [ + "bmffBox" + ], + "properties": { + "bmffBox": { + "type": "string", + "description": "XPath to the BMFF box of the part" + } + }, + "additionalProperties": false + } + ] + }, + "softBindingAssertion": { + "type": "object", + "description": "Soft binding assertion per C2PA CDDL soft-binding-map (c2pa.soft-binding).", + "required": [ + "alg", + "blocks" + ], + "properties": { + "alg": { + "type": "string", + "description": "Soft binding algorithm and version from C2PA soft binding algorithm list. If absent, taken from enclosing alg_soft." + }, + "blocks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/softBindingBlockMap" + }, + "description": "One or more soft bindings across some or all of the asset's content" + }, + "pad": { + "type": "string", + "description": "Zero-filled byte string for filling space (typically Base64 in JSON)" + }, + "pad2": { + "type": "string", + "description": "Zero-filled byte string for filling space (typically Base64 in JSON)" + }, + "name": { + "type": "string", + "description": "Human-readable description of what this hash covers" + }, + "alg-params": { + "type": "string", + "description": "CBOR byte string (e.g. Base64 in JSON) describing parameters of the soft binding algorithm" + }, + "bindingMetadata": { + "$ref": "#/definitions/softBindingMetadataMap", + "description": "Additional metadata of the soft binding" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Unused and deprecated" + } + }, + "additionalProperties": false + }, + "softBindingBlockMap": { + "type": "object", + "description": "Single soft binding block per C2PA CDDL soft-binding-block-map.", + "required": [ + "scope", + "value" + ], + "properties": { + "scope": { + "$ref": "#/definitions/softBindingScopeMap", + "description": "Scope of the digital content over which the soft binding is computed" + }, + "value": { + "type": "string", + "description": "CBOR byte string (e.g. Base64 in JSON), in algorithm-specific format, for the soft binding value over this block" + } + }, + "additionalProperties": false + }, + "softBindingScopeMap": { + "type": "object", + "description": "Scope of content for a soft binding block per C2PA CDDL soft-binding-scope-map.", + "properties": { + "extent": { + "type": "string", + "description": "Deprecated. CBOR byte string (e.g. Base64) describing the part of content over which the binding was computed" + }, + "timespan": { + "$ref": "#/definitions/softBindingTimespanMap", + "description": "Time range over which the soft binding value has been computed" + }, + "region": { + "$ref": "#/definitions/regionMap", + "description": "Region of interest (region-map)" + } + }, + "additionalProperties": false + }, + "softBindingTimespanMap": { + "type": "object", + "description": "Time range per C2PA CDDL soft-binding-timespan-map.", + "required": [ + "start", + "end" + ], + "properties": { + "start": { + "type": "integer", + "minimum": 0, + "description": "Start of the time range in milliseconds from media start (uint)" + }, + "end": { + "type": "integer", + "minimum": 0, + "description": "End of the time range in milliseconds from media start (uint)" + } + }, + "additionalProperties": false + }, + "softBindingMetadataMap": { + "type": "object", + "description": "Additional metadata per C2PA CDDL soft-binding-metadata-map.", + "properties": { + "description": { + "type": "string", + "description": "Additional description of the implementation or author of the binding or algorithm" + }, + "contact": { + "type": "string", + "description": "Contact information for the implementation or author of the binding or algorithm" + }, + "informationalUrl": { + "type": "string", + "description": "Web page with more details about the implementation or author of the binding or algorithm" + } + }, + "additionalProperties": true + }, + "actionsAssertionV1": { + "type": "object", + "description": "Actions assertion (v1) per C2PA CDDL actions-map; list of action-items-map.", + "properties": { + "actions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/actionItemV1" + }, + "description": "List of actions (action-items-map)" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "required": [ + "actions" + ], + "additionalProperties": false + }, + "actionItemV1": { + "type": "object", + "description": "Single action (v1) per C2PA CDDL action-items-map.", + "properties": { + "action": { + "type": "string", + "description": "Action type (e.g., c2pa.created, c2pa.edited, c2pa.converted); $action-choice" + }, + "when": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the action occurred (tdate)" + }, + "softwareAgent": { + "type": "string", + "description": "The software agent that performed the action" + }, + "changed": { + "type": "string", + "description": "Semicolon-delimited list of parts of the resource that were changed since the previous event history" + }, + "instanceID": { + "type": "string", + "description": "xmpMM:InstanceID for the modified (output) resource (buuid)" + }, + "parameters": { + "$ref": "#/definitions/parametersMapV1", + "description": "Additional parameters of the action" + }, + "digitalSourceType": { + "type": "string", + "description": "One of the defined source types at https://cv.iptc.org/newscodes/digitalsourcetype/" + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "parametersMapV1": { + "type": "object", + "description": "Action parameters (v1) per C2PA CDDL parameters-map.", + "properties": { + "ingredient": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI to the ingredient assertion this action acts on" + }, + "description": { + "type": "string", + "description": "Additional description of the action" + } + }, + "additionalProperties": true + }, + "actionsAssertionV2": { + "type": "object", + "description": "Actions assertion (v2) per C2PA CDDL actions-map-v2.", + "properties": { + "actions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/actionItemV2" + }, + "description": "List of actions (action-item-map-v2)" + }, + "templates": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/actionTemplateV2" + }, + "description": "List of templates for the actions" + }, + "softwareAgents": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/softwareAgent" + }, + "description": "List of software/hardware that performed the actions (generator-info maps)" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + }, + "allActionsIncluded": { + "type": "boolean", + "description": "If true, indicates that all actions performed are included in this assertion" + } + }, + "required": [ + "actions" + ], + "additionalProperties": false + }, + "actionItemV2": { + "type": "object", + "description": "Single action (v2) per C2PA CDDL action-item-map-v2.", + "properties": { + "action": { + "type": "string", + "description": "Action type; $action-choice" + }, + "softwareAgent": { + "$ref": "#/definitions/softwareAgent", + "description": "Description of the software/hardware that did the action (generator-info map)" + }, + "softwareAgentIndex": { + "type": "integer", + "description": "0-based index into the softwareAgents array in the actions assertion" + }, + "description": { + "type": "string", + "description": "Additional description of the action, important for custom actions" + }, + "digitalSourceType": { + "type": "string", + "description": "One of the defined source types at https://cv.iptc.org/newscodes/digitalsourcetype/" + }, + "when": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the action occurred (tdate)" + }, + "changes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/regionMap" + }, + "description": "Regions of interest of the resource that were changed" + }, + "related": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/actionItemV2" + }, + "description": "List of related actions" + }, + "reason": { + "type": "string", + "description": "Reason the action was performed (required when action is c2pa.redacted); $action-reason" + }, + "parameters": { + "$ref": "#/definitions/parametersMapV2", + "description": "Additional parameters of the action" + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "actionTemplateV2": { + "type": "object", + "description": "Action template (v2) per C2PA CDDL action-template-map-v2.", + "properties": { + "action": { + "type": "string", + "description": "Action type or \"*\" for templates; $action-choice / \"*\"" + }, + "softwareAgent": { + "$ref": "#/definitions/softwareAgent", + "description": "Description of the software/hardware (generator-info map)" + }, + "softwareAgentIndex": { + "type": "integer", + "description": "0-based index into the softwareAgents array" + }, + "description": { + "type": "string", + "description": "Additional description of the action" + }, + "digitalSourceType": { + "type": "string", + "description": "Digital source type URI" + }, + "icon": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to an embedded data assertion" + }, + "templateParameters": { + "type": "object", + "description": "Additional parameters of the template (parameters-common-map-v2)", + "additionalProperties": true + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "regionMap": { + "type": "object", + "description": "Region of interest per C2PA CDDL region-map (referenced by action-item-map-v2 changes).", + "additionalProperties": true + }, + "parametersMapV2": { + "type": "object", + "description": "Action parameters (v2) per C2PA CDDL parameters-map-v2.", + "properties": { + "redacted": { + "type": "string", + "description": "JUMBF URI to the redacted assertion (required when action is c2pa.redacted)" + }, + "ingredients": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/hashedUriMap" + }, + "description": "List of hashed JUMBF URIs to ingredient (v2 or v3) assertion(s) this action acts on" + }, + "sourceLanguage": { + "type": "string", + "description": "BCP-47 code of the source language for c2pa.translated" + }, + "targetLanguage": { + "type": "string", + "description": "BCP-47 code of the target language for c2pa.translated" + }, + "multipleInstances": { + "type": "boolean", + "description": "Whether this action was performed multiple times" + } + }, + "additionalProperties": true + }, + "softwareAgent": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the software" + }, + "version": { + "type": "string", + "description": "Version of the software" + }, + "icon": { + "$ref": "#/definitions/hashedUriMap", + "description": "Icon representing the software/hardware" + }, + "operating_system": { + "type": "string", + "description": "Operating system the software runs on" + } + }, + "required": [ + "name" + ], + "additionalProperties": true + }, + "signature": { + "type": "object", + "properties": { + "algorithm": { + "type": "string", + "description": "Algorithm used for signing (e.g., SHA256withECDSA)" + }, + "serialNumber": { + "type": "string", + "description": "Certificate serial number" + }, + "certificate": { + "type": "object", + "description": "Certificate details", + "properties": { + "serialNumber": { + "type": "string" + }, + "issuer": { + "$ref": "#/definitions/distinguishedName" + }, + "subject": { + "$ref": "#/definitions/distinguishedName" + }, + "validity": { + "type": "object", + "properties": { + "notBefore": { + "type": "string", + "format": "date-time" + }, + "notAfter": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "subject": { + "$ref": "#/definitions/distinguishedName" + }, + "issuer": { + "$ref": "#/definitions/distinguishedName" + }, + "validity": { + "type": "object", + "properties": { + "notBefore": { + "type": "string", + "format": "date-time", + "description": "Alternate field name for validity start" + }, + "notAfter": { + "type": "string", + "format": "date-time", + "description": "Alternate field name for validity end" + } + } + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Time when the claim was signed (e.g. from TSA timestamp); RFC 3339 format" + } + }, + "additionalProperties": false + }, + "distinguishedName": { + "type": "object", + "description": "X.509 Distinguished Name components", + "properties": { + "C": { + "type": "string", + "description": "Country" + }, + "ST": { + "type": "string", + "description": "State or province" + }, + "L": { + "type": "string", + "description": "Locality" + }, + "O": { + "type": "string", + "description": "Organization" + }, + "OU": { + "type": "string", + "description": "Organizational unit" + }, + "CN": { + "type": "string", + "description": "Common name" + }, + "E": { + "type": "string", + "description": "Email address" + }, + "2.5.4.97": { + "type": "string", + "description": "Organization identifier OID" + } + }, + "additionalProperties": false + }, + "status": { + "type": "object", + "description": "Validation status information", + "properties": { + "signature": { + "type": "string", + "description": "Signature validation status" + }, + "assertion": { + "type": "object", + "description": "Status of each assertion", + "additionalProperties": { + "type": "string" + } + }, + "content": { + "type": "string", + "description": "Content validation status" + }, + "trust": { + "type": "string", + "description": "Trust validation status" + } + }, + "additionalProperties": false + }, + "ingredientAssertionV1": { + "type": "object", + "description": "Ingredient assertion (v1) per C2PA CDDL ingredient-map.", + "required": [ + "dc:title", + "dc:format", + "instanceID", + "relationship" + ], + "properties": { + "dc:title": { + "type": "string", + "description": "Name of the ingredient (Dublin Core)" + }, + "dc:format": { + "type": "string", + "description": "Media type of the ingredient (format-string)" + }, + "documentID": { + "type": "string", + "description": "Value of the ingredient's xmpMM:DocumentID" + }, + "instanceID": { + "type": "string", + "description": "Unique identifier, e.g. value of the ingredient's xmpMM:InstanceID" + }, + "relationship": { + "type": "string", + "description": "Relationship of this ingredient to the asset; $relation-choice (parentOf, componentOf, inputTo)" + }, + "c2pa_manifest": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to the C2PA Manifest of the ingredient" + }, + "thumbnail": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to an ingredient thumbnail" + }, + "validationStatus": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "Status map per C2PA CDDL $status-map", + "additionalProperties": true + }, + "description": "Validation status of the ingredient" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "additionalProperties": false + }, + "ingredientAssertionV2": { + "type": "object", + "description": "Ingredient assertion (v2) per C2PA CDDL ingredient-map-v2.", + "required": [ + "dc:title", + "dc:format", + "relationship" + ], + "properties": { + "dc:title": { + "type": "string", + "description": "Name of the ingredient (Dublin Core)" + }, + "dc:format": { + "type": "string", + "description": "Media type of the ingredient (format-string)" + }, + "relationship": { + "type": "string", + "description": "Relationship of this ingredient to the asset; $relation-choice" + }, + "documentID": { + "type": "string", + "description": "Value of the ingredient's xmpMM:DocumentID" + }, + "instanceID": { + "type": "string", + "description": "Unique identifier, e.g. value of the ingredient's xmpMM:InstanceID" + }, + "data": { + "description": "Hashed URI to embedded data assertion or hashed_ext_uri to external data", + "oneOf": [ + { + "$ref": "#/definitions/hashedUriMap" + }, + { + "$ref": "#/definitions/hashedExtUriMap" + } + ] + }, + "data_types": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "Asset type per C2PA CDDL $asset-type-map", + "additionalProperties": true + }, + "description": "Additional information about the data's type" + }, + "c2pa_manifest": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to the C2PA Manifest of the ingredient" + }, + "thumbnail": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to a thumbnail in an embedded data assertion" + }, + "validationStatus": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "Status map per C2PA CDDL $status-map", + "additionalProperties": true + }, + "description": "Validation status of the ingredient" + }, + "description": { + "type": "string", + "description": "Additional description of the ingredient" + }, + "informational_URI": { + "type": "string", + "description": "URI to an informational page about the ingredient or its data" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "additionalProperties": false + }, + "ingredientAssertionV3": { + "type": "object", + "description": "Ingredient assertion (v3) per C2PA CDDL ingredient-map-v3.", + "required": [ + "relationship" + ], + "properties": { + "dc:title": { + "type": "string", + "description": "Name of the ingredient (Dublin Core)" + }, + "dc:format": { + "type": "string", + "description": "Media type of the ingredient (format-string)" + }, + "relationship": { + "type": "string", + "description": "Relationship of this ingredient to the asset; $relation-choice" + }, + "validationResults": { + "$ref": "#/definitions/validationResults", + "description": "Results from the claim generator performing full validation on the ingredient asset ($validation-results-map)" + }, + "instanceID": { + "type": "string", + "description": "Unique identifier, e.g. value of the ingredient's xmpMM:InstanceID" + }, + "data": { + "description": "Hashed URI to embedded data assertion or hashed_ext_uri to external data", + "oneOf": [ + { + "$ref": "#/definitions/hashedUriMap" + }, + { + "$ref": "#/definitions/hashedExtUriMap" + } + ] + }, + "dataTypes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "Asset type per C2PA CDDL $asset-type-map", + "additionalProperties": true + }, + "description": "Additional information about the data's type (v3 camelCase)" + }, + "activeManifest": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI to the box corresponding to the active manifest of the ingredient" + }, + "claimSignature": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI to the Claim Signature box in the C2PA Manifest of the ingredient" + }, + "thumbnail": { + "$ref": "#/definitions/hashedUriMap", + "description": "Hashed URI reference to a thumbnail in an embedded data assertion" + }, + "description": { + "type": "string", + "description": "Additional description of the ingredient" + }, + "informationalURI": { + "type": "string", + "description": "URI to an informational page about the ingredient or its data (v3 camelCase)" + }, + "softBindingsMatched": { + "type": "boolean", + "description": "Whether soft bindings were matched" + }, + "softBindingAlgorithmsMatched": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "Array of algorithm names used for discovering the active manifest" + }, + "metadata": { + "$ref": "#/definitions/assertionMetadataMap" + } + }, + "additionalProperties": false + }, + "hashedExtUriMap": { + "type": "object", + "description": "A data structure to store a reference to an external URL and its hash per C2PA CDDL $hashed-ext-uri-map. Contains the URL, the cryptographic hash algorithm identifier, hash, and optional MIME type, size, and data types. Used in ingredient v2/v3 data field.", + "properties": { + "url": { + "type": "string", + "description": "HTTP/HTTPS URI reference to external data", + "format": "uri" + }, + "alg": { + "type": "string", + "description": "String identifying the cryptographic hash algorithm used to compute the hash on this URI's data, taken from the C2PA hash algorithm identifier list." + }, + "hash": { + "type": "string", + "description": "Base64-encoded hash value of the external data" + }, + "dc:format": { + "type": "string", + "description": "IANA media type of the data (optional)" + }, + "size": { + "type": "integer", + "description": "Number of bytes of data (optional)" + }, + "data_types": { + "type": "array", + "description": "Additional information about the data's type (optional)", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "required": [ + "url", + "alg", + "hash" + ], + "additionalProperties": false + }, + "thumbnailAssertion": { + "type": "object", + "description": "Thumbnail assertion", + "properties": { + "thumbnailType": { + "type": "integer", + "description": "Thumbnail type (0=claim, 1=ingredient)" + }, + "mimeType": { + "type": "string", + "description": "MIME type of thumbnail" + } + }, + "additionalProperties": false + }, + "hashBmffAssertion": { + "type": "object", + "description": "BMFF (ISO Base Media File Format) hash assertion", + "properties": { + "exclusions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "xpath": { + "type": "string" + }, + "length": { + "type": [ + "integer", + "null" + ] + }, + "data": { + "type": [ + "array", + "null" + ] + }, + "subset": { + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "object", + "null" + ] + }, + "flags": { + "type": [ + "object", + "null" + ] + }, + "exact": { + "type": [ + "boolean", + "null" + ] + } + } + } + }, + "algorithm": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "validationResults": { + "type": "object", + "description": "Output of validation_results(): status codes for active manifest and ingredient deltas", + "properties": { + "activeManifest": { + "$ref": "#/definitions/statusCodes", + "description": "Validation status codes for the active manifest" + }, + "ingredientDeltas": { + "type": "array", + "description": "Validation deltas per ingredient assertion", + "items": { + "$ref": "#/definitions/ingredientDeltaValidationResult" + } + } + }, + "required": [ + "activeManifest" + ], + "additionalProperties": false + }, + "statusCodes": { + "type": "object", + "description": "Success, informational, and failure validation status codes", + "properties": { + "success": { + "type": "array", + "items": { + "$ref": "#/definitions/validationStatusEntry" + } + }, + "informational": { + "type": "array", + "items": { + "$ref": "#/definitions/validationStatusEntry" + } + }, + "failure": { + "type": "array", + "items": { + "$ref": "#/definitions/validationStatusEntry" + } + } + }, + "required": [ + "success", + "informational", + "failure" + ], + "additionalProperties": false + }, + "validationStatusEntry": { + "type": "object", + "description": "Single validation status (code, optional url and explanation)", + "properties": { + "code": { + "type": "string", + "description": "Validation status code" + }, + "url": { + "type": "string", + "description": "JUMBF URI where status applies" + }, + "explanation": { + "type": "string", + "description": "Human-readable explanation" + } + }, + "required": [ + "code" + ], + "additionalProperties": false + }, + "ingredientDeltaValidationResult": { + "type": "object", + "description": "Validation deltas for one ingredient's manifest", + "properties": { + "ingredientAssertionURI": { + "type": "string", + "description": "JUMBF URI to the ingredient assertion" + }, + "validationDeltas": { + "$ref": "#/definitions/statusCodes" + } + }, + "required": [ + "ingredientAssertionURI", + "validationDeltas" + ], + "additionalProperties": false + } + } +} diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index 5416e2eaa..ea3509929 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -916,7 +916,7 @@ impl CrJsonReader { if let Some(cert_info) = self.parse_certificate(&sig_info.cert_chain)? { // Add serial number (hex format) if let Some(serial) = cert_info.serial_number { - claim_signature.insert("serial_number".to_string(), json!(serial)); + claim_signature.insert("serialNumber".to_string(), json!(serial)); } // Add issuer DN components @@ -973,8 +973,8 @@ impl CrJsonReader { DateTime::from_timestamp(not_after.unix_timestamp(), 0) .ok_or(Error::CoseInvalidCert)?; details.validity = Some(json!({ - "not_before": not_before_chrono.to_rfc3339(), - "not_after": not_after_chrono.to_rfc3339() + "notBefore": not_before_chrono.to_rfc3339(), + "notAfter": not_after_chrono.to_rfc3339() })); Ok(Some(details)) From 4d56b7576941332661b61c33f6d953fd18326989 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Wed, 4 Mar 2026 23:51:09 -0500 Subject: [PATCH 11/18] Enhance CrJSON schema and reader to include jsonGenerator and detailed certificate information. Updated schema to require manifests and jsonGenerator fields, and modified CrJsonReader to build and include jsonGenerator in the output. Improved claim signature handling to incorporate certificate details and timestamp information. --- cli/schemas/crJSON-schema.json | 126 +++++++++++------- sdk/src/cr_json_reader.rs | 196 ++++++++++++++++++++-------- sdk/src/crypto/cose/mod.rs | 3 +- sdk/src/crypto/cose/sigtst.rs | 10 ++ sdk/src/crypto/time_stamp/mod.rs | 4 +- sdk/src/crypto/time_stamp/verify.rs | 59 +++++++++ 6 files changed, 297 insertions(+), 101 deletions(-) diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json index 4ee0e09a1..298dc779d 100644 --- a/cli/schemas/crJSON-schema.json +++ b/cli/schemas/crJSON-schema.json @@ -5,7 +5,9 @@ "description": "JSON Schema for Content Credential JSON (CrJSON) documents. CrJSON does not include asset_info, content, or metadata.", "type": "object", "required": [ - "@context" + "@context", + "manifests", + "jsonGenerator" ], "properties": { "@context": { @@ -43,10 +45,40 @@ "validationResults": { "description": "Validation results from validation (active manifest and ingredient deltas)", "$ref": "#/definitions/validationResults" + }, + "jsonGenerator": { + "description": "Information about the claim generator, including date stamp", + "$ref": "#/definitions/jsonGenerator" } }, "additionalProperties": true, "definitions": { + "jsonGenerator": { + "type": "object", + "description": "Information about the claim generator (generator-info fields) plus date stamp", + "properties": { + "name": { + "type": "string", + "description": "Name of the software" + }, + "version": { + "type": "string", + "description": "Version of the software (SemVer 2.0: major.minor.patch with optional pre-release and build)", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "date": { + "type": "string", + "format": "date-time", + "description": "Date and time when the CrJSON was generated (ISO 8601)" + } + }, + "required": [ + "name", + "version", + "date" + ], + "additionalProperties": true + }, "manifest": { "type": "object", "required": [ @@ -1057,49 +1089,18 @@ ], "additionalProperties": true }, - "signature": { + "certificateInfo": { "type": "object", + "description": "Certificate details (serial number, issuer, subject, validity)", "properties": { - "algorithm": { - "type": "string", - "description": "Algorithm used for signing (e.g., SHA256withECDSA)" - }, "serialNumber": { "type": "string", "description": "Certificate serial number" }, - "certificate": { - "type": "object", - "description": "Certificate details", - "properties": { - "serialNumber": { - "type": "string" - }, - "issuer": { - "$ref": "#/definitions/distinguishedName" - }, - "subject": { - "$ref": "#/definitions/distinguishedName" - }, - "validity": { - "type": "object", - "properties": { - "notBefore": { - "type": "string", - "format": "date-time" - }, - "notAfter": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "subject": { + "issuer": { "$ref": "#/definitions/distinguishedName" }, - "issuer": { + "subject": { "$ref": "#/definitions/distinguishedName" }, "validity": { @@ -1107,22 +1108,57 @@ "properties": { "notBefore": { "type": "string", - "format": "date-time", - "description": "Alternate field name for validity start" + "format": "date-time" }, "notAfter": { "type": "string", - "format": "date-time", - "description": "Alternate field name for validity end" + "format": "date-time" } } - }, - "timestamp": { - "type": "string", - "format": "date-time", - "description": "Time when the claim was signed (e.g. from TSA timestamp); RFC 3339 format" } }, + "required": [ + "serialNumber", + "issuer", + "subject", + "validity" + ], + "additionalProperties": false + }, + "signature": { + "type": "object", + "properties": { + "algorithm": { + "type": "string", + "description": "Algorithm used for signing (e.g., SHA256withECDSA)" + }, + "certificateInfo": { + "$ref": "#/definitions/certificateInfo" + }, + "timeStampInfo": { + "type": "object", + "description": "Timestamp information (e.g. from TSA), when present", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Time when the claim was signed (e.g. from TSA timestamp); RFC 3339 format" + }, + "certificateInfo": { + "$ref": "#/definitions/certificateInfo" + } + }, + "required": [ + "timestamp", + "certificateInfo" + ] + }, + "additionalProperties": false + }, + "required": [ + "algorithm", + "certificateInfo" + ], "additionalProperties": false }, "distinguishedName": { diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index ea3509929..9b38db245 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -32,10 +32,15 @@ use crate::{ assertion::AssertionData, claim::Claim, context::Context, - crypto::base64, + crypto::{ + base64, + cose::{parse_cose_sign1, timestamp_token_bytes_from_sign1}, + time_stamp::tsa_signer_cert_der_from_token, + }, error::{Error, Result}, jumbf::labels::to_absolute_uri, reader::{AsyncPostValidator, MaybeSend, PostValidator, Reader}, + status_tracker::StatusTracker, validation_results::{ validation_codes::{ SIGNING_CREDENTIAL_EXPIRED, SIGNING_CREDENTIAL_INVALID, SIGNING_CREDENTIAL_TRUSTED, @@ -213,9 +218,21 @@ impl CrJsonReader { result["validationResults"] = serde_json::to_value(validation_results)?; } + // jsonGenerator: claim_generator_info fields from the (active) manifest + date (ISO 8601). + result["jsonGenerator"] = self.build_json_generator()?; + Ok(result) } + /// Build the root-level jsonGenerator object: the CrJSON is produced by c2pa-rs (this library). + fn build_json_generator(&self) -> Result { + Ok(json!({ + "name": "c2pa-rs", + "version": env!("CARGO_PKG_VERSION"), + "date": Utc::now().to_rfc3339() + })) + } + /// Get the CrJsonReader as a JSON string pub fn json(&self) -> String { match self.to_json_value() { @@ -253,8 +270,9 @@ impl CrJsonReader { } // Build signature object (required per manifest schema; use empty object when no signature info) + let claim_ref = self.inner.store.get_claim(label); let signature = self - .build_claim_signature(manifest)? + .build_claim_signature(manifest, claim_ref)? .unwrap_or_else(|| Value::Object(Map::new())); manifest_obj.insert("signature".to_string(), signature); @@ -894,7 +912,11 @@ impl CrJsonReader { } /// Build claim_signature object with detailed certificate information - fn build_claim_signature(&self, manifest: &Manifest) -> Result> { + fn build_claim_signature( + &self, + manifest: &Manifest, + claim: Option<&Claim>, + ) -> Result> { let sig_info = match manifest.signature_info() { Some(info) => info, None => return Ok(None), @@ -907,33 +929,65 @@ impl CrJsonReader { claim_signature.insert("algorithm".to_string(), json!(alg.to_string())); } - // Add signing timestamp (e.g. from TSA) when available - if let Some(time) = &sig_info.time { - claim_signature.insert("timestamp".to_string(), json!(time)); - } - - // Parse certificate to get detailed DN components and validity + // Parse certificate to get detailed DN components and validity (nested in certificateInfo) if let Some(cert_info) = self.parse_certificate(&sig_info.cert_chain)? { - // Add serial number (hex format) + let mut cert_info_obj = Map::new(); if let Some(serial) = cert_info.serial_number { - claim_signature.insert("serialNumber".to_string(), json!(serial)); + cert_info_obj.insert("serialNumber".to_string(), json!(serial)); } - - // Add issuer DN components if let Some(issuer) = cert_info.issuer { - claim_signature.insert("issuer".to_string(), json!(issuer)); + cert_info_obj.insert("issuer".to_string(), json!(issuer)); } - - // Add subject DN components if let Some(subject) = cert_info.subject { - claim_signature.insert("subject".to_string(), json!(subject)); + cert_info_obj.insert("subject".to_string(), json!(subject)); } - - // Add validity period if let Some(validity) = cert_info.validity { - claim_signature.insert("validity".to_string(), json!(validity)); + cert_info_obj.insert("validity".to_string(), validity); + } + if !cert_info_obj.is_empty() { + claim_signature.insert("certificateInfo".to_string(), Value::Object(cert_info_obj)); + } + } + + // Build timeStampInfo when we have a signing time and/or TSA certificate + let has_ts_time = sig_info.time.is_some(); + let mut tsa_cert_info_obj = Map::new(); + if let Some(claim) = claim { + if let Ok(data) = claim.data() { + let sig_bytes = claim.signature_val(); + let mut log = StatusTracker::default(); + if let Ok(sign1) = parse_cose_sign1(sig_bytes, data.as_ref(), &mut log) { + if let Some(token_bytes) = timestamp_token_bytes_from_sign1(&sign1) { + if let Ok(Some(tsa_der)) = tsa_signer_cert_der_from_token(&token_bytes) { + if let Ok(Some(tsa_info)) = self.parse_certificate_from_der(&tsa_der) { + if let Some(serial) = tsa_info.serial_number { + tsa_cert_info_obj.insert("serialNumber".to_string(), json!(serial)); + } + if let Some(issuer) = tsa_info.issuer { + tsa_cert_info_obj.insert("issuer".to_string(), json!(issuer)); + } + if let Some(subject) = tsa_info.subject { + tsa_cert_info_obj.insert("subject".to_string(), json!(subject)); + } + if let Some(validity) = tsa_info.validity { + tsa_cert_info_obj.insert("validity".to_string(), validity); + } + } + } + } + } } } + if has_ts_time || !tsa_cert_info_obj.is_empty() { + let mut time_stamp_info = Map::new(); + if let Some(time) = &sig_info.time { + time_stamp_info.insert("timestamp".to_string(), json!(time)); + } + if !tsa_cert_info_obj.is_empty() { + time_stamp_info.insert("certificateInfo".to_string(), Value::Object(tsa_cert_info_obj)); + } + claim_signature.insert("timeStampInfo".to_string(), Value::Object(time_stamp_info)); + } Ok(Some(Value::Object(claim_signature))) } @@ -980,6 +1034,28 @@ impl CrJsonReader { Ok(Some(details)) } + /// Parse a single certificate (DER) to extract DN components and validity. + fn parse_certificate_from_der(&self, der: &[u8]) -> Result> { + let (_, cert) = X509Certificate::from_der(der).map_err(|_e| Error::CoseInvalidCert)?; + let mut details = CertificateDetails::default(); + details.serial_number = Some(format!("{:x}", cert.serial)); + details.issuer = Some(self.extract_dn_components(cert.issuer())?); + details.subject = Some(self.extract_dn_components(cert.subject())?); + let not_before = cert.validity().not_before.to_datetime(); + let not_after = cert.validity().not_after.to_datetime(); + let not_before_chrono: DateTime = + DateTime::from_timestamp(not_before.unix_timestamp(), 0) + .ok_or(Error::CoseInvalidCert)?; + let not_after_chrono: DateTime = + DateTime::from_timestamp(not_after.unix_timestamp(), 0) + .ok_or(Error::CoseInvalidCert)?; + details.validity = Some(json!({ + "notBefore": not_before_chrono.to_rfc3339(), + "notAfter": not_after_chrono.to_rfc3339() + })); + Ok(Some(details)) + } + /// Extract DN components from X.509 name fn extract_dn_components( &self, @@ -1053,38 +1129,32 @@ impl CrJsonReader { if !cert_chain.is_empty() { // Parse the first certificate (end entity) if let Ok((_rem, cert)) = X509Certificate::from_der(&cert_chain[0]) { - // Extract serial number in hex format - signature_obj.insert("serial_number".to_string(), json!(format!("{:x}", cert.serial))); - - // Extract issuer DN components + let mut cert_info = Map::new(); + cert_info.insert("serialNumber".to_string(), json!(format!("{:x}", cert.serial))); if let Ok(issuer) = Self::extract_dn_components_static(cert.issuer()) { - signature_obj.insert("issuer".to_string(), json!(issuer)); + cert_info.insert("issuer".to_string(), json!(issuer)); } - - // Extract subject DN components if let Ok(subject) = Self::extract_dn_components_static(cert.subject()) { - signature_obj.insert("subject".to_string(), json!(subject)); + cert_info.insert("subject".to_string(), json!(subject)); } - - // Extract validity period let not_before = cert.validity().not_before.to_datetime(); let not_after = cert.validity().not_after.to_datetime(); - if let Some(not_before_chrono) = DateTime::::from_timestamp(not_before.unix_timestamp(), 0) { if let Some(not_after_chrono) = DateTime::::from_timestamp(not_after.unix_timestamp(), 0) { - signature_obj.insert("validity".to_string(), json!({ - "not_before": not_before_chrono.to_rfc3339(), - "not_after": not_after_chrono.to_rfc3339() + cert_info.insert("validity".to_string(), json!({ + "notBefore": not_before_chrono.to_rfc3339(), + "notAfter": not_after_chrono.to_rfc3339() })); } } + signature_obj.insert("certificateInfo".to_string(), Value::Object(cert_info)); } } } // If no certificate chain was found, try to extract Verifiable Credential // (for cawg.identity_claims_aggregation signatures) - if !signature_obj.contains_key("serial_number") { + if !signature_obj.contains_key("certificateInfo") { if let Some(payload) = sign1.payload.as_ref() { // Try to parse payload as JSON (W3C Verifiable Credential) if let Ok(vc_value) = serde_json::from_slice::(payload) { @@ -1310,6 +1380,13 @@ mod tests { // Verify required fields assert!(json_value.get("@context").is_some()); assert!(json_value.get("manifests").is_some()); + assert!(json_value.get("jsonGenerator").is_some(), "jsonGenerator must be present"); + + // jsonGenerator is c2pa-rs (this library) with name, version, date + let jg = &json_value["jsonGenerator"]; + assert_eq!(jg.get("name").and_then(|v| v.as_str()), Some("c2pa-rs")); + assert!(jg.get("version").and_then(|v| v.as_str()).is_some(), "jsonGenerator.version required"); + assert!(jg.get("date").is_some(), "jsonGenerator.date required"); // Verify manifests is an array assert!(json_value["manifests"].is_array()); @@ -1379,23 +1456,28 @@ mod tests { // Verify algorithm is present assert!(sig.get("algorithm").is_some(), "signature should have algorithm"); - // Verify certificate details are decoded (not just algorithm) - // Should have serial_number, issuer, subject, and validity for X.509 certificates + // Verify certificate details are decoded in certificateInfo (not just algorithm) + let cert_info = sig.get("certificateInfo").and_then(|c| c.as_object()); + assert!( + cert_info.is_some(), + "signature should have certificateInfo from decoded certificate" + ); + let cert_info = cert_info.unwrap(); assert!( - sig.get("serial_number").is_some(), - "signature should have serial_number from decoded certificate" + cert_info.get("serialNumber").is_some(), + "certificateInfo should have serialNumber" ); assert!( - sig.get("issuer").is_some(), - "signature should have issuer from decoded certificate" + cert_info.get("issuer").is_some(), + "certificateInfo should have issuer" ); assert!( - sig.get("subject").is_some(), - "signature should have subject from decoded certificate" + cert_info.get("subject").is_some(), + "certificateInfo should have subject" ); assert!( - sig.get("validity").is_some(), - "signature should have validity from decoded certificate" + cert_info.get("validity").is_some(), + "certificateInfo should have validity" ); Ok(()) @@ -1435,28 +1517,34 @@ mod tests { let signature = &cawg_identity["signature"]; assert!(signature.is_object(), "signature should be an object, not an array"); - // For X.509 signatures (sig_type: cawg.x509.cose), verify certificate details + // For X.509 signatures (sig_type: cawg.x509.cose), verify certificate details in certificateInfo let sig_type = cawg_identity["signer_payload"]["sig_type"].as_str().unwrap(); if sig_type == "cawg.x509.cose" { assert!( signature.get("algorithm").is_some(), "signature should have algorithm" ); + let cert_info = signature.get("certificateInfo").and_then(|c| c.as_object()); + assert!( + cert_info.is_some(), + "X.509 signature should have certificateInfo" + ); + let cert_info = cert_info.unwrap(); assert!( - signature.get("serial_number").is_some(), - "X.509 signature should have serial_number" + cert_info.get("serialNumber").is_some(), + "certificateInfo should have serialNumber" ); assert!( - signature.get("issuer").is_some(), - "X.509 signature should have issuer DN components" + cert_info.get("issuer").is_some(), + "certificateInfo should have issuer DN components" ); assert!( - signature.get("subject").is_some(), - "X.509 signature should have subject DN components" + cert_info.get("subject").is_some(), + "certificateInfo should have subject DN components" ); assert!( - signature.get("validity").is_some(), - "X.509 signature should have validity period" + cert_info.get("validity").is_some(), + "certificateInfo should have validity period" ); } diff --git a/sdk/src/crypto/cose/mod.rs b/sdk/src/crypto/cose/mod.rs index 32ca63675..d82cf258f 100644 --- a/sdk/src/crypto/cose/mod.rs +++ b/sdk/src/crypto/cose/mod.rs @@ -47,7 +47,8 @@ pub use sign1::{ mod sigtst; pub(crate) use sigtst::{ add_sigtst_header, add_sigtst_header_async, get_cose_tst_info, - timestamptoken_from_timestamprsp, validate_cose_tst_info, validate_cose_tst_info_async, + timestamptoken_from_timestamprsp, timestamp_token_bytes_from_sign1, + validate_cose_tst_info, validate_cose_tst_info_async, }; mod time_stamp_storage; diff --git a/sdk/src/crypto/cose/sigtst.rs b/sdk/src/crypto/cose/sigtst.rs index 8bcf3d5ea..50672e26f 100644 --- a/sdk/src/crypto/cose/sigtst.rs +++ b/sdk/src/crypto/cose/sigtst.rs @@ -54,6 +54,16 @@ pub(crate) fn get_cose_tst_info(sign1: &coset::CoseSign1) -> Option<(&Value, Tim }) } +/// Extract the raw RFC 3161 timestamp token bytes from a COSE Sign1 (sigTst/sigTst2 header). +/// Returns the first token's bytes if present, for use when parsing TSA certificate info. +pub(crate) fn timestamp_token_bytes_from_sign1(sign1: &coset::CoseSign1) -> Option> { + let (sigtst, _) = get_cose_tst_info(sign1)?; + let mut time_cbor = Vec::new(); + coset::cbor::into_writer(sigtst, &mut time_cbor).ok()?; + let tst_container: TstContainer = coset::cbor::from_reader(time_cbor.as_slice()).ok()?; + tst_container.tst_tokens.first().map(|t| t.val.clone()) +} + /// Given a COSE signature, retrieve the `sigTst` header from it and validate /// the information within it. /// diff --git a/sdk/src/crypto/time_stamp/mod.rs b/sdk/src/crypto/time_stamp/mod.rs index d71216bad..a13b9f25f 100644 --- a/sdk/src/crypto/time_stamp/mod.rs +++ b/sdk/src/crypto/time_stamp/mod.rs @@ -28,4 +28,6 @@ mod response; pub(crate) use response::{ContentInfo, TimeStampResponse}; mod verify; -pub use verify::{verify_time_stamp, verify_time_stamp_async}; +pub use verify::{ + tsa_signer_cert_der_from_token, verify_time_stamp, verify_time_stamp_async, +}; diff --git a/sdk/src/crypto/time_stamp/verify.rs b/sdk/src/crypto/time_stamp/verify.rs index 68a677fab..4b1941db3 100644 --- a/sdk/src/crypto/time_stamp/verify.rs +++ b/sdk/src/crypto/time_stamp/verify.rs @@ -576,6 +576,65 @@ pub fn verify_time_stamp( Err(last_err) } +/// Extract the TSA signer certificate (DER) from an RFC 3161 timestamp token. +/// Does not verify the token; only parses it and returns the first signer's certificate. +pub fn tsa_signer_cert_der_from_token(ts: &[u8]) -> Result>, TimeStampError> { + let Some(sd) = signed_data_from_time_stamp_response(ts)? else { + return Ok(None); + }; + let Some(certs) = &sd.certificates else { + return Ok(None); + }; + let certs_vec = certs.to_vec(); + let cert_ders: Vec> = certs_vec + .iter() + .filter_map(|cc| { + if let CertificateChoices::Certificate(c) = cc { + rasn::der::encode(c).ok() + } else { + None + } + }) + .collect(); + if cert_ders.len() != certs_vec.len() { + return Err(TimeStampError::DecodeError( + "time stamp certificate could not be processed".to_string(), + )); + } + let Some(signer_info) = sd.signer_infos.to_vec().into_iter().next() else { + return Ok(None); + }; + let Some(cert_pos) = certs_vec.iter().position(|cc| { + let c = match cc { + CertificateChoices::Certificate(c) => c, + _ => return false, + }; + match &signer_info.sid { + SignerIdentifier::IssuerAndSerialNumber(sn) => { + sn.issuer == c.tbs_certificate.issuer + && sn.serial_number == c.tbs_certificate.serial_number + } + SignerIdentifier::SubjectKeyIdentifier(ski) => { + if let Some(extensions) = &c.tbs_certificate.extensions { + extensions.iter().any(|e| { + if e.extn_id + == Oid::JOINT_ISO_ITU_T_DS_CERTIFICATE_EXTENSION_SUBJECT_KEY_IDENTIFIER + { + return *ski == e.extn_value; + } + false + }) + } else { + false + } + } + } + }) else { + return Ok(None); + }; + Ok(Some(cert_ders[cert_pos].clone())) +} + fn generalized_time_to_datetime>>(gt: T) -> DateTime { gt.into() } From 636746afdce8a6bef5b51c4faa794efd6b986411 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Fri, 6 Mar 2026 12:13:13 -0500 Subject: [PATCH 12/18] added updated schema and then revised implementation to match --- cli/schemas/crJSON-schema.json | 342 +++++++++++++++++++--------- sdk/src/cr_json_reader.rs | 32 ++- sdk/tests/crjson/hash_assertions.rs | 2 +- sdk/tests/crjson/hash_encoding.rs | 11 +- sdk/tests/crjson/ingredients.rs | 34 +-- 5 files changed, 289 insertions(+), 132 deletions(-) diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json index 298dc779d..3117b6b27 100644 --- a/cli/schemas/crJSON-schema.json +++ b/cli/schemas/crJSON-schema.json @@ -55,7 +55,7 @@ "definitions": { "jsonGenerator": { "type": "object", - "description": "Information about the claim generator (generator-info fields) plus date stamp", + "description": "Information about the claim generator (generator-info fields) plus date stamp. CrJSON document-level; name/version align with claim.cddl generator-info-map; date is CrJSON-specific.", "properties": { "name": { "type": "string", @@ -81,6 +81,7 @@ }, "manifest": { "type": "object", + "description": "CrJSON manifest wrapper containing label, assertions, claim or claim.v2, signature, and status. Claim content: claim.cddl claim-map or claim-map-v2.", "required": [ "label", "assertions", @@ -128,7 +129,7 @@ }, "claimV1": { "type": "object", - "description": "Claim map (v1) per C2PA CDDL claim-map. Alternative to claim.v2; each manifest may contain either claim or claim.v2.", + "description": "Claim map (v1). CDDL: claim.cddl claim-map. Alternative to claim.v2; each manifest may contain either claim or claim.v2.", "required": [ "claim_generator", "claim_generator_info", @@ -197,7 +198,7 @@ }, "hashedUriMap": { "type": "object", - "description": "Hashed URI reference (url and hash) per C2PA CDDL $hashed-uri-map", + "description": "Hashed URI reference (url and hash). CDDL: hashed-uri.cddl $hashed-uri-map.", "required": [ "url", "hash" @@ -220,7 +221,7 @@ }, "assertionMetadataMap": { "type": "object", - "description": "Additional information about an assertion per C2PA CDDL $assertion-metadata-map.", + "description": "Additional information about an assertion. CDDL: assertion-metadata-common.cddl $assertion-metadata-map.", "properties": { "dateTime": { "type": "string", @@ -260,7 +261,7 @@ }, "sourceMap": { "type": "object", - "description": "Source of assertion data per C2PA CDDL source-map.", + "description": "Source of assertion data. CDDL: assertion-metadata-common.cddl source-map.", "required": [ "type" ], @@ -290,7 +291,7 @@ }, "ratingMap": { "type": "object", - "description": "Rating of an assertion item per C2PA CDDL rating-map.", + "description": "Rating of an assertion item. CDDL: assertion-metadata-common.cddl rating-map.", "required": [ "value" ], @@ -326,14 +327,14 @@ }, "localizationDataEntry": { "type": "object", - "description": "Localization dictionary per C2PA CDDL localization-data-entry (language keys to string).", + "description": "Localization dictionary (language keys to string). CDDL: assertion-metadata-common.cddl $localization-data-entry.", "additionalProperties": { "type": "string" } }, "claim": { "type": "object", - "description": "Claim (v2) per C2PA CDDL claim-map-v2.", + "description": "Claim (v2). CDDL: claim.cddl claim-map-v2.", "required": [ "instanceID", "claim_generator_info", @@ -400,6 +401,7 @@ }, "assertions": { "type": "object", + "description": "Container of assertion objects keyed by assertion label. Each key (e.g. c2pa.hash.data, c2pa.actions) corresponds to a C2PA assertion type; see definitions and INTERNAL/cddl for data-hash, boxes-hash, actions, ingredient, etc.", "properties": { "c2pa.hash.data": { "$ref": "#/definitions/hashDataAssertion" @@ -461,15 +463,12 @@ }, "hashDataAssertion": { "type": "object", + "description": "Data hash assertion (c2pa.hash.data). CDDL: data-hash.cddl data-hash-map, EXCLUSION_RANGE-map.", "properties": { "alg": { "type": "string", "description": "Hash algorithm" }, - "algorithm": { - "type": "string", - "description": "Alternative field name for hash algorithm" - }, "hash": { "type": "string", "description": "Base64-encoded hash value" @@ -478,13 +477,9 @@ "type": "string", "description": "Padding bytes" }, - "paddingLength": { - "type": "integer", - "description": "Length of padding" - }, - "padding2Length": { - "type": "integer", - "description": "Length of secondary padding" + "pad2": { + "type": "string", + "description": "Secondary padding bytes" }, "name": { "type": "string", @@ -511,11 +506,15 @@ } } }, + "required": [ + "hash", + "pad" + ], "additionalProperties": false }, "hashBoxesAssertion": { "type": "object", - "description": "Boxes hash assertion per C2PA CDDL box-map (c2pa.hash.boxes).", + "description": "Boxes hash assertion (c2pa.hash.boxes). CDDL: boxes-hash.cddl box-map.", "required": [ "boxes" ], @@ -537,7 +536,7 @@ }, "boxHashMap": { "type": "object", - "description": "Single box hash entry per C2PA CDDL box-hash-map.", + "description": "Single box hash entry. CDDL: boxes-hash.cddl box-hash-map.", "required": [ "names", "hash" @@ -587,7 +586,7 @@ }, "boxExclusionsMap": { "type": "object", - "description": "Exclusion range per C2PA CDDL box-exclusions-map.", + "description": "Exclusion range for a box. CDDL: boxes-hash.cddl box-exclusions-map.", "required": [ "start", "length" @@ -612,7 +611,7 @@ }, "hashMultiAssetAssertion": { "type": "object", - "description": "Multi-asset hash assertion per C2PA CDDL multi-asset-hash-map (c2pa.hash.multi-asset).", + "description": "Multi-asset hash assertion (c2pa.hash.multi-asset). CDDL: multi-asset-hash.cddl multi-asset-hash-map.", "required": [ "parts" ], @@ -630,7 +629,7 @@ }, "partHashMap": { "type": "object", - "description": "Part hash entry per C2PA CDDL part-hash-map.", + "description": "Part hash entry. CDDL: multi-asset-hash.cddl part-hash-map.", "required": [ "location", "hashAssertion" @@ -653,7 +652,7 @@ }, "locatorMap": { "type": "object", - "description": "Location of a part per C2PA CDDL locator-map (byte range or BMFF box path).", + "description": "Location of a part (byte range or BMFF box path). CDDL: multi-asset-hash.cddl locator-map.", "oneOf": [ { "required": [ @@ -690,7 +689,7 @@ }, "softBindingAssertion": { "type": "object", - "description": "Soft binding assertion per C2PA CDDL soft-binding-map (c2pa.soft-binding).", + "description": "Soft binding assertion (c2pa.soft-binding). CDDL: soft-binding.cddl soft-binding-map.", "required": [ "alg", "blocks" @@ -738,7 +737,7 @@ }, "softBindingBlockMap": { "type": "object", - "description": "Single soft binding block per C2PA CDDL soft-binding-block-map.", + "description": "Single soft binding block. CDDL: soft-binding.cddl soft-binding-block-map.", "required": [ "scope", "value" @@ -757,7 +756,7 @@ }, "softBindingScopeMap": { "type": "object", - "description": "Scope of content for a soft binding block per C2PA CDDL soft-binding-scope-map.", + "description": "Scope of content for a soft binding block. CDDL: soft-binding.cddl soft-binding-scope-map.", "properties": { "extent": { "type": "string", @@ -776,7 +775,7 @@ }, "softBindingTimespanMap": { "type": "object", - "description": "Time range per C2PA CDDL soft-binding-timespan-map.", + "description": "Time range for soft binding. CDDL: soft-binding.cddl soft-binding-timespan-map.", "required": [ "start", "end" @@ -797,7 +796,7 @@ }, "softBindingMetadataMap": { "type": "object", - "description": "Additional metadata per C2PA CDDL soft-binding-metadata-map.", + "description": "Additional metadata for soft binding. CDDL: soft-binding.cddl soft-binding-metadata-map.", "properties": { "description": { "type": "string", @@ -816,7 +815,7 @@ }, "actionsAssertionV1": { "type": "object", - "description": "Actions assertion (v1) per C2PA CDDL actions-map; list of action-items-map.", + "description": "Actions assertion (v1) (c2pa.actions). CDDL: actions.cddl actions-map.", "properties": { "actions": { "type": "array", @@ -837,7 +836,7 @@ }, "actionItemV1": { "type": "object", - "description": "Single action (v1) per C2PA CDDL action-items-map.", + "description": "Single action (v1). CDDL: actions.cddl action-items-map.", "properties": { "action": { "type": "string", @@ -876,7 +875,7 @@ }, "parametersMapV1": { "type": "object", - "description": "Action parameters (v1) per C2PA CDDL parameters-map.", + "description": "Action parameters (v1). CDDL: actions.cddl parameters-map.", "properties": { "ingredient": { "$ref": "#/definitions/hashedUriMap", @@ -891,7 +890,7 @@ }, "actionsAssertionV2": { "type": "object", - "description": "Actions assertion (v2) per C2PA CDDL actions-map-v2.", + "description": "Actions assertion (v2) (c2pa.actions.v2). CDDL: actions.cddl actions-map-v2.", "properties": { "actions": { "type": "array", @@ -932,7 +931,7 @@ }, "actionItemV2": { "type": "object", - "description": "Single action (v2) per C2PA CDDL action-item-map-v2.", + "description": "Single action (v2). CDDL: actions.cddl action-item-map-v2.", "properties": { "action": { "type": "string", @@ -991,7 +990,7 @@ }, "actionTemplateV2": { "type": "object", - "description": "Action template (v2) per C2PA CDDL action-template-map-v2.", + "description": "Action template (v2). CDDL: actions.cddl action-template-map-v2.", "properties": { "action": { "type": "string", @@ -1030,12 +1029,12 @@ }, "regionMap": { "type": "object", - "description": "Region of interest per C2PA CDDL region-map (referenced by action-item-map-v2 changes).", + "description": "Region of interest. CDDL: regions-of-interest.cddl region-map.", "additionalProperties": true }, "parametersMapV2": { "type": "object", - "description": "Action parameters (v2) per C2PA CDDL parameters-map-v2.", + "description": "Action parameters (v2). CDDL: actions.cddl parameters-map-v2.", "properties": { "redacted": { "type": "string", @@ -1066,6 +1065,7 @@ }, "softwareAgent": { "type": "object", + "description": "Generator/software agent info. CDDL: claim.cddl generator-info-map.", "properties": { "name": { "type": "string", @@ -1091,7 +1091,7 @@ }, "certificateInfo": { "type": "object", - "description": "Certificate details (serial number, issuer, subject, validity)", + "description": "Certificate details (serial number, issuer, subject, validity). CrJSON decoding of signing certificate; no CDDL rule in INTERNAL/cddl.", "properties": { "serialNumber": { "type": "string", @@ -1127,6 +1127,7 @@ }, "signature": { "type": "object", + "description": "Decoded claim signature (algorithm, certificate, optional timestamp). CrJSON decoding of Claim Signature box; no CDDL rule in INTERNAL/cddl.", "properties": { "algorithm": { "type": "string", @@ -1152,8 +1153,7 @@ "timestamp", "certificateInfo" ] - }, - "additionalProperties": false + } }, "required": [ "algorithm", @@ -1163,7 +1163,7 @@ }, "distinguishedName": { "type": "object", - "description": "X.509 Distinguished Name components", + "description": "X.509 Distinguished Name components. Used in signature/certificate decoding; no CDDL rule in INTERNAL/cddl.", "properties": { "C": { "type": "string", @@ -1202,7 +1202,7 @@ }, "status": { "type": "object", - "description": "Validation status information", + "description": "Validation status information (signature, assertion, content, trust). CrJSON manifest-level status; validation codes use validation-results.cddl status-map / status-codes-map.", "properties": { "signature": { "type": "string", @@ -1228,7 +1228,7 @@ }, "ingredientAssertionV1": { "type": "object", - "description": "Ingredient assertion (v1) per C2PA CDDL ingredient-map.", + "description": "Ingredient assertion (v1) (c2pa.ingredient). CDDL: ingredient.cddl ingredient-map.", "required": [ "dc:title", "dc:format", @@ -1282,7 +1282,7 @@ }, "ingredientAssertionV2": { "type": "object", - "description": "Ingredient assertion (v2) per C2PA CDDL ingredient-map-v2.", + "description": "Ingredient assertion (v2) (c2pa.ingredient.v2). CDDL: ingredient.cddl ingredient-map-v2.", "required": [ "dc:title", "dc:format", @@ -1364,7 +1364,7 @@ }, "ingredientAssertionV3": { "type": "object", - "description": "Ingredient assertion (v3) per C2PA CDDL ingredient-map-v3.", + "description": "Ingredient assertion (v3) (c2pa.ingredient.v3). CDDL: ingredient.cddl ingredient-map-v3.", "required": [ "relationship" ], @@ -1450,7 +1450,7 @@ }, "hashedExtUriMap": { "type": "object", - "description": "A data structure to store a reference to an external URL and its hash per C2PA CDDL $hashed-ext-uri-map. Contains the URL, the cryptographic hash algorithm identifier, hash, and optional MIME type, size, and data types. Used in ingredient v2/v3 data field.", + "description": "Reference to an external URL and its hash. CDDL: hashed-ext-uri.cddl $hashed-ext-uri-map. Used in ingredient v2/v3 data field.", "properties": { "url": { "type": "string", @@ -1491,7 +1491,7 @@ }, "thumbnailAssertion": { "type": "object", - "description": "Thumbnail assertion", + "description": "Thumbnail assertion (e.g. c2pa.thumbnail.claim.jpeg, c2pa.thumbnail.ingredient.jpeg). No CDDL in INTERNAL/cddl; structure is CrJSON/schema-defined.", "properties": { "thumbnailType": { "type": "integer", @@ -1504,83 +1504,221 @@ }, "additionalProperties": false }, + "bmffExclusionsMap": { + "type": "object", + "description": "Exclusion entry for BMFF hash. CDDL: bmff-hash.cddl exclusions-map.", + "required": [ + "xpath" + ], + "properties": { + "xpath": { + "type": "string", + "description": "Location of box(es) to exclude (XPath from root node)" + }, + "length": { + "type": "integer", + "minimum": 0, + "description": "Length that a leafmost box must have to be excluded" + }, + "data": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/bmffDataMap" + }, + "description": "Data at relative byte offset that must match for box to be excluded" + }, + "subset": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/bmffSubsetMap" + }, + "description": "Portion of the excluded box that is excluded from the hash" + }, + "version": { + "type": "integer", + "description": "Version that must be set in a leafmost FullBox for the box to be excluded" + }, + "flags": { + "type": "string", + "description": "24-bit flags (3-byte byte string, e.g. Base64-encoded in JSON). Only for FullBox when version is specified." + }, + "exact": { + "type": "boolean", + "description": "If true, flags must be an exact match (default true). Only for FullBox when flags is specified." + } + }, + "additionalProperties": false + }, + "bmffDataMap": { + "type": "object", + "description": "CDDL: bmff-hash.cddl data-map.", + "required": [ + "offset", + "value" + ], + "properties": { + "offset": { + "type": "integer", + "minimum": 0, + "description": "Relative byte offset in the leafmost box" + }, + "value": { + "type": "string", + "description": "Byte string (e.g. Base64 in JSON) that must match at the offset" + } + }, + "additionalProperties": false + }, + "bmffSubsetMap": { + "type": "object", + "description": "CDDL: bmff-hash.cddl subset-map.", + "required": [ + "offset", + "length" + ], + "properties": { + "offset": { + "type": "integer", + "minimum": 0, + "description": "Relative byte offset within the excluded box" + }, + "length": { + "type": "integer", + "minimum": 0, + "description": "Number of bytes in this subset" + } + }, + "additionalProperties": false + }, + "bmffMerkleMap": { + "type": "object", + "description": "Merkle tree row for mdat verification. CDDL: bmff-hash.cddl merkle-map.", + "required": [ + "uniqueId", + "localId", + "count", + "hashes" + ], + "properties": { + "uniqueId": { + "type": "integer", + "description": "1-based unique id to determine which Merkle tree validates a given mdat box" + }, + "localId": { + "type": "integer", + "description": "Local id indicating Merkle tree" + }, + "count": { + "type": "integer", + "description": "Number of leaf nodes in the Merkle tree (null nodes not included)" + }, + "alg": { + "type": "string", + "description": "Hash algorithm for this Merkle tree; if absent, from enclosing structure" + }, + "initHash": { + "type": "string", + "description": "Hash of init segment or bytes before first moof (fragmented MP4); absent for non-fragmented" + }, + "hashes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "Ordered array representing a single row of the Merkle tree (e.g. Base64 in JSON)" + }, + "fixedBlockSize": { + "type": "integer", + "minimum": 0, + "description": "Size in bytes of a leaf node (non-fragmented MP4, piecewise mdat validation)" + }, + "variableBlockSizes": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0 + }, + "description": "Size in bytes of each leaf node (non-fragmented MP4); length equals count" + } + }, + "additionalProperties": false + }, "hashBmffAssertion": { "type": "object", - "description": "BMFF (ISO Base Media File Format) hash assertion", + "description": "BMFF hash assertion (c2pa.hash.bmff.v2). CDDL: bmff-hash.cddl bmff-hash-map, exclusions-map, merkle-map.", + "required": [ + "exclusions" + ], "properties": { "exclusions": { "type": "array", + "minItems": 1, "items": { - "type": "object", - "properties": { - "xpath": { - "type": "string" - }, - "length": { - "type": [ - "integer", - "null" - ] - }, - "data": { - "type": [ - "array", - "null" - ] - }, - "subset": { - "type": [ - "array", - "null" - ] - }, - "version": { - "type": [ - "object", - "null" - ] - }, - "flags": { - "type": [ - "object", - "null" - ] - }, - "exact": { - "type": [ - "boolean", - "null" - ] - } - } - } + "$ref": "#/definitions/bmffExclusionsMap" + }, + "description": "Box(es) to exclude from the hash" }, - "algorithm": { - "type": "string" + "alg": { + "type": "string", + "description": "Hash algorithm; if absent, from enclosing structure (C2PA hash algorithm identifier list)" }, "hash": { - "type": "string" + "type": "string", + "description": "Hash of BMFF/segment excluding exclusions (e.g. Base64 in JSON)" + }, + "merkle": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/bmffMerkleMap" + }, + "description": "Merkle tree rows for verification of mdat box(es) or fragment files" }, "name": { - "type": "string" + "type": "string", + "description": "Human-readable description of what this hash covers" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Unused and deprecated" + }, + "sequenceNumber": { + "type": "integer", + "minimum": 0, + "description": "In a live video workflow, the monotonically increasing sequence number for each segment" } }, "additionalProperties": false }, "validationResults": { "type": "object", - "description": "Output of validation_results(): status codes for active manifest and ingredient deltas", + "description": "Validation results (active manifest and ingredient deltas). CDDL: validation-results.cddl validation-results-map.", "properties": { "activeManifest": { "$ref": "#/definitions/statusCodes", - "description": "Validation status codes for the active manifest" + "description": "Validation status codes for the active manifest. Present if ingredient is a C2PA asset." }, "ingredientDeltas": { "type": "array", - "description": "Validation deltas per ingredient assertion", + "description": "Validation deltas per ingredient assertion. Present if the ingredient is a C2PA asset.", "items": { "$ref": "#/definitions/ingredientDeltaValidationResult" } + }, + "specVersion": { + "type": "string", + "description": "The version of the specification against which the validation was performed (SemVer formatted string)", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "trustListUri": { + "type": "string", + "format": "uri", + "description": "URI to the trust list that was used to validate certificates" } }, "required": [ @@ -1590,7 +1728,7 @@ }, "statusCodes": { "type": "object", - "description": "Success, informational, and failure validation status codes", + "description": "Success, informational, and failure validation status codes. CDDL: validation-results.cddl status-codes-map.", "properties": { "success": { "type": "array", @@ -1620,7 +1758,7 @@ }, "validationStatusEntry": { "type": "object", - "description": "Single validation status (code, optional url and explanation)", + "description": "Single validation status (code, optional url and explanation). CDDL: validation-results.cddl status-map.", "properties": { "code": { "type": "string", @@ -1642,7 +1780,7 @@ }, "ingredientDeltaValidationResult": { "type": "object", - "description": "Validation deltas for one ingredient's manifest", + "description": "Validation deltas for one ingredient's manifest. CDDL: validation-results.cddl ingredient-delta-validation-result-map.", "properties": { "ingredientAssertionURI": { "type": "string", diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index 9b38db245..d321ae601 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -544,6 +544,11 @@ impl CrJsonReader { } } + // Per crJSON hashDataAssertion schema: "hash" and "pad" are required. Ensure "pad" is present when "hash" is. + if map.contains_key("hash") && !map.contains_key("pad") { + map.insert("pad".to_string(), json!("")); + } + // Check if this object has a "signature" field that's an array (cawg.identity) // This should be decoded as COSE_Sign1 and expanded similar to claimSignature if let Some(signature_value) = map.get("signature") { @@ -810,6 +815,14 @@ impl CrJsonReader { } } } + // Per crJSON schema, claim.v2 requires claim_generator_info (softwareAgent with at least "name"). + if !claim_v2.contains_key("claim_generator_info") { + let fallback = manifest.claim_generator().map_or_else( + || json!({"name": "Unknown"}), + |cg| json!({"name": cg}), + ); + claim_v2.insert("claim_generator_info".to_string(), fallback); + } // Add algorithm (from data hash assertion if available) claim_v2.insert("alg".to_string(), json!("SHA-256")); @@ -924,14 +937,17 @@ impl CrJsonReader { let mut claim_signature = Map::new(); - // Add algorithm - if let Some(alg) = &sig_info.alg { - claim_signature.insert("algorithm".to_string(), json!(alg.to_string())); - } + // Add algorithm (required per schema when signature object is present) + let alg_str = sig_info + .alg + .as_ref() + .map_or_else(String::new, |a| a.to_string()); + claim_signature.insert("algorithm".to_string(), json!(alg_str)); - // Parse certificate to get detailed DN components and validity (nested in certificateInfo) + // Parse certificate to get detailed DN components and validity (nested in certificateInfo). + // Schema requires signature object to have both algorithm and certificateInfo. + let mut cert_info_obj = Map::new(); if let Some(cert_info) = self.parse_certificate(&sig_info.cert_chain)? { - let mut cert_info_obj = Map::new(); if let Some(serial) = cert_info.serial_number { cert_info_obj.insert("serialNumber".to_string(), json!(serial)); } @@ -944,10 +960,8 @@ impl CrJsonReader { if let Some(validity) = cert_info.validity { cert_info_obj.insert("validity".to_string(), validity); } - if !cert_info_obj.is_empty() { - claim_signature.insert("certificateInfo".to_string(), Value::Object(cert_info_obj)); - } } + claim_signature.insert("certificateInfo".to_string(), Value::Object(cert_info_obj)); // Build timeStampInfo when we have a signing time and/or TSA certificate let has_ts_time = sig_info.time.is_some(); diff --git a/sdk/tests/crjson/hash_assertions.rs b/sdk/tests/crjson/hash_assertions.rs index d2cba1a95..f1ff63b9b 100644 --- a/sdk/tests/crjson/hash_assertions.rs +++ b/sdk/tests/crjson/hash_assertions.rs @@ -269,7 +269,7 @@ fn test_hash_assertion_pad_encoding() -> Result<()> { "pad should be a string (base64), not an array" ); - let pad = pad_value.as_str().expect("pad should be a string"); + let _pad = pad_value.as_str().expect("pad should be a string"); // Verify it's not empty (unless the pad is actually empty) // An empty pad would encode to an empty string diff --git a/sdk/tests/crjson/hash_encoding.rs b/sdk/tests/crjson/hash_encoding.rs index 180dc73a6..a35a00b53 100644 --- a/sdk/tests/crjson/hash_encoding.rs +++ b/sdk/tests/crjson/hash_encoding.rs @@ -162,7 +162,7 @@ fn test_assertion_reference_hashes_are_base64() -> Result<()> { let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; let json_value = reader.to_json_value()?; - // Check created_assertions hashes in claim.v2 + // Check created_assertions hashes in claim.v2 (manifest may have claim v1 or claim.v2 per schema) let manifests = json_value["manifests"] .as_array() .expect("manifests should be array"); @@ -170,10 +170,11 @@ fn test_assertion_reference_hashes_are_base64() -> Result<()> { let first_manifest = manifests .first() .expect("should have at least one manifest"); - let claim_v2 = first_manifest["claim.v2"] - .as_object() - .expect("claim.v2 should be object"); + let claim_v2 = first_manifest + .get("claim.v2") + .and_then(|v| v.as_object()); + if let Some(claim_v2) = claim_v2 { if let Some(created_assertions) = claim_v2.get("created_assertions") { let assertions_array = created_assertions .as_array() @@ -197,6 +198,8 @@ fn test_assertion_reference_hashes_are_base64() -> Result<()> { } } } + } + // If manifest has claim (v1) only, created_assertions is not present; nothing to check. Ok(()) } diff --git a/sdk/tests/crjson/ingredients.rs b/sdk/tests/crjson/ingredients.rs index 65fe31984..78c9dfaba 100644 --- a/sdk/tests/crjson/ingredients.rs +++ b/sdk/tests/crjson/ingredients.rs @@ -127,11 +127,10 @@ fn test_ingredient_referenced_in_claim() -> Result<()> { } } - // Find the manifest with the ingredient (active manifest with claim v2) + // Find a manifest with assertions: either claim.v2 (created/gathered) or claim v1 (assertions array) let active_manifest = manifests .iter() - .filter(|m| { - // Look for a manifest with non-empty created or gathered assertions + .find(|m| { if let Some(claim_v2) = m.get("claim.v2") { let has_created = claim_v2.get("created_assertions") .and_then(|a| a.as_array()) @@ -142,25 +141,28 @@ fn test_ingredient_referenced_in_claim() -> Result<()> { .map(|a| !a.is_empty()) .unwrap_or(false); has_created || has_gathered + } else if let Some(claim_v1) = m.get("claim") { + claim_v1.get("assertions") + .and_then(|a| a.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false) } else { false } }) - .next() .expect("should have at least one manifest with assertions"); - // Check if ingredient is referenced in created_assertions - let claim_v2 = active_manifest["claim.v2"] - .as_object() - .expect("claim.v2 should exist"); - - let created_assertions = claim_v2["created_assertions"] - .as_array() - .expect("created_assertions should be array"); - - let gathered_assertions = claim_v2["gathered_assertions"] - .as_array() - .expect("gathered_assertions should be array"); + // Check if ingredient is referenced in created_assertions (v2) or assertions (v1) + let (created_assertions, gathered_assertions) = if let Some(claim_v2) = active_manifest.get("claim.v2").and_then(|v| v.as_object()) { + ( + claim_v2["created_assertions"].as_array().expect("created_assertions should be array"), + claim_v2["gathered_assertions"].as_array().expect("gathered_assertions should be array"), + ) + } else { + // V1: single "assertions" array; treat as both created and gathered for this check + let assertions = active_manifest.get("claim").and_then(|c| c.get("assertions")).and_then(|a| a.as_array()).expect("claim.assertions should be array"); + (assertions, assertions) + }; // Debug: Print what we found println!("Created assertions count: {}", created_assertions.len()); From 04838c53971ccfc2f4ab1171db37f8572c175475 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Sun, 8 Mar 2026 17:05:23 -0400 Subject: [PATCH 13/18] Cleaning this up in preparation for formal review --- sdk/target/crjson_test_output/CA.jpg.json | 227 ---------------------- 1 file changed, 227 deletions(-) delete mode 100644 sdk/target/crjson_test_output/CA.jpg.json diff --git a/sdk/target/crjson_test_output/CA.jpg.json b/sdk/target/crjson_test_output/CA.jpg.json deleted file mode 100644 index defce45e1..000000000 --- a/sdk/target/crjson_test_output/CA.jpg.json +++ /dev/null @@ -1,227 +0,0 @@ -{ - "@context": { - "@vocab": "https://contentcredentials.org/crjson", - "extras": "https://contentcredentials.org/crjson/extras" - }, - "manifests": [ - { - "label": "contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf", - "assertions": { - "stds.schema-org.CreativeWork": { - "@context": "http://schema.org/", - "@type": "CreativeWork", - "author": [ - { - "name": "John Doe", - "@type": "Person" - } - ] - }, - "c2pa.actions.v2": { - "actions": [ - { - "action": "c2pa.opened", - "parameters": { - "ingredient": { - "url": "self#jumbf=c2pa.assertions/c2pa.ingredient", - "hash": "5dNlxTKe4afGAicpJa1hF1R3mBZKE+Bl0xmh0McXuO4=" - } - } - }, - { - "action": "c2pa.color_adjustments", - "parameters": { - "name": "brightnesscontrast" - } - } - ] - }, - "c2pa.hash.data": { - "exclusions": [ - { - "start": 20, - "length": 117273 - } - ], - "name": "jumbf manifest", - "alg": "sha256", - "hash": "hrHkEQU/Ib6/1/hVlU4Ak9dMqTLnWqyM6I3pLGRYHHI=", - "pad": "AAAAAAAAAA==" - }, - "c2pa.ingredient": { - "title": "A.jpg", - "format": "image/jpeg", - "document_id": "xmp.did:813ee422-9736-4cdc-9be6-4e35ed8e41cb", - "instance_id": "xmp.iid:813ee422-9736-4cdc-9be6-4e35ed8e41cb", - "thumbnail": { - "format": "image/jpeg", - "identifier": "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpeg" - }, - "relationship": "parentOf" - } - }, - "claim.v2": { - "dc:title": "CA.jpg", - "instanceID": "xmp:iid:ba572347-db0e-4619-b6eb-d38e487da238", - "claim_generator": "make_test_images/0.33.1 c2pa-rs/0.33.1", - "claim_generator_info": [ - { - "name": "make_test_images", - "version": "0.33.1" - }, - { - "name": "c2pa-rs", - "version": "0.33.1" - } - ], - "alg": "SHA-256", - "signature": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", - "created_assertions": [ - { - "url": "self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg", - "hash": "Tz+TZh0TJI1DhH2CB6ZMQ1CkEvfa5if6riBRAyqcOUk=" - }, - { - "url": "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpeg", - "hash": "GfgeiV34aTy+zThtyInZH+NP0E/NPWAUfwvJoG0ZDzM=" - }, - { - "url": "self#jumbf=c2pa.assertions/c2pa.ingredient", - "hash": "5dNlxTKe4afGAicpJa1hF1R3mBZKE+Bl0xmh0McXuO4=" - }, - { - "url": "self#jumbf=c2pa.assertions/stds.schema-org.CreativeWork", - "hash": "2uusTnr+Tm81nTxzeqy0kcHOPR88vqYfDbBDQ1hIuro=" - }, - { - "url": "self#jumbf=c2pa.assertions/c2pa.actions", - "hash": "AM+4ImIIQWpq2obgFY7h32+Gqkpuoi0aMJ7KqGdXKeY=" - }, - { - "url": "self#jumbf=c2pa.assertions/c2pa.hash.data", - "hash": "xqoYs91+LQbKWXVfiEkYaWpiq18cm+NUUVpgi2U2Pcw=" - } - ], - "gathered_assertions": [], - "redacted_assertions": [] - }, - "signature": { - "algorithm": "ps256", - "serial_number": "7e3e629adccfe7d99710135b5a48056972df8199", - "issuer": { - "C": "US", - "ST": "CA", - "L": "Somewhere", - "O": "C2PA Test Intermediate Root CA", - "OU": "FOR TESTING_ONLY", - "CN": "Intermediate CA" - }, - "subject": { - "C": "US", - "ST": "CA", - "L": "Somewhere", - "O": "C2PA Test Signing Cert", - "OU": "FOR TESTING_ONLY", - "CN": "C2PA Signer" - }, - "validity": { - "not_before": "2022-06-10T18:46:28+00:00", - "not_after": "2030-08-26T18:46:28+00:00" - } - }, - "status": { - "signature": "claimSignature.insideValidity", - "trust": "signingCredential.untrusted", - "content": "assertion.dataHash.match", - "assertion": { - "stds.schema-org.CreativeWork": "assertion.hashedURI.match" - } - } - } - ], - "extras:validation_status": { - "isValid": true, - "error": null, - "validationErrors": [ - { - "code": "signingCredential.untrusted", - "message": "signing certificate untrusted", - "severity": "error" - } - ], - "entries": [ - { - "code": "timeStamp.validated", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", - "explanation": "timestamp message digest matched: DigiCert Timestamp 2023", - "severity": "info" - }, - { - "code": "timeStamp.trusted", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", - "explanation": "timestamp cert trusted: DigiCert Timestamp 2023", - "severity": "info" - }, - { - "code": "claimSignature.insideValidity", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", - "explanation": "claim signature valid", - "severity": "info" - }, - { - "code": "claimSignature.validated", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", - "explanation": "claim signature valid", - "severity": "info" - }, - { - "code": "assertion.hashedURI.match", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.thumbnail.claim.jpeg", - "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.thumbnail.claim.jpeg", - "severity": "info" - }, - { - "code": "assertion.hashedURI.match", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.thumbnail.ingredient.jpeg", - "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpeg", - "severity": "info" - }, - { - "code": "assertion.hashedURI.match", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.ingredient", - "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient", - "severity": "info" - }, - { - "code": "assertion.hashedURI.match", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/stds.schema-org.CreativeWork", - "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/stds.schema-org.CreativeWork", - "severity": "info" - }, - { - "code": "assertion.hashedURI.match", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.actions", - "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions", - "severity": "info" - }, - { - "code": "assertion.hashedURI.match", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.hash.data", - "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data", - "severity": "info" - }, - { - "code": "assertion.dataHash.match", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.hash.data", - "explanation": "data hash valid", - "severity": "info" - }, - { - "code": "signingCredential.untrusted", - "url": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.signature", - "explanation": "signing certificate untrusted", - "severity": "error" - } - ] - } -} \ No newline at end of file From 34b0fa903ba6746e66aff8f61256d0208d630614 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Sun, 8 Mar 2026 17:05:34 -0400 Subject: [PATCH 14/18] Clean up in preparation for formal review --- .gitignore | 1 + docs/crjson-format.adoc | 522 --------------------------- sdk/src/crypto/time_stamp/verify.rs | 2 +- sdk/src/validation_results.rs | 4 +- sdk/tests/crjson/created_gathered.rs | 17 - sdk/tests/crjson/hash_encoding.rs | 2 - sdk/tests/crjson/ingredients.rs | 23 -- 7 files changed, 4 insertions(+), 567 deletions(-) delete mode 100644 docs/crjson-format.adoc diff --git a/.gitignore b/.gitignore index 948a98e8a..f725d4573 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .vscode /semver-checks/target/ +sdk/target/ # Unit test output lands here. TO DO: Fix cli/target diff --git a/docs/crjson-format.adoc b/docs/crjson-format.adoc deleted file mode 100644 index 787afb975..000000000 --- a/docs/crjson-format.adoc +++ /dev/null @@ -1,522 +0,0 @@ -= Content Credential JSON (CrJSON) File Format Specification -:doctype: article -:toc: left -:toclevels: 3 -:sectnums: - -== Scope - -This document describes a JSON serialization for Content Credentials (aka a C2PA manifest store) known as the *Content Credential JSON* format (abbreviated *CrJSON*). It's purpose is to provide a JSON-based representation of a C2PA manifest store for profile evaluation, interoperability testing, and validation reporting. - -// add some more stuff about what this is for & not for! -// lossy visualization, not for input, etc. - -== Normative References - -* C2PA Technical Specification v2.3: https://c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html -* CrJSON JSON Schema: `cli/schemas/crJSON-schema.json` (in this repository) - -== Relationship to C2PA v2.3 - -CrJSON does not replace C2PA claim stores, JUMBF, or COSE structures. Instead, it is a *derived JSON view* over C2PA data. - -The following C2PA concepts are directly represented: - -* C2PA manifests -> `manifests[]` -* C2PA assertions -> `manifests[].assertions` (object keyed by assertion label) -* C2PA claim data -> `manifests[].claim.v2` -* C2PA claim signature and credential details -> `manifests[].signature` -* C2PA validation results -> `manifests[].status` and `validationResults` - -== Data Model Overview - -[source,text] ----- -C2PA Asset/Manifest Store - -> Reader validation + manifest extraction - -> CrJSON transformation - -> Output JSON object - |- @context - |- manifests[] - | |- label - | |- assertions{...} - | |- claim or claim.v2 (one required) - | |- signature (required) - | |- status (required) - |- validationResults (optional) ----- - -== Serialization Requirements - -=== Root Object - -A CrJSON document shall be a JSON object. - -The following top-level properties are used: - -[cols="1,1,3"] -|=== -|Property |Presence |Description - -|`@context` -|REQUIRED -|JSON-LD context. Schema allows object or array of URI strings. - -|`manifests` -|REQUIRED -|Array of manifest objects. Each item conforms to the manifest definition (required: label, assertions, signature, status; one of claim or claim.v2). - -|`validationResults` -|OPTIONAL -|Output of `validation_results()`: active manifest status codes and ingredient deltas (success, informational, failure arrays). -|=== - -=== `@context` - -CrJSON uses a standard https://www.w3.org/TR/json-ld11/[JSON-LD serialisation], and therefore shall contain a JSON-LD standard `@context` field whose value shall be either an object or an array listing terms (also known as namespaces) that are used in the CrJSON. In the case of an object, the terms shall be listed as key-value pairs, where the key is the term name and the value is the URI of the term. As described in clause 4.1.2 of JSON-LD, the `@vocab` key can be used for the default vocabulary, as shown in <>. In the case of an array, only the URI is required since it shall apply to all terms not otherwise identified by a specific term, as shown in <>. - -[[json-ld-context-obj-example]] -[source,json] -.Example of an object-based JSON-LD `@context` field ----- -{ - "@context": { - "@vocab": "https://contentcredentials.org/crjson/", - "extras": "https://contentcredentials.org/crjson/extras/", - } -} ----- - -[[json-ld-context-array-example]] -[source,json] -.Example of an array-based JSON-LD `@context` field ----- -{ - "@context": [ - "https://contentcredentials.org/crjson/" - ] -} ----- - -Since CrJSON may contain values that are specific to a given workflow, it is important that each one of these terms shall be defined in the `@context` field. This allows the CrJSON document to be self-describing and non-conflicting ensuring that any consumer of the CrJSON can understand the meaning of each term. - -Since a `@context` element can appear inside of any object in JSON-LD, it is possible to have custom values, and their associated `@context` elements in multiple places throughout a single JSON-LD document, where the terms are localized to that specific object. - - -=== Manifests - -A C2PA Manifest consists of, at least, a set of Assertions, Claims, and Claim Signatures that are bound together into a single entity. For all C2PA Manifests present in the C2PA Manifest Store, they shall be present in the `manifests` array. The order of the Manifests in the array shall match the reverse order that they are found in the Manifest Store, so that the active manifest is always first (i.e., `manifests[0]`). - -Each manifest object shall include the following properties (per schema: required `label`, `assertions`, `signature`, `status`; exactly one of `claim` or `claim.v2`): - -* `label` (manifest label/URN) -* `assertions` (object keyed by assertion label) -* `claim` (v1, per C2PA `claim-map`) or `claim.v2` (v2, per C2PA `claim-map-v2`) -* `signature` (signature and credential details object) -* `status` (per-manifest validation results object) - -NOTE: The `label` field's value is a string that identifies the C2PA Manifest using the label of its JUMBF box, such as `urn:c2pa:2702fc84-a1ae-44d1-9825-dd86311e980b`. - -The manifest object does not allow additional properties (schema `additionalProperties: false`). - -=== Claims - -==== General -A Claim shall be serialised in the same manner as a <>. It shall be named based on the label of the claim box (e.g., `claim` or `claim.v2`). An example is found in <>. - -If there are no assertions listed in the claim's `gathered_assertions` or `redacted_assertions` fields, then the corresponding object shall be present, but its value shall be an empty object. - -[[json-ld-claim]] -[source,json] -.A JSON-LD serialised claim ----- -"claim.v2": { - "dc:title": "MIPAMS test image", - "instanceID": "uuid:7b57930e-2f23-47fc-affe-0400d70b738d", - "claim_generator": "MIPAMS GENERATOR 0.1", - "alg": "SHA-256", - "signature": "self#jumbf=c2pa.signature", - "created_assertions": [ - { - "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2", - "hash": "APqpWkPm91k98DD03sIQ+uYGspG+bxdy0c7+FMu8puU=" - }, - { - "url": "self#jumbf=c2pa.assertions/c2pa.hash.data", - "hash": "A8wNdhjiIyOOkGg8+GkJRSYJALG6orPQJRQKMFtq/rc=" - } - ], - "gathered_assertions": [], - "redacted_assertions": [] -}, ----- - -==== `claim.v2` (v2 claim) - -`claim.v2` conforms to the C2PA CDDL claim-map-v2. Required properties: - -* `instanceID` — uniquely identifies a specific version of an asset -* `claim_generator_info` — single generator-info map (object with e.g. `name`, `version`, optional `icon`, `operating_system`) -* `signature` — JUMBF URI reference to the signature of this claim (e.g. `self#jumbf=/c2pa/{label}/c2pa.signature`) -* `created_assertions` — array of one or more hashed URI maps; each entry has `url`, `hash`, and optionally `alg` - -Optional properties: - -* `gathered_assertions` — array of hashed URI maps (same structure as created_assertions) -* `dc:title` — name of the asset -* `redacted_assertions` — array of JUMBF URI strings (references to redacted ingredient manifest assertions) -* `alg` — cryptographic hash algorithm for data hash assertions (e.g. `SHA-256`) -* `alg_soft` — algorithm for soft binding assertions -* `specVersion` — specification version (SemVer) -* `metadata` — (DEPRECATED) additional information - -All `hash` values in hashed URI maps shall be Base64 strings. - -=== `claim` (v1 claim) - -When a manifest uses `claim` instead of `claim.v2`, it conforms to the C2PA CDDL claim-map (claimV1). Required properties: - -* `claim_generator` — User-Agent string for the claim generator -* `claim_generator_info` — array of one or more generator-info maps -* `signature` — JUMBF URI reference to the signature -* `assertions` — array of one or more hashed URI maps (`url`, `hash`, optional `alg`) -* `dc:format` — media type of the asset -* `instanceID` — uniquely identifies a specific version of an asset - -Optional: `dc:title`, `redacted_assertions` (JUMBF URI strings), `alg`, `alg_soft`, `metadata`. - -All `hash` values in hashed URI maps shall be Base64 strings. - -=== Assertions - -[[assertions-fields]] -==== General - -Each manifest object shall contain an `assertions` field whose value is an object keyed by assertion label. Each individual assertion shall be represented as an object, where they key is the label of the Assertion and the value is an object containing the JSON-LD serialization derived from that Assertion. If it is not possible to derive information from an assertion, then the key shall be present in the assertions object, but its value shall be an empty object. - -==== JSON-LD serialised assertions - -For any Assertion which is serialised in the C2PA Manifest as JSON-LD, that exact same JSON-LD shall be used as the value for the assertion. For example, the `c2pa.metadata` assertion is expressed in XMP, that XMP data is serialised as JSON-LD. - -An example `c2pa.metadata` assertion is shown in <>. - -[[json-ld-assertion]] -[source,json] -.A JSON-LD serialised assertion ----- -"c2pa.metadata": { - "@context" : { - "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", - "photoshop" : "http://ns.adobe.com/photoshop/1.0/" - }, - "photoshop:DateCreated": "Aug 31, 2022", - "Iptc4xmpExt:LocationCreated": { - "Iptc4xmpExt:City": "Beijing, China" - } -} ----- - -NOTE: The various terms and context namespaces in <> are defined as part of <>. - -[[cbor_serialised_assertions]] -===== CBOR serialised assertions -For each Assertion, which is serialised in the C2PA Manifest as CBOR, the JSON-LD representation shall be described as the same key name in the CBOR map and its value type shall be determined by <>: - -[[table_cbor_json_mapping]] -.Mapping from CBOR to JSON-LD -[cols="2,1", options="header"] -|==== -|CBOR Type(s) -|JSON-LD Type - -| integer, unsigned integer -| unsigned number - -| negative integer -| integer - -| byte string -| string (Base64 encoded, <>) - -| UTF-8 string -| string - -| array -| array - -| map -| object - -| False, True -| boolean - -| Null -| null - -| half-precision float, single-precision float, double-precision float -| float - -| date-time -| string (<>) - -|==== - -Since CBOR allows map keys of any type, whereas JSON-LD only allows strings as keys in object values, CBOR maps with keys other than UTF-8 strings shall have those keys converted to UTF-8 strings. An example of a CBOR serialised assertion is shown in <>, and its equivalent JSON-LD representation is shown in <>. - -[[actions-cbordiag]] -[source] -.CBOR Diagnostics for an actions.v2 assertion ----- -"c2pa.actions.v2": { - "actions": [ - { - "action": "c2pa.cropped", - "when": 0("2020-02-11T09:30:00Z") - }, - { - "action": "c2pa.filtered", - "when": 0("2020-02-11T09:00:00Z") - } - ] -} ----- - -[[actions-json]] -[source,json] -.JSON-LD representation of <> ----- -"c2pa.actions.v2": { - "actions": [ - { - "action": "c2pa.cropped", - "when": "2020-02-11T09:30:00Z" - }, - { - "action": "c2pa.filtered", - "when": "2020-02-11T09:00:00Z" - } - ] -} ----- - - -==== Binary normalization and hash encoding - -CrJSON normalizes byte-array encodings into Base64 string encodings. - -If fields are serialized as integer arrays, they are converted to Base64 strings for: - -* `hash` -* `pad` -* `pad1` -* `pad2` -* Certain `signature` byte payloads (decoded when possible; otherwise Base64) - -This rule applies recursively to nested objects/arrays. - -==== Binary assertion representation - -For binary formatted assertions (e.g., thumbnails), CrJSON emits a reference form: - -[source,json] ----- -{ - "format": "", - "identifier": "", - "hash": "" -} ----- - -=== Signature - -// https://github.com/jcrowgey/x5092json is useful information for this section -// also see https://darutk.medium.com/illustrated-x-509-certificate-84aece2c5c2e - -The JSON-LD representation of the X.509 certificate (as defined in <>) from the claim signature is based on a logical mapping of its ASN.1 serialisation as defined in RFC 5280 into a JSON-LD serialized object whose key is `signature`. An example certificate is in <>. Additional mappings, such as the mapping of the distinguished name to JSON-LD should also be done in the most logical fashion possible. - -When signature information is available, it should include: - -* `algorithm` -* `serial_number` -* `issuer` (DN map, e.g., `C`, `ST`, `L`, `O`, `OU`, `CN`) -* `subject` (DN map) -* `validity.not_before` -* `validity.not_after` - -NOTE: An X.509 certificate can contain all sorts of information, and implementations may choose to include additional information in their JSON-LD representation. - -Times shall be represented as RFC 3339 strings. When signature information is unavailable, `signature` shall be present as an empty object `{}`. - -The value of the `signature_algorithm` field shall be one of the strings defined in <>, such as "ES256" or "Ed25519", or "Unknown" if it is not one of the defined values. - -[[json-ld-x509]] -[source,json] -.Representation of an X.509 Certificate ----- -"signature": { - "signature_algorithm": "ES256", - "subject": { - "ST": "CA", - "CN": "C2PA Signer", - "C": "US", - "L": "Somewhere", - "OU": "FOR TESTING_ONLY", - "O": "C2PA Test Signing Cert" - }, - "issuer": { - "ST": "CA", - "CN": "Intermediate CA", - "C": "US", - "L": "Somewhere", - "OU": "FOR TESTING_ONLY", - "O": "C2PA Test Intermediate Root CA" - }, - "validity": { - "not_after": "2030-08-26T18:46:40Z", - "not_before": "2022-06-10T18:46:40Z" - } -} ----- - -=== Status - -The `manifest` object shall contain a `status` field whose value is an object containing the trust and/or validity status of the various parts of the C2PA Manifest. The status object shall contain the following fields: - -`signature`:: A field whose value is a single <> determined from <>. - -`assertions`:: A field whose value is an object, where each field is the label of an assertion and its value is the <> determined from validation. All assertions in the Claim shall be listed in this object, whether they are in the `created_assertions` or `gathered_assertions` fields of the Claim. - -`trust`:: After the signing certificate is checked against one or more Trust Lists, this field shall contain a single <> that specifies the status of the signing certificate against the Trust Lists. There may also be `trust_list` field that contains a URI that identifies the Trust List used to validate the signing certificate. - -For the active manifest, the following additional fields shall also be present: - -`content`:: A field whose value is a single <> determined from validation of the content bindings. - -NOTE: This is present only in the active manifest, since that is the only one that can be used to check the validity of the content bindings. This applies to both standard and update manifests. - -An example of a status object is shown in <>. - -[[status-example]] -[source,json] -.Example status object ----- -"status": { - "signature": "claimSignature.validated", - "assertion": { - "c2pa.actions.v2": "assertion.hashedURI.match", - "c2pa.hash.data": "assertion.dataHash.match" - }, - "content": "assertion.dataHash.match", - "trust": "signingCredential.trusted", - "trust_list": "https://example.com/trustlists/c2pa-trust-list.json" -} ----- - -When validation results are unavailable, `status` shall be present as an empty object `{}`. - -=== Validation Results - -The `validationResults` object is modelled on the `validation-results-map` data structure used to store the results of ingredient validation in the ingredient assertion. It is not a required property of crJSON (as it may be used outside of a validation workflow), but when present, it will always include the set of validation status codes for the active manifest. If the active manifest refers to one or more ingredients, there shall also be an `ingredientDeltas` field present that contains the list of validation deltas (if any). - -Each validation status entry is an object with a `code` (required), and optionally `url` and `explanation` fields. - -[[results-example]] -[source,json] ----- -"validationResults": { - "activeManifest": { - "success": [], - "informational": [ - { - "code": "claimSignature.insideValidity", - "url": "self#jumbf=/c2pa/urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/c2pa.signature", - "explanation": "claim signature valid", - }, - { - "code": "claimSignature.validated", - "url": "self#jumbf=/c2pa/urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/c2pa.signature", - "explanation": "claim signature valid", - }, - { - "code": "assertion.hashedURI.match", - "url": "self#jumbf=/c2pa/urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/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:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/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:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/c2pa.assertions/c2pa.hash.data", - "explanation": "data hash valid", - }, - - ], - "failure": [ - { - "code": "signingCredential.untrusted", - "url": "self#jumbf=/c2pa/urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268/c2pa.signature", - "explanation": "signing certificate untrusted", - }, - { - "code": "assertion.action.malformed", - "url": "urn:c2pa:4646c5e4-31e1-4d08-8156-d90a8e323268", - "explanation": "first action must be created or opened", - } - ] - } -} - ----- - -== Constraints and Current Implementation Limits - -* CrJSON is export-oriented; it is not the canonical source of cryptographic truth. Canonical validation remains bound to C2PA/JUMBF/COSE data structures per C2PA v2.3. - -== Minimal Example - -The following example conforms to the CrJSON schema. In many of the example values, a `...` placeholder is used for a value that is not relevant to the example. Also, any values which would be Base64-encoded are represented as ``. - -[source,json] ----- -{ - "@context": { - "@vocab": "https://contentcredentials.org/crjson", - "extras": "https://contentcredentials.org/crjson/extras" - }, - "manifests": [ - { - "label": "urn:uuid:...", - "claim.v2": { - "instanceID": "xmp:iid:...", - "claim_generator_info": {"name": "Example Tool", "version": "1.0"}, - "signature": "self#jumbf=/c2pa/urn:uuid:.../c2pa.signature", - "created_assertions": [ - {"url": "self#jumbf=c2pa.assertions/c2pa.hash.data", "hash": ""} - ], - "gathered_assertions": [], - "redacted_assertions": [] - }, - "assertions": { - "c2pa.actions.v2": {"actions": []}, - "c2pa.hash.data": {"alg": "sha256", "hash": ""} - }, - "signature": {}, - "status": { - "signature": "claimSignature.validated", - "trust": "signingCredential.trusted" - } - } - ], - "validationResults": { - "activeManifest": { - "success": [], - "informational": [], - "failure": [] - } - } -} ----- - diff --git a/sdk/src/crypto/time_stamp/verify.rs b/sdk/src/crypto/time_stamp/verify.rs index 4b1941db3..cfe0a3371 100644 --- a/sdk/src/crypto/time_stamp/verify.rs +++ b/sdk/src/crypto/time_stamp/verify.rs @@ -253,7 +253,7 @@ pub fn verify_time_stamp( .informational(&mut current_validation_log); last_err = TimeStampError::DecodeError( - "unable to decode igned message data".to_string(), + "unable to decode signed message data".to_string(), ); continue; } diff --git a/sdk/src/validation_results.rs b/sdk/src/validation_results.rs index e9cb5ab4c..56dae4bd9 100644 --- a/sdk/src/validation_results.rs +++ b/sdk/src/validation_results.rs @@ -26,7 +26,7 @@ use crate::{ validation_status::{self, log_kind, ValidationStatus}, }; -/// Represents the levels of assurance a manifest store achives when evaluated against the C2PA +/// Represents the levels of assurance a manifest store achieves when evaluated against the C2PA /// specifications structural, cryptographic, and trust requirements. /// /// See [Validation states - C2PA Technical Specification](https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_validation_states). @@ -57,7 +57,7 @@ pub struct StatusCodes { pub success: Vec, /// An array of validation informational codes. May be empty. pub informational: Vec, - // An array of validation failure codes. May be empty. + /// An array of validation failure codes. May be empty. pub failure: Vec, } diff --git a/sdk/tests/crjson/created_gathered.rs b/sdk/tests/crjson/created_gathered.rs index 01e4d2bfc..0c11411a1 100644 --- a/sdk/tests/crjson/created_gathered.rs +++ b/sdk/tests/crjson/created_gathered.rs @@ -85,11 +85,6 @@ fn test_created_and_gathered_assertions_separated() -> Result<()> { }) .expect("should have a manifest with our test assertions (org.test.created, org.test.gathered, org.test.regular)"); - // Print the label for debugging - println!("Manifest label: {:?}", active_manifest.get("label")); - println!("Claim version: {:?}", active_manifest.get("claim.v2")); - println!("Total manifests: {}", manifests.len()); - // Check claim.v2 for created_assertions and gathered_assertions let claim_v2 = active_manifest["claim.v2"] .as_object() @@ -103,18 +98,6 @@ fn test_created_and_gathered_assertions_separated() -> Result<()> { .as_array() .expect("gathered_assertions should be array"); - // Print for debugging - println!("Created assertions count: {}", created_assertions.len()); - println!("Gathered assertions count: {}", gathered_assertions.len()); - - for (i, assertion) in created_assertions.iter().enumerate() { - println!("Created[{}]: {}", i, assertion.get("url").unwrap()); - } - - for (i, assertion) in gathered_assertions.iter().enumerate() { - println!("Gathered[{}]: {}", i, assertion.get("url").unwrap()); - } - // Verify that gathered_assertions is not empty assert!( !gathered_assertions.is_empty(), diff --git a/sdk/tests/crjson/hash_encoding.rs b/sdk/tests/crjson/hash_encoding.rs index a35a00b53..05a2b3384 100644 --- a/sdk/tests/crjson/hash_encoding.rs +++ b/sdk/tests/crjson/hash_encoding.rs @@ -310,7 +310,5 @@ fn test_all_hashes_match_schema_format() -> Result<()> { "Should have at least one hash field in the output" ); - println!("Verified {} hash fields are all base64 strings", hash_count); - Ok(()) } diff --git a/sdk/tests/crjson/ingredients.rs b/sdk/tests/crjson/ingredients.rs index 78c9dfaba..aab42ed33 100644 --- a/sdk/tests/crjson/ingredients.rs +++ b/sdk/tests/crjson/ingredients.rs @@ -114,19 +114,6 @@ fn test_ingredient_referenced_in_claim() -> Result<()> { .as_array() .expect("manifests should be array"); - println!("Total manifests: {}", manifests.len()); - for (i, m) in manifests.iter().enumerate() { - println!("Manifest {}: label={:?}", i, m.get("label")); - if let Some(claim_v2) = m.get("claim.v2") { - if let Some(created) = claim_v2.get("created_assertions") { - println!(" Created assertions count: {}", created.as_array().map(|a| a.len()).unwrap_or(0)); - } - if let Some(gathered) = claim_v2.get("gathered_assertions") { - println!(" Gathered assertions count: {}", gathered.as_array().map(|a| a.len()).unwrap_or(0)); - } - } - } - // Find a manifest with assertions: either claim.v2 (created/gathered) or claim v1 (assertions array) let active_manifest = manifests .iter() @@ -164,16 +151,6 @@ fn test_ingredient_referenced_in_claim() -> Result<()> { (assertions, assertions) }; - // Debug: Print what we found - println!("Created assertions count: {}", created_assertions.len()); - println!("Gathered assertions count: {}", gathered_assertions.len()); - for (i, a) in created_assertions.iter().enumerate() { - println!("Created[{}]: {}", i, a.get("url").unwrap_or(&serde_json::Value::Null)); - } - for (i, a) in gathered_assertions.iter().enumerate() { - println!("Gathered[{}]: {}", i, a.get("url").unwrap_or(&serde_json::Value::Null)); - } - // Find ingredient reference in EITHER created or gathered let has_ingredient_in_created = created_assertions.iter().any(|assertion_ref| { if let Some(url) = assertion_ref.get("url") { From a7881ab45431eb363bafb216b965aeebd5d89e43 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Mon, 9 Mar 2026 14:41:53 -0400 Subject: [PATCH 15/18] add support for the new validationTime field --- cli/schemas/crJSON-schema.json | 5 +++++ sdk/src/validation_results.rs | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json index 3117b6b27..8062a3420 100644 --- a/cli/schemas/crJSON-schema.json +++ b/cli/schemas/crJSON-schema.json @@ -1719,6 +1719,11 @@ "type": "string", "format": "uri", "description": "URI to the trust list that was used to validate certificates" + }, + "validationTime": { + "type": "string", + "format": "date-time", + "description": "Time when the validation was performed; RFC 3339 format" } }, "required": [ diff --git a/sdk/src/validation_results.rs b/sdk/src/validation_results.rs index 56dae4bd9..bd171be74 100644 --- a/sdk/src/validation_results.rs +++ b/sdk/src/validation_results.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; +use chrono::Utc; #[cfg(feature = "json_schema")] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -115,6 +116,10 @@ pub struct ValidationResults { /// manifest. Present if the the ingredient is a C2PA asset. #[serde(rename = "ingredientDeltas", skip_serializing_if = "Option::is_none")] ingredient_deltas: Option>, + + /// Time when the validation was performed (RFC 3339 date-time). Exported in crJSON as validationTime. + #[serde(rename = "validationTime", skip_serializing_if = "Option::is_none")] + validation_time: Option, } impl ValidationResults { @@ -199,6 +204,7 @@ impl ValidationResults { results.add_status(status); } } + results.validation_time = Some(Utc::now().to_rfc3339()); results } From ac700f1ba175d3fca9720a04be506658d2e2568c Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Tue, 10 Mar 2026 08:26:12 -0400 Subject: [PATCH 16/18] revamped the validationResults and status info to reflect TWG input --- cli/schemas/crJSON-schema.json | 62 ++++-- sdk/src/cr_json_reader.rs | 150 +++++++------ sdk/src/validation_results.rs | 9 +- sdk/tests/crjson/schema_compliance.rs | 309 ++++++++++++++++++-------- 4 files changed, 357 insertions(+), 173 deletions(-) diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json index 8062a3420..715d990f1 100644 --- a/cli/schemas/crJSON-schema.json +++ b/cli/schemas/crJSON-schema.json @@ -42,12 +42,12 @@ "$ref": "#/definitions/manifest" } }, - "validationResults": { - "description": "Validation results from validation (active manifest and ingredient deltas)", - "$ref": "#/definitions/validationResults" + "validationInfo": { + "description": "Validation information summary and validation time (signature codes, trust, content). Document-level.", + "$ref": "#/definitions/validationInfo" }, "jsonGenerator": { - "description": "Information about the claim generator, including date stamp", + "description": "Information about the tool used to generate this JSON, and when it was generated", "$ref": "#/definitions/jsonGenerator" } }, @@ -81,12 +81,12 @@ }, "manifest": { "type": "object", - "description": "CrJSON manifest wrapper containing label, assertions, claim or claim.v2, signature, and status. Claim content: claim.cddl claim-map or claim-map-v2.", + "description": "CrJSON manifest wrapper containing label, assertions, claim or claim.v2, signature, and validationResults. Claim content: claim.cddl claim-map or claim-map-v2.", "required": [ "label", "assertions", "signature", - "status" + "validationResults" ], "oneOf": [ { @@ -121,8 +121,16 @@ "signature": { "$ref": "#/definitions/signature" }, - "status": { - "$ref": "#/definitions/status" + "validationResults": { + "$ref": "#/definitions/statusCodes", + "description": "Validation status codes for this manifest (success, informational, failure)" + }, + "ingredientDeltas": { + "type": "array", + "description": "Validation deltas for this manifest's ingredient assertions. Present when ingredients are C2PA assets.", + "items": { + "$ref": "#/definitions/ingredientDeltaValidationResult" + } } }, "additionalProperties": false @@ -1695,17 +1703,44 @@ }, "additionalProperties": false }, + "validationInfo": { + "type": "object", + "description": "Validation information summary (signature codes, trust, content) and validation time. Document-level.", + "properties": { + "signature": { + "type": "array", + "description": "All status codes related to claim signature (claimSignature*)", + "items": { + "type": "string" + } + }, + "content": { + "type": "string", + "description": "Content validation status" + }, + "trust": { + "type": "string", + "description": "Trust validation status" + }, + "validationTime": { + "type": "string", + "format": "date-time", + "description": "Time when the validation was performed; RFC 3339 format" + } + }, + "additionalProperties": false + }, "validationResults": { "type": "object", - "description": "Validation results (active manifest and ingredient deltas). CDDL: validation-results.cddl validation-results-map.", + "description": "Validation results (active manifest and optional ingredient deltas). CDDL: validation-results.cddl validation-results-map. Used in ingredient assertions (e.g. c2pa.ingredient.v3).", "properties": { "activeManifest": { "$ref": "#/definitions/statusCodes", - "description": "Validation status codes for the active manifest. Present if ingredient is a C2PA asset." + "description": "Validation status codes for the active manifest. Present when ingredient is a C2PA asset." }, "ingredientDeltas": { "type": "array", - "description": "Validation deltas per ingredient assertion. Present if the ingredient is a C2PA asset.", + "description": "Validation deltas per ingredient assertion. Present when the ingredient is a C2PA asset.", "items": { "$ref": "#/definitions/ingredientDeltaValidationResult" } @@ -1719,11 +1754,6 @@ "type": "string", "format": "uri", "description": "URI to the trust list that was used to validate certificates" - }, - "validationTime": { - "type": "string", - "format": "date-time", - "description": "Time when the validation was performed; RFC 3339 format" } }, "required": [ diff --git a/sdk/src/cr_json_reader.rs b/sdk/src/cr_json_reader.rs index d321ae601..a7f02c97d 100644 --- a/sdk/src/cr_json_reader.rs +++ b/sdk/src/cr_json_reader.rs @@ -38,7 +38,7 @@ use crate::{ time_stamp::tsa_signer_cert_der_from_token, }, error::{Error, Result}, - jumbf::labels::to_absolute_uri, + jumbf::labels::{manifest_label_from_uri, to_absolute_uri}, reader::{AsyncPostValidator, MaybeSend, PostValidator, Reader}, status_tracker::StatusTracker, validation_results::{ @@ -46,6 +46,7 @@ use crate::{ SIGNING_CREDENTIAL_EXPIRED, SIGNING_CREDENTIAL_INVALID, SIGNING_CREDENTIAL_TRUSTED, SIGNING_CREDENTIAL_UNTRUSTED, }, + StatusCodes, ValidationState, }, validation_status::ValidationStatus, @@ -212,10 +213,11 @@ impl CrJsonReader { let manifests_array = self.convert_manifests_to_array()?; result["manifests"] = manifests_array; - // Add validationResults (output of validation_results() method). - // When present, validationResults always includes activeManifest (see ValidationResults::from_store). + // Add document-level validationInfo (summary + validationTime). ingredientDeltas are per-manifest. if let Some(validation_results) = self.inner.validation_results() { - result["validationResults"] = serde_json::to_value(validation_results)?; + if let Some(vs) = self.build_validation_info(validation_results)? { + result["validationInfo"] = vs; + } } // jsonGenerator: claim_generator_info fields from the (active) manifest + date (ISO 8601). @@ -276,11 +278,14 @@ impl CrJsonReader { .unwrap_or_else(|| Value::Object(Map::new())); manifest_obj.insert("signature".to_string(), signature); - // Build status object (required; use empty object when no validation results) - let status = self - .build_manifest_status(manifest, label)? - .unwrap_or_else(|| Value::Object(Map::new())); - manifest_obj.insert("status".to_string(), status); + // Build validationResults (statusCodes) for this manifest (required; use empty success/informational/failure when none) + let validation_results = self.build_manifest_validation_results(label); + manifest_obj.insert("validationResults".to_string(), validation_results); + + // Per-manifest ingredientDeltas: deltas whose ingredientAssertionURI belongs to this manifest + if let Some(deltas) = self.build_manifest_ingredient_deltas(label) { + manifest_obj.insert("ingredientDeltas".to_string(), deltas); + } labeled.push((label.clone(), Value::Object(manifest_obj))); } @@ -1228,65 +1233,80 @@ impl CrJsonReader { Ok(components) } - /// Build status object for a manifest - fn build_manifest_status(&self, manifest: &Manifest, _label: &str) -> Result> { - let validation_results = match self.inner.validation_results() { - Some(results) => results, - None => return Ok(None), - }; - - let mut status = Map::new(); - - // Extract key validation codes from results - let active_manifest = match validation_results.active_manifest() { + /// Build document-level validationInfo object (signature codes array, trust, content, validationTime). + fn build_validation_info( + &self, + validation_results: &crate::validation_results::ValidationResults, + ) -> Result> { + let active_codes = match validation_results.active_manifest() { Some(am) => am, None => return Ok(None), }; - // Signature validation status - if let Some(sig_code) = - Self::find_validation_code(&active_manifest.success, "claimSignature") - { - status.insert("signature".to_string(), json!(sig_code)); - } + let mut info = Map::new(); - // Trust status: prefer trusted, invalid, untrusted, expired; else first signingCredential code - if let Some(trust_code) = - Self::find_preferred_trust_code(&active_manifest) - { - status.insert("trust".to_string(), json!(trust_code)); + let sig_codes = Self::find_all_validation_codes(active_codes, "claimSignature"); + if !sig_codes.is_empty() { + info.insert("signature".to_string(), json!(sig_codes)); + } + if let Some(trust_code) = Self::find_preferred_trust_code(active_codes) { + info.insert("trust".to_string(), json!(trust_code)); } else if let Some(trust_code) = - Self::find_validation_code(&active_manifest.success, "signingCredential") + Self::find_validation_code(&active_codes.success, "signingCredential") { - status.insert("trust".to_string(), json!(trust_code)); + info.insert("trust".to_string(), json!(trust_code)); } else if let Some(trust_code) = - Self::find_validation_code(&active_manifest.failure, "signingCredential") + Self::find_validation_code(&active_codes.failure, "signingCredential") { - status.insert("trust".to_string(), json!(trust_code)); + info.insert("trust".to_string(), json!(trust_code)); } - - // Content validation status if let Some(content_code) = - Self::find_validation_code(&active_manifest.success, "assertion.dataHash") + Self::find_validation_code(&active_codes.success, "assertion.dataHash") { - status.insert("content".to_string(), json!(content_code)); + info.insert("content".to_string(), json!(content_code)); } - // Assertion-specific validation codes - let mut assertion_status = Map::new(); - for assertion in manifest.assertions() { - let assertion_label = assertion.label(); - if let Some(code) = - Self::find_validation_code_for_assertion(&active_manifest.success, assertion_label) - { - assertion_status.insert(assertion_label.to_string(), json!(code)); - } - } - if !assertion_status.is_empty() { - status.insert("assertion".to_string(), Value::Object(assertion_status)); + if let Some(t) = validation_results.validation_time() { + info.insert("validationTime".to_string(), json!(t)); } - Ok(Some(Value::Object(status))) + Ok(Some(Value::Object(info))) + } + + /// Build validationResults (statusCodes) for a single manifest. Active manifest gets its codes; others get empty. + fn build_manifest_validation_results(&self, label: &str) -> Value { + let codes: StatusCodes = match ( + self.inner.active_label(), + self.inner.validation_results(), + ) { + (Some(active), Some(vr)) if active == label => { + vr.active_manifest().cloned().unwrap_or_default() + } + _ => StatusCodes::default(), + }; + serde_json::to_value(&codes).unwrap_or_else(|_| { + json!({ + "success": [], + "informational": [], + "failure": [] + }) + }) + } + + /// Build ingredientDeltas for a single manifest: deltas whose ingredientAssertionURI belongs to this manifest. + fn build_manifest_ingredient_deltas(&self, label: &str) -> Option { + let validation_results = self.inner.validation_results()?; + let deltas = validation_results.ingredient_deltas()?; + let for_manifest: Vec<_> = deltas + .iter() + .filter(|idv| { + manifest_label_from_uri(idv.ingredient_assertion_uri()).as_deref() == Some(label) + }) + .collect(); + if for_manifest.is_empty() { + return None; + } + serde_json::to_value(&for_manifest).ok() } /// Find a preferred trust status (trusted, invalid, untrusted, expired) in the active manifest. @@ -1318,19 +1338,21 @@ impl CrJsonReader { .map(|s| s.code().to_string()) } - /// Find validation code for a specific assertion - fn find_validation_code_for_assertion( - statuses: &[ValidationStatus], - assertion_label: &str, - ) -> Option { - statuses + /// Collect all validation codes (from success, informational, failure) that start with the given prefix. + fn find_all_validation_codes( + active_codes: &crate::validation_results::StatusCodes, + prefix: &str, + ) -> Vec { + let mut codes: Vec = active_codes + .success() .iter() - .find(|s| { - s.url() - .map(|u| u.contains(assertion_label)) - .unwrap_or(false) - }) + .chain(active_codes.informational().iter()) + .chain(active_codes.failure().iter()) + .filter(|s| s.code().starts_with(prefix)) .map(|s| s.code().to_string()) + .collect(); + codes.dedup(); + codes } /// Get a reference to the underlying Reader @@ -1410,7 +1432,7 @@ mod tests { assert!(manifest.get("label").is_some()); assert!(manifest.get("assertions").is_some()); assert!(manifest.get("signature").is_some()); - assert!(manifest.get("status").is_some()); + assert!(manifest.get("validationResults").is_some()); let has_claim = manifest.get("claim").is_some(); let has_claim_v2 = manifest.get("claim.v2").is_some(); assert!(has_claim != has_claim_v2, "manifest must have exactly one of claim (v1) or claim.v2"); diff --git a/sdk/src/validation_results.rs b/sdk/src/validation_results.rs index bd171be74..4d89d1b8a 100644 --- a/sdk/src/validation_results.rs +++ b/sdk/src/validation_results.rs @@ -117,8 +117,8 @@ pub struct ValidationResults { #[serde(rename = "ingredientDeltas", skip_serializing_if = "Option::is_none")] ingredient_deltas: Option>, - /// Time when the validation was performed (RFC 3339 date-time). Exported in crJSON as validationTime. - #[serde(rename = "validationTime", skip_serializing_if = "Option::is_none")] + /// Time when the validation was performed (RFC 3339 date-time). Used only for document-level validationInfo; not serialized in validationResults (e.g. ingredient assertions). + #[serde(rename = "validationTime", skip_serializing)] validation_time: Option, } @@ -327,6 +327,11 @@ impl ValidationResults { self.ingredient_deltas.as_ref() } + /// Returns the time when validation was performed (RFC 3339), if set. + pub fn validation_time(&self) -> Option<&str> { + self.validation_time.as_deref() + } + pub fn add_active_manifest(mut self, scm: StatusCodes) -> Self { self.active_manifest = Some(scm); self diff --git a/sdk/tests/crjson/schema_compliance.rs b/sdk/tests/crjson/schema_compliance.rs index 92d049535..554e61076 100644 --- a/sdk/tests/crjson/schema_compliance.rs +++ b/sdk/tests/crjson/schema_compliance.rs @@ -53,56 +53,60 @@ fn test_validation_results_schema_compliance() -> Result<()> { let json_value = reader.to_json_value()?; - // Verify validationResults exists - let validation_results = json_value - .get("validationResults") - .expect("validationResults should exist"); - assert!( - validation_results.is_object(), - "validationResults should be an object" - ); - - let vr = validation_results.as_object().unwrap(); - - // Required per schema: activeManifest (statusCodes with success, informational, failure) - let active_manifest = vr - .get("activeManifest") - .expect("validationResults must have activeManifest per crJSON schema"); - assert!(active_manifest.is_object(), "activeManifest should be object"); - let am = active_manifest.as_object().unwrap(); - for key in &["success", "informational", "failure"] { - let arr = am - .get(*key) - .unwrap_or_else(|| panic!("activeManifest must have {} array per schema", key)); - assert!(arr.is_array(), "{} should be array", key); - for entry in arr.as_array().unwrap() { - assert!(entry.is_object(), "Each entry should be object"); - let obj = entry.as_object().unwrap(); - assert!(obj.contains_key("code"), "Entry should have code (validationStatusEntry)"); - assert!(obj.get("code").unwrap().is_string(), "code should be string"); - if let Some(url) = obj.get("url") { - assert!(url.is_string(), "url should be string"); + // Document-level validationInfo (summary + validationTime) + if let Some(validation_info) = json_value.get("validationInfo") { + assert!( + validation_info.is_object(), + "validationInfo should be an object" + ); + let vi = validation_info.as_object().unwrap(); + if let Some(signature) = vi.get("signature") { + assert!(signature.is_array(), "validationInfo.signature should be array of status codes"); + for item in signature.as_array().unwrap() { + assert!(item.is_string(), "validationInfo.signature items should be strings"); } - if let Some(explanation) = obj.get("explanation") { - assert!(explanation.is_string(), "explanation should be string"); + } + for key in &["trust", "content", "validationTime"] { + if let Some(v) = vi.get(*key) { + assert!(v.is_string(), "validationInfo.{} should be string", key); } } } - // Optional: ingredientDeltas array - if let Some(deltas) = vr.get("ingredientDeltas") { - assert!(deltas.is_array(), "ingredientDeltas should be array"); - for item in deltas.as_array().unwrap() { - assert!(item.is_object(), "Each delta should be object"); - let obj = item.as_object().unwrap(); - assert!( - obj.contains_key("ingredientAssertionURI"), - "Delta should have ingredientAssertionURI" - ); - assert!( - obj.contains_key("validationDeltas"), - "Delta should have validationDeltas" - ); + // Per-manifest validationResults (statusCodes: success, informational, failure) and optional ingredientDeltas + let manifests = json_value + .get("manifests") + .and_then(|m| m.as_array()) + .expect("manifests should exist"); + if let Some(first) = manifests.first() { + let vr = first + .get("validationResults") + .expect("manifest must have validationResults per crJSON schema"); + assert!(vr.is_object(), "validationResults should be object"); + let vr_obj = vr.as_object().unwrap(); + for key in &["success", "informational", "failure"] { + let arr = vr_obj + .get(*key) + .unwrap_or_else(|| panic!("validationResults must have {} array per schema", key)); + assert!(arr.is_array(), "{} should be array", key); + for entry in arr.as_array().unwrap() { + assert!(entry.is_object(), "Each entry should be object"); + let obj = entry.as_object().unwrap(); + assert!( + obj.contains_key("code"), + "Entry should have code (validationStatusEntry)" + ); + assert!(obj.get("code").unwrap().is_string(), "code should be string"); + } + } + // Optional: per-manifest ingredientDeltas + if let Some(deltas) = first.get("ingredientDeltas") { + assert!(deltas.is_array(), "manifest ingredientDeltas should be array"); + for item in deltas.as_array().unwrap() { + let obj = item.as_object().expect("Each delta should be object"); + assert!(obj.contains_key("ingredientAssertionURI"), "Delta should have ingredientAssertionURI"); + assert!(obj.contains_key("validationDeltas"), "Delta should have validationDeltas"); + } } } @@ -110,49 +114,47 @@ fn test_validation_results_schema_compliance() -> Result<()> { } #[test] -fn test_manifest_status_schema_compliance() -> Result<()> { +fn test_manifest_validation_and_status_schema_compliance() -> Result<()> { let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; let json_value = reader.to_json_value()?; - // Get manifests array + // Document-level validationInfo (signature array, trust, content, validationTime) + if let Some(validation_info) = json_value.get("validationInfo") { + assert!(validation_info.is_object(), "validationInfo should be an object"); + let info_obj = validation_info.as_object().unwrap(); + + if let Some(signature) = info_obj.get("signature") { + assert!(signature.is_array(), "signature should be array of status codes"); + for item in signature.as_array().unwrap() { + assert!(item.is_string(), "signature items should be strings"); + } + } + if let Some(trust) = info_obj.get("trust") { + assert!(trust.is_string(), "trust status should be string"); + } + if let Some(content) = info_obj.get("content") { + assert!(content.is_string(), "content status should be string"); + } + if let Some(validation_time) = info_obj.get("validationTime") { + assert!(validation_time.is_string(), "validationTime should be string"); + } + } + + // Each manifest has validationResults (statusCodes) let manifests = json_value .get("manifests") .expect("manifests should exist") .as_array() .expect("manifests should be an array"); - - // Check first manifest for status - if let Some(manifest) = manifests.first() { - if let Some(status) = manifest.get("status") { - assert!(status.is_object(), "status should be an object"); - let status_obj = status.as_object().unwrap(); - - // Per-manifest status can have: signature, trust, content, assertion - // All should be strings or objects - - if let Some(signature) = status_obj.get("signature") { - assert!(signature.is_string(), "signature status should be string"); - } - - if let Some(trust) = status_obj.get("trust") { - assert!(trust.is_string(), "trust status should be string"); - } - - if let Some(content) = status_obj.get("content") { - assert!(content.is_string(), "content status should be string"); - } - - if let Some(assertion) = status_obj.get("assertion") { - assert!(assertion.is_object(), "assertion status should be object"); - // Each assertion status value should be a string - for (_key, value) in assertion.as_object().unwrap() { - assert!( - value.is_string(), - "assertion status values should be strings" - ); - } - } - } + for manifest in manifests { + let vr = manifest + .get("validationResults") + .expect("manifest should have validationResults"); + assert!(vr.is_object(), "validationResults should be object"); + let vr_obj = vr.as_object().unwrap(); + assert!(vr_obj.contains_key("success")); + assert!(vr_obj.contains_key("informational")); + assert!(vr_obj.contains_key("failure")); } Ok(()) @@ -192,7 +194,7 @@ fn test_manifests_array_schema_compliance() -> Result<()> { assert!(manifests.is_array(), "manifests should be an array"); - // Check each manifest (schema required: label, assertions, signature, status; oneOf: claim or claim.v2) + // Check each manifest (schema required: label, assertions, signature, validationResults; oneOf: claim or claim.v2) for manifest in manifests.as_array().unwrap() { assert!(manifest.is_object(), "Each manifest should be an object"); let manifest_obj = manifest.as_object().unwrap(); @@ -216,9 +218,11 @@ fn test_manifests_array_schema_compliance() -> Result<()> { .expect("manifest should have signature"); assert!(signature.is_object(), "signature should be object"); - // Required: status (object) - let status = manifest_obj.get("status").expect("manifest should have status"); - assert!(status.is_object(), "status should be object"); + // Required: validationResults (statusCodes object) + let validation_results = manifest_obj + .get("validationResults") + .expect("manifest should have validationResults"); + assert!(validation_results.is_object(), "validationResults should be object"); // oneOf: either claim or claim.v2 (implementation emits claim.v2) let has_claim = manifest_obj.get("claim").is_some(); @@ -264,10 +268,8 @@ fn test_complete_schema_structure() -> Result<()> { // Verify all top-level required fields (no asset_info, content, or metadata) assert!(json_value.get("@context").is_some(), "@context missing"); assert!(json_value.get("manifests").is_some(), "manifests missing"); - assert!( - json_value.get("validationResults").is_some(), - "validationResults missing" - ); + // validationInfo present when validation was run; manifests have validationResults + assert!(json_value.get("validationInfo").is_some(), "validationInfo missing"); // CrJSON does not include asset_info, content, or metadata assert!(json_value.get("asset_info").is_none()); @@ -277,7 +279,7 @@ fn test_complete_schema_structure() -> Result<()> { // Verify types assert!(json_value["@context"].is_object()); assert!(json_value["manifests"].is_array()); - assert!(json_value["validationResults"].is_object()); + assert!(json_value["validationInfo"].is_object()); Ok(()) } @@ -298,10 +300,16 @@ fn test_cr_json_schema_file_valid_and_matches_format() -> Result<()> { assert!(props.contains_key("@context"), "schema must define @context"); assert!(props.contains_key("manifests"), "schema must define manifests"); assert!( - props.contains_key("validationResults"), - "schema must define validationResults" + props.contains_key("validationInfo"), + "schema must define validationInfo" ); + // Manifest definition must allow ingredientDeltas (per-manifest) + let definitions = schema_value.get("definitions").and_then(|d| d.as_object()).expect("schema must have definitions"); + let manifest_def = definitions.get("manifest").and_then(|m| m.as_object()).expect("schema must define manifest"); + let manifest_props = manifest_def.get("properties").and_then(|p| p.as_object()).expect("manifest must have properties"); + assert!(manifest_props.contains_key("ingredientDeltas"), "manifest must define ingredientDeltas (per-manifest)"); + // CrJSON schema must NOT include removed sections assert!(!props.contains_key("declaration"), "schema must not include declaration"); assert!(!props.contains_key("asset_info"), "schema must not include asset_info"); @@ -350,3 +358,122 @@ fn test_cr_json_output_matches_schema_root() -> Result<()> { Ok(()) } + +/// Schema must define validationResults (used by ingredient assertions, e.g. c2pa.ingredient.v3). +/// When crJSON output contains an ingredient assertion with validationResults, it must match that definition. +#[test] +fn test_validation_results_definition_and_ingredient_usage() -> Result<()> { + let schema_value: serde_json::Value = + serde_json::from_str(CRJSON_SCHEMA).expect("crJSON-schema.json must be valid JSON"); + let definitions = schema_value + .get("definitions") + .and_then(|d| d.as_object()) + .expect("schema must have definitions"); + + // validationResults definition must exist (used by manifest-level statusCodes and by ingredientAssertionV3) + let validation_results_def = definitions + .get("validationResults") + .and_then(|v| v.as_object()) + .expect("schema must define validationResults for use by ingredient assertions"); + assert_eq!( + validation_results_def + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or(""), + "object", + "validationResults must be type object" + ); + let vr_props = validation_results_def + .get("properties") + .and_then(|p| p.as_object()) + .expect("validationResults must have properties"); + assert!( + vr_props.contains_key("activeManifest"), + "validationResults must have activeManifest (statusCodes)" + ); + assert!( + vr_props.contains_key("ingredientDeltas"), + "validationResults must have ingredientDeltas" + ); + let vr_required = validation_results_def + .get("required") + .and_then(|r| r.as_array()) + .expect("validationResults must have required array"); + assert!( + vr_required.iter().any(|v| v.as_str() == Some("activeManifest")), + "validationResults.required must include activeManifest" + ); + + // ingredientAssertionV3 must reference validationResults + let ingredient_v3 = definitions + .get("ingredientAssertionV3") + .and_then(|v| v.as_object()) + .expect("schema must define ingredientAssertionV3"); + let v3_props = ingredient_v3 + .get("properties") + .and_then(|p| p.as_object()) + .expect("ingredientAssertionV3 must have properties"); + let vr_ref = v3_props + .get("validationResults") + .and_then(|v| v.as_object()) + .and_then(|o| o.get("$ref")) + .and_then(|r| r.as_str()) + .expect("ingredientAssertionV3.validationResults must have $ref to validationResults"); + assert_eq!( + vr_ref, + "#/definitions/validationResults", + "ingredientAssertionV3.validationResults must $ref #/definitions/validationResults" + ); + + // When crJSON output contains an ingredient assertion with validationResults, validate its shape + let reader = CrJsonReader::from_stream("image/jpeg", Cursor::new(IMAGE_WITH_MANIFEST))?; + let json_value = reader.to_json_value()?; + let manifests = json_value + .get("manifests") + .and_then(|m| m.as_array()) + .expect("manifests should exist"); + for manifest in manifests { + let assertions = match manifest.get("assertions").and_then(|a| a.as_object()) { + Some(a) => a, + None => continue, + }; + for (_key, assertion_value) in assertions { + let assertion_obj = match assertion_value.as_object() { + Some(o) => o, + None => continue, + }; + if let Some(ingredient_vr) = assertion_obj.get("validationResults") { + // This assertion has validationResults (e.g. v3 ingredient) - must match validationResults definition + let vr = ingredient_vr + .as_object() + .expect("ingredient validationResults must be object"); + let active_manifest = vr + .get("activeManifest") + .expect("ingredient validationResults must have activeManifest per schema"); + let am = active_manifest + .as_object() + .expect("activeManifest must be object (statusCodes)"); + for key in &["success", "informational", "failure"] { + assert!( + am.contains_key(*key), + "ingredient validationResults.activeManifest must have {} array", + key + ); + assert!( + am.get(*key).unwrap().as_array().is_some(), + "ingredient validationResults.activeManifest.{} must be array", + key + ); + } + if let Some(deltas) = vr.get("ingredientDeltas") { + assert!( + deltas.is_array(), + "ingredient validationResults.ingredientDeltas must be array" + ); + } + } + } + } + + Ok(()) +} From c87d7379b2265492e8b64d0be63b187feae3bfe4 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Tue, 10 Mar 2026 08:42:53 -0400 Subject: [PATCH 17/18] update schema --- cli/schemas/crJSON-schema.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json index 715d990f1..37d882929 100644 --- a/cli/schemas/crJSON-schema.json +++ b/cli/schemas/crJSON-schema.json @@ -465,6 +465,10 @@ "$ref": "#/definitions/hashBoxesAssertion" } ] + }, + "^c2pa\\.actions\\.v2(__[0-9]+)?$": { + "description": "Actions assertion (v2) with optional multiple instance identifier (e.g. c2pa.actions.v2, c2pa.actions.v2__1).", + "$ref": "#/definitions/actionsAssertionV2" } }, "additionalProperties": true From 52985b2b1081274aff9650fe7a8ec9abfc2d1de5 Mon Sep 17 00:00:00 2001 From: Leonard Rosenthol Date: Tue, 10 Mar 2026 13:55:35 -0400 Subject: [PATCH 18/18] minor change --- cli/schemas/crJSON-schema.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/schemas/crJSON-schema.json b/cli/schemas/crJSON-schema.json index 37d882929..87ebcae10 100644 --- a/cli/schemas/crJSON-schema.json +++ b/cli/schemas/crJSON-schema.json @@ -81,12 +81,11 @@ }, "manifest": { "type": "object", - "description": "CrJSON manifest wrapper containing label, assertions, claim or claim.v2, signature, and validationResults. Claim content: claim.cddl claim-map or claim-map-v2.", + "description": "CrJSON manifest wrapper containing label, assertions, claim or claim.v2, signature. Claim content: claim.cddl claim-map or claim-map-v2.", "required": [ "label", "assertions", - "signature", - "validationResults" + "signature" ], "oneOf": [ {