use super::{
hash_bytes, is_allowed_hash_algorithm, is_generous, ClaimAssertionRefs, ClaimGeneration,
EngineProfile, ValidationResults, ALGORITHM_UNSUPPORTED, CLAIM_MALFORMED, HASHED_URI_MISMATCH,
HASHED_URI_MISSING,
};
use crate::c2pa_cbor::Value;
use crate::c2pa_core::jumbf::ParsedManifest;
const MAX_NESTED_REFERENCES: usize = 4_096;
const MAX_RELATED_DEPTH: usize = 8;
#[derive(Clone, Copy)]
enum Step {
Field(&'static str),
Each,
EachRelated,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Internal,
Either,
}
struct RefField {
path: &'static [Step],
kind: Kind,
}
const fn field(path: &'static [Step], kind: Kind) -> RefField {
RefField { path, kind }
}
const INGREDIENT_V1: &[RefField] = &[field(&[Step::Field("thumbnail")], Kind::Internal)];
const INGREDIENT_V2: &[RefField] = &[
field(&[Step::Field("thumbnail")], Kind::Internal),
field(&[Step::Field("data")], Kind::Either),
];
const INGREDIENT_V3: &[RefField] = &[
field(&[Step::Field("thumbnail")], Kind::Internal),
field(&[Step::Field("data")], Kind::Either),
];
const ACTIONS: &[RefField] = &[
field(
&[
Step::Field("actions"),
Step::Each,
Step::EachRelated,
Step::Field("parameters"),
Step::Field("ingredient"),
],
Kind::Internal,
),
field(
&[
Step::Field("actions"),
Step::Each,
Step::EachRelated,
Step::Field("parameters"),
Step::Field("ingredients"),
Step::Each,
],
Kind::Internal,
),
field(
&[
Step::Field("actions"),
Step::Each,
Step::EachRelated,
Step::Field("parameters"),
Step::Field("relatedAssertions"),
Step::Each,
],
Kind::Internal,
),
field(
&[
Step::Field("actions"),
Step::Each,
Step::EachRelated,
Step::Field("softwareAgent"),
Step::Field("icon"),
],
Kind::Either,
),
field(
&[
Step::Field("softwareAgents"),
Step::Each,
Step::Field("icon"),
],
Kind::Either,
),
field(
&[Step::Field("templates"), Step::Each, Step::Field("icon")],
Kind::Internal,
),
];
fn ref_fields(label: &str) -> &'static [RefField] {
match base_label(label) {
"c2pa.ingredient" => INGREDIENT_V1,
"c2pa.ingredient.v2" => INGREDIENT_V2,
"c2pa.ingredient.v3" => INGREDIENT_V3,
"c2pa.actions" | "c2pa.actions.v2" => ACTIONS,
_ => &[],
}
}
pub(super) fn base_label(label: &str) -> &str {
match label.split_once("__") {
Some((base, instance))
if !instance.is_empty() && instance.bytes().all(|b| b.is_ascii_digit()) =>
{
base
}
_ => label,
}
}
struct Located<'v> {
kind: Kind,
value: &'v Value,
path: String,
}
fn locate<'v>(
value: &'v Value,
kind: Kind,
steps: &[Step],
depth: usize,
path: &mut String,
out: &mut Vec<Located<'v>>,
limit: usize,
) -> bool {
if out.len() >= limit {
return true;
}
match steps.split_first() {
None => {
out.push(Located {
kind,
value,
path: path.clone(),
});
out.len() >= limit
}
Some((Step::Field(name), rest)) => {
let Some(child) = value.get(name) else {
return false;
};
let mark = path.len();
if !path.is_empty() {
path.push('.');
}
path.push_str(name);
let capped = locate(child, kind, rest, depth, path, out, limit);
path.truncate(mark);
capped
}
Some((Step::Each, rest)) => {
let Value::Array(items) = value else {
return false;
};
for (index, item) in items.iter().enumerate() {
let mark = path.len();
path.push('[');
path.push_str(&index.to_string());
path.push(']');
let capped = locate(item, kind, rest, depth, path, out, limit);
path.truncate(mark);
if capped {
return true;
}
}
false
}
Some((Step::EachRelated, rest)) => {
if locate(value, kind, rest, depth, path, out, limit) {
return true;
}
if depth >= MAX_RELATED_DEPTH {
return false;
}
let Some(Value::Array(items)) = value.get("related") else {
return false;
};
for (index, item) in items.iter().enumerate() {
let mark = path.len();
path.push_str(".related[");
path.push_str(&index.to_string());
path.push(']');
let capped = locate(item, kind, steps, depth + 1, path, out, limit);
path.truncate(mark);
if capped {
return true;
}
}
false
}
}
}
pub(super) fn verify_assertion_references(
claim: &Value,
claim_refs: &ClaimAssertionRefs<'_>,
generation: ClaimGeneration,
profile: EngineProfile,
manifest_label: &str,
manifests: &[ParsedManifest<'_>],
results: &mut ValidationResults,
) {
if generation == ClaimGeneration::V1 && is_generous(profile) {
return;
}
let claim_alg = claim.get("alg").and_then(Value::as_text);
let mut seen: Vec<&str> = Vec::new();
let mut examined = 0usize;
for reference in &claim_refs.references {
let Some(alabel) = reference.label else {
continue;
};
if seen.contains(&alabel) {
continue;
}
seen.push(alabel);
let fields = ref_fields(alabel);
if fields.is_empty() {
continue;
}
let Some(assertion) = claim_refs
.indexed(alabel)
.and_then(|indexed| indexed.decoded.as_ref())
else {
continue;
};
let assertion_url = format!("self#jumbf=/c2pa/{manifest_label}/c2pa.assertions/{alabel}");
let assertion_alg = assertion.get("alg").and_then(Value::as_text);
let mut located = Vec::new();
let mut capped = false;
for field in fields {
let remaining = MAX_NESTED_REFERENCES.saturating_sub(examined + located.len());
if remaining == 0 {
capped = true;
break;
}
let mut path = String::new();
if locate(
assertion,
field.kind,
field.path,
0,
&mut path,
&mut located,
remaining,
) {
capped = true;
break;
}
}
examined += located.len();
for located in &located {
validate_reference(
located,
alabel,
&assertion_url,
generation,
manifest_label,
manifests,
assertion_alg.or(claim_alg),
results,
);
}
if capped {
results.push_failure(
CLAIM_MALFORMED,
assertion_url,
format!(
"assertion reference count exceeds verifier bound ({MAX_NESTED_REFERENCES})"
),
);
return;
}
}
}
#[allow(clippy::too_many_arguments)] fn validate_reference(
located: &Located<'_>,
alabel: &str,
assertion_url: &str,
generation: ClaimGeneration,
manifest_label: &str,
manifests: &[ParsedManifest<'_>],
inherited_alg: Option<&str>,
results: &mut ValidationResults,
) {
let path = &located.path;
let Some(url) = located.value.get("url").and_then(Value::as_text) else {
results.push_failure(
HASHED_URI_MISSING,
assertion_url.to_string(),
format!("{alabel} field '{path}' is not a hashed URI: no url"),
);
return;
};
if located.kind == Kind::Either && !url.starts_with("self#jumbf=") {
return;
}
let Some(destination) = resolve(url, manifest_label, manifests) else {
results.push_failure(
HASHED_URI_MISSING,
url.to_string(),
format!("{alabel} field '{path}' references '{url}', which cannot be located"),
);
return;
};
let algorithm = located
.value
.get("alg")
.and_then(Value::as_text)
.or(inherited_alg);
let Some(algorithm) = algorithm else {
results.push_failure(
ALGORITHM_UNSUPPORTED,
url.to_string(),
format!("{alabel} field '{path}' has no hash algorithm"),
);
return;
};
if !is_allowed_hash_algorithm(algorithm) {
results.push_failure(
ALGORITHM_UNSUPPORTED,
url.to_string(),
format!("{alabel} field '{path}' uses unsupported hash algorithm '{algorithm}'"),
);
return;
}
let expected = located.value.get("hash").and_then(Value::as_bytes);
let matches_domain = |bytes: Option<&[u8]>| {
matches!(
(bytes.and_then(|bytes| hash_bytes(algorithm, bytes)), expected),
(Some(actual), Some(expected)) if actual.as_slice() == expected
)
};
let matched = matches_domain(Some(destination.jumbf))
|| (generation == ClaimGeneration::V1 && matches_domain(destination.payload));
if !matched {
results.push_failure(
HASHED_URI_MISMATCH,
url.to_string(),
format!("{alabel} field '{path}' hashed uri mismatch: {url}"),
);
}
}
struct Destination<'a> {
jumbf: &'a [u8],
payload: Option<&'a [u8]>,
}
fn resolve<'a>(
url: &str,
manifest_label: &str,
manifests: &'a [ParsedManifest<'_>],
) -> Option<Destination<'a>> {
let rest = url.strip_prefix("self#jumbf=")?;
let (target_manifest, alabel) = match rest.strip_prefix("/c2pa/") {
Some(absolute) => absolute.split_once("/c2pa.assertions/")?,
None => (manifest_label, rest.strip_prefix("c2pa.assertions/")?),
};
if alabel.is_empty() || alabel.contains('/') {
return None;
}
let manifest = unique(manifests, |manifest| manifest.label == target_manifest)?;
let (_, jumbf) = unique(&manifest.assertion_jumbf, |(label, _)| label == alabel)?;
Some(Destination {
jumbf,
payload: unique(&manifest.assertions, |(label, _)| label == alabel)
.map(|(_, payload)| *payload),
})
}
fn unique<T>(items: &[T], mut matches: impl FnMut(&T) -> bool) -> Option<&T> {
let mut found = items.iter().filter(|item| matches(item));
let first = found.next()?;
found.next().is_none().then_some(first)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::c2pa_cbor::{encode, Profile};
use crate::c2pa_core::jumbf::{assertion_box, superbox_content};
use sha2::{Digest, Sha256};
fn enc(value: &Value) -> Vec<u8> {
encode(value, Profile::CanonicalForHashedSubstructures).expect("encode")
}
fn vmap(fields: Vec<(&str, Value)>) -> Value {
Value::Map(
fields
.into_iter()
.map(|(key, value)| (Value::Text(key.into()), value))
.collect(),
)
}
fn sha(bytes: &[u8]) -> Vec<u8> {
Sha256::digest(bytes).to_vec()
}
const MANIFEST: &str = "urn:c2pa:active";
struct Fixture {
jumbf: Vec<(String, Vec<u8>)>,
payloads: Vec<(String, Vec<u8>)>,
}
impl Fixture {
fn new(assertions: &[(&str, Value)]) -> Self {
let mut jumbf = Vec::new();
let mut payloads = Vec::new();
for (label, value) in assertions {
let cbor = enc(value);
let boxed = assertion_box(label, &cbor, None);
let content = superbox_content(&boxed).expect("content").to_vec();
jumbf.push(((*label).to_string(), content));
payloads.push(((*label).to_string(), cbor));
}
Self { jumbf, payloads }
}
fn manifest(&self) -> ParsedManifest<'_> {
ParsedManifest {
label: MANIFEST.into(),
manifest_jumbf: &[],
assertions: self
.payloads
.iter()
.map(|(label, bytes)| (label.clone(), bytes.as_slice()))
.collect(),
assertion_jumbf: self
.jumbf
.iter()
.map(|(label, bytes)| (label.clone(), bytes.as_slice()))
.collect(),
claim_cbor: None,
signature_cose: None,
claim_count: 1,
claim_box_label: None,
}
}
fn content(&self, label: &str) -> &[u8] {
self.jumbf
.iter()
.find(|(candidate, _)| candidate == label)
.map(|(_, bytes)| bytes.as_slice())
.expect("assertion")
}
}
fn claim_for(labels: &[&str]) -> Value {
vmap(vec![(
"created_assertions",
Value::Array(
labels
.iter()
.map(|label| {
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
),
("hash", Value::Bytes(vec![0; 32])),
])
})
.collect(),
),
)])
}
fn run(fixture: &Fixture, claim: &Value) -> ValidationResults {
run_as(fixture, claim, ClaimGeneration::V2, EngineProfile::GENEROUS)
}
fn run_as(
fixture: &Fixture,
claim: &Value,
generation: ClaimGeneration,
profile: EngineProfile,
) -> ValidationResults {
let manifests = [fixture.manifest()];
let refs = ClaimAssertionRefs::build(&manifests[0], claim, generation);
let mut results = ValidationResults::default();
verify_assertion_references(
claim,
&refs,
generation,
profile,
MANIFEST,
&manifests,
&mut results,
);
results
}
fn hashed_uri(label: &str, hash: Vec<u8>) -> Value {
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
),
("hash", Value::Bytes(hash)),
("alg", Value::Text("sha256".into())),
])
}
#[test]
fn ingredient_thumbnail_reference_digest_is_enforced() {
let thumb = vmap(vec![("data", Value::Bytes(vec![7; 16]))]);
let staged = Fixture::new(&[("c2pa.thumbnail.ingredient", thumb.clone())]);
let digest = sha(staged.content("c2pa.thumbnail.ingredient"));
let good = Fixture::new(&[
("c2pa.thumbnail.ingredient", thumb.clone()),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"thumbnail",
hashed_uri("c2pa.thumbnail.ingredient", digest.clone()),
),
]),
),
]);
let results = run(
&good,
&claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]),
);
assert!(!results.has_failure(HASHED_URI_MISMATCH));
assert!(!results.has_failure(HASHED_URI_MISSING));
let tampered = Fixture::new(&[
("c2pa.thumbnail.ingredient", thumb),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"thumbnail",
hashed_uri("c2pa.thumbnail.ingredient", vec![0xAB; 32]),
),
]),
),
]);
let results = run(
&tampered,
&claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]),
);
assert!(results.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn unresolvable_reference_reports_missing_and_active_manifest_is_exempt() {
let fixture = Fixture::new(&[(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"thumbnail",
hashed_uri("c2pa.thumbnail.absent", vec![1; 32]),
),
(
"activeManifest",
vmap(vec![
("url", Value::Text("self#jumbf=/c2pa/urn:c2pa:gone".into())),
("hash", Value::Bytes(vec![2; 32])),
("alg", Value::Text("sha256".into())),
]),
),
]),
)]);
let results = run(&fixture, &claim_for(&["c2pa.ingredient.v3"]));
assert_eq!(
results
.failure
.iter()
.filter(|status| status.code == HASHED_URI_MISSING)
.count(),
1
);
assert!(results
.failure
.iter()
.all(|status| !status.explanation.contains("activeManifest")));
}
#[test]
fn nested_related_action_icon_is_walked() {
let icon = vmap(vec![("data", Value::Bytes(vec![3; 8]))]);
let related_action = vmap(vec![
("action", Value::Text("c2pa.edited".into())),
(
"softwareAgent",
vmap(vec![
("name", Value::Text("agent".into())),
("icon", hashed_uri("c2pa.icon", vec![0xCD; 32])),
]),
),
]);
let actions = vmap(vec![(
"actions",
Value::Array(vec![vmap(vec![
("action", Value::Text("c2pa.created".into())),
("related", Value::Array(vec![related_action])),
])]),
)]);
let fixture = Fixture::new(&[("c2pa.icon", icon), ("c2pa.actions.v2", actions)]);
let results = run(&fixture, &claim_for(&["c2pa.icon", "c2pa.actions.v2"]));
assert!(results.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn metadata_subtree_is_never_validated() {
let fixture = Fixture::new(&[(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"metadata",
vmap(vec![("reference", hashed_uri("c2pa.absent", vec![9; 32]))]),
),
]),
)]);
let results = run(&fixture, &claim_for(&["c2pa.ingredient.v3"]));
assert!(results.failure.is_empty(), "{:?}", results.failure);
}
#[test]
fn external_data_reference_is_left_to_external_validation() {
let external = Fixture::new(&[(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("inputTo".into())),
(
"data",
vmap(vec![
("url", Value::Text("https://example.com/data.bin".into())),
("hash", Value::Bytes(vec![4; 32])),
("alg", Value::Text("sha256".into())),
]),
),
]),
)]);
let results = run(&external, &claim_for(&["c2pa.ingredient.v3"]));
assert!(results.failure.is_empty(), "{:?}", results.failure);
let internal = Fixture::new(&[
(
"c2pa.embedded-data",
vmap(vec![("v", Value::Bytes(vec![5]))]),
),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("inputTo".into())),
("data", hashed_uri("c2pa.embedded-data", vec![6; 32])),
]),
),
]);
let results = run(
&internal,
&claim_for(&["c2pa.embedded-data", "c2pa.ingredient.v3"]),
);
assert!(results.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn hash_algorithm_is_inherited_then_checked() {
let thumb = vmap(vec![("data", Value::Bytes(vec![8; 4]))]);
let staged = Fixture::new(&[("c2pa.thumbnail.ingredient", thumb.clone())]);
let digest = sha(staged.content("c2pa.thumbnail.ingredient"));
let inherited = |alg: Option<&str>, claim_alg: Option<&str>| {
let mut reference = vec![
(
"url",
Value::Text("self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient".into()),
),
("hash", Value::Bytes(digest.clone())),
];
if let Some(alg) = alg {
reference.push(("alg", Value::Text(alg.into())));
}
let fixture = Fixture::new(&[
("c2pa.thumbnail.ingredient", thumb.clone()),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
("thumbnail", vmap(reference)),
]),
),
]);
let mut claim = claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]);
if let (Some(alg), Value::Map(entries)) = (claim_alg, &mut claim) {
entries.push((Value::Text("alg".into()), Value::Text(alg.into())));
}
run(&fixture, &claim)
};
let results = inherited(None, Some("sha256"));
assert!(results.failure.is_empty(), "{:?}", results.failure);
let results = inherited(None, None);
assert!(results.has_failure(ALGORITHM_UNSUPPORTED));
let results = inherited(Some("md5"), Some("sha256"));
assert!(results.has_failure(ALGORITHM_UNSUPPORTED));
assert!(!results.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn schema_lookup_handles_instances_and_ignores_vendor_assertions() {
assert!(!ref_fields("c2pa.ingredient.v3__2").is_empty());
assert!(ref_fields("c2pa.ingredient.v3__x").is_empty());
assert!(ref_fields("com.example.thing").is_empty());
let fixture = Fixture::new(&[(
"com.example.thing",
vmap(vec![("thumbnail", hashed_uri("c2pa.absent", vec![1; 32]))]),
)]);
let results = run(&fixture, &claim_for(&["com.example.thing"]));
assert!(results.failure.is_empty(), "{:?}", results.failure);
}
#[test]
fn a_v1_claim_is_held_to_the_reference_rule_only_under_strict_conformance() {
let ingredient = vmap(vec![("relationship", Value::Text("parentOf".into()))]);
let actions = vmap(vec![(
"actions",
Value::Array(vec![vmap(vec![
("action", Value::Text("c2pa.opened".into())),
(
"parameters",
vmap(vec![(
"ingredient",
hashed_uri("c2pa.ingredient", vec![0xEE; 32]),
)]),
),
])]),
)]);
let fixture = Fixture::new(&[("c2pa.ingredient", ingredient), ("c2pa.actions", actions)]);
let claim = vmap(vec![(
"assertions",
Value::Array(
["c2pa.ingredient", "c2pa.actions"]
.iter()
.map(|label| {
vmap(vec![
(
"url",
Value::Text(format!("self#jumbf=c2pa.assertions/{label}")),
),
("hash", Value::Bytes(vec![0; 32])),
])
})
.collect(),
),
)]);
let generous = run_as(
&fixture,
&claim,
ClaimGeneration::V1,
EngineProfile::GENEROUS,
);
assert!(generous.failure.is_empty(), "{:?}", generous.failure);
let strict = run_as(
&fixture,
&claim,
ClaimGeneration::V1,
EngineProfile::CONFORMANCE_V2_2,
);
assert!(strict.has_failure(HASHED_URI_MISMATCH));
}
#[test]
fn a_retargeted_nested_reference_reaches_the_verification_report() {
use super::super::{
verify_manifest, CawgTrustInputs, StoreContext, VerifyInput,
MAX_REPORT_DECODED_VALUE_NODES,
};
use crate::c2pa_core::EngineProfile;
use crate::c2pa_formats::AssetFormat;
let thumb = vmap(vec![("data", Value::Bytes(vec![7; 16]))]);
let fixture = Fixture::new(&[
("c2pa.thumbnail.ingredient", thumb),
(
"c2pa.ingredient.v3",
vmap(vec![
("relationship", Value::Text("parentOf".into())),
(
"thumbnail",
hashed_uri("c2pa.thumbnail.ingredient", vec![0xAB; 32]),
),
]),
),
]);
let claim = claim_for(&["c2pa.thumbnail.ingredient", "c2pa.ingredient.v3"]);
let claim_cbor = enc(&claim);
let mut manifest = fixture.manifest();
manifest.claim_cbor = Some(&claim_cbor);
manifest.claim_box_label = Some("c2pa.claim.v2".into());
let manifests = [manifest];
let manifest_hashes = std::collections::HashMap::new();
let input = VerifyInput {
data: &[],
mime: "application/c2pa",
claim_signer_trust: None,
tsa_trust: None,
allowed_certs: None,
validation_time: None,
profile: EngineProfile::GENEROUS,
evidence: Default::default(),
cawg_strict_encoding: false,
};
let mut report_decode_nodes = MAX_REPORT_DECODED_VALUE_NODES;
let out = verify_manifest(
&manifests[0],
StoreContext {
manifests: &manifests,
manifest_hashes: &manifest_hashes,
},
&input,
AssetFormat::C2paStore,
&[],
None,
CawgTrustInputs::default(),
&mut report_decode_nodes,
);
assert!(
out.results.has_failure(HASHED_URI_MISMATCH),
"{:?}",
out.results.failure
);
}
}