Summary
The CDDL declares the soft-binding block value as a CBOR byte string, but there is no input to
Builder that produces one when the assertion is supplied as JSON. Every available shape yields
an array, a text string or a map instead. In the natural case, where the value is given as a JSON
array of byte values, the reference Reader renders the result identically to a conformant
bstr, so a read-back check cannot tell the two apart and the mismatch shows up only in the raw
CBOR on disk.
What the spec requires
C2PA 2.4 section 18.10.2:
soft-binding-block-map = {
"scope": soft-binding-scope-map,
"value": bstr, ; CBOR byte string describing, in algorithm specific format,
; the value of the soft binding computed over this block of digital content
}
sdk/src/assertions/soft_binding.rs agrees:
pub struct SoftBindingBlock {
pub scope: SoftBindingScope,
#[serde(default, with = "serde_bytes")]
pub value: Vec<u8>,
}
What is produced instead
Measured by reading the CBOR initial byte of the block value off the signed asset, using
c2pa-python 0.37.10 (native c2pa-v0.90.22):
JSON supplied as value |
CBOR written |
major type |
[0, 1, 2, … 31] |
98 20 |
4, array |
"AAECAw…" (base64) |
78 2c |
3, text string |
"000102…" (hex) |
78 40 |
3, text string |
{"kind": "Cbor", …} |
a2 |
5, map |
There is no fifth option. A bstr is not reachable from JSON.
Root cause
AssertionDefinition's Deserialize impl in sdk/src/builder.rs holds the assertion body as a
serde_json::Value and converts it generically:
let cbor_val = c2pa_cbor::value::to_value(helper.data)?;
AssertionData::Cbor(cbor_val)
serde_json::Value has no bytes variant, so the typed struct is never involved and its
serde_bytes attribute never runs.
Builder::add_assertion_impl has the same problem separately: it builds an AssertionDefinition
with a struct literal, so it does not go through that Deserialize impl at all, and
add_assertion("c2pa.soft-binding", &serde_json::json!(...)) lands in the same place. A caller
passing a typed SoftBinding through add_assertion is fine, because serde_bytes runs
for that path.
Reproduction
let value: Vec<u8> = (0u8..32).collect();
let json = serde_json::json!({
"assertions": [{
"label": "c2pa.soft-binding",
"data": {
"alg": "com.example.watermark.1",
"blocks": [{ "scope": {}, "value": value }]
}
}]
}).to_string();
let builder = Builder::from_json(&json).unwrap();
// the CBOR value is Array([Integer(0), Integer(1), …]), not Bytes([0, 1, …])
Substituting a base64 or hex string for value yields a CBOR text string instead. Neither is a
bstr.
Why this has not been caught
Signing the same 32 bytes down a conformant path and down the JSON path produces files that
differ on disk but are indistinguishable through the reference reader:
conformant on disk: 58 20 … reader: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
JSON path on disk: 98 20 … reader: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
So the obvious verification, write it and read it back through the reference Reader, passes on
the broken encoding. Only a raw CBOR inspection separates them. That seems worth knowing
independently of this bug, since it means a read-back check cannot be used to confirm
conformance on this field.
Impact
Anything that consumes the assertion by parsing the CDDL rather than by using this SDK will not
find a bstr where the specification says one must be. A strict CDDL consumer would reject these
assets today, and because the mismatch is invisible to a read-back check it accumulates quietly
in the meantime.
This also reaches every c2pa-python user, since that binding has no assertion entry point
other than JSON: the exported C ABI offers c2pa_builder_from_json,
c2pa_builder_with_definition and the actions-specific c2pa_builder_add_action, and no typed
assertion call. So a Python caller cannot work around it at all.
Related, possibly a separate issue
alg-params is also declared bstr by the CDDL but is typed Option<String> in
sdk/src/assertions/soft_binding.rs, so it serialises as a CBOR text string regardless of how
the assertion is supplied. Happy to split that out if you would prefer it tracked separately.
Offer
We have a patch that repairs the block value at both entry points, with tests that assert on
the CBOR major type rather than on the read-back value, and that pin the surrounding behaviour
(other keys preserved, malformed arrays left alone, every block covered). Happy to open it as a
PR if that is useful, or to leave the fix to you if you would rather take a different approach,
for example a general JSON convention for byte values rather than a per-label repair.
Summary
The CDDL declares the soft-binding block
valueas a CBOR byte string, but there is no input toBuilderthat produces one when the assertion is supplied as JSON. Every available shape yieldsan array, a text string or a map instead. In the natural case, where the value is given as a JSON
array of byte values, the reference
Readerrenders the result identically to a conformantbstr, so a read-back check cannot tell the two apart and the mismatch shows up only in the rawCBOR on disk.
What the spec requires
C2PA 2.4 section 18.10.2:
sdk/src/assertions/soft_binding.rsagrees:What is produced instead
Measured by reading the CBOR initial byte of the block
valueoff the signed asset, usingc2pa-python0.37.10 (nativec2pa-v0.90.22):value[0, 1, 2, … 31]98 20"AAECAw…"(base64)78 2c"000102…"(hex)78 40{"kind": "Cbor", …}a2There is no fifth option. A
bstris not reachable from JSON.Root cause
AssertionDefinition'sDeserializeimpl insdk/src/builder.rsholds the assertion body as aserde_json::Valueand converts it generically:serde_json::Valuehas no bytes variant, so the typed struct is never involved and itsserde_bytesattribute never runs.Builder::add_assertion_implhas the same problem separately: it builds anAssertionDefinitionwith a struct literal, so it does not go through that
Deserializeimpl at all, andadd_assertion("c2pa.soft-binding", &serde_json::json!(...))lands in the same place. A callerpassing a typed
SoftBindingthroughadd_assertionis fine, becauseserde_bytesrunsfor that path.
Reproduction
Substituting a base64 or hex string for
valueyields a CBOR text string instead. Neither is abstr.Why this has not been caught
Signing the same 32 bytes down a conformant path and down the JSON path produces files that
differ on disk but are indistinguishable through the reference reader:
So the obvious verification, write it and read it back through the reference
Reader, passes onthe broken encoding. Only a raw CBOR inspection separates them. That seems worth knowing
independently of this bug, since it means a read-back check cannot be used to confirm
conformance on this field.
Impact
Anything that consumes the assertion by parsing the CDDL rather than by using this SDK will not
find a
bstrwhere the specification says one must be. A strict CDDL consumer would reject theseassets today, and because the mismatch is invisible to a read-back check it accumulates quietly
in the meantime.
This also reaches every
c2pa-pythonuser, since that binding has no assertion entry pointother than JSON: the exported C ABI offers
c2pa_builder_from_json,c2pa_builder_with_definitionand the actions-specificc2pa_builder_add_action, and no typedassertion call. So a Python caller cannot work around it at all.
Related, possibly a separate issue
alg-paramsis also declaredbstrby the CDDL but is typedOption<String>insdk/src/assertions/soft_binding.rs, so it serialises as a CBOR text string regardless of howthe assertion is supplied. Happy to split that out if you would prefer it tracked separately.
Offer
We have a patch that repairs the block
valueat both entry points, with tests that assert onthe CBOR major type rather than on the read-back value, and that pin the surrounding behaviour
(other keys preserved, malformed arrays left alone, every block covered). Happy to open it as a
PR if that is useful, or to leave the fix to you if you would rather take a different approach,
for example a general JSON convention for byte values rather than a per-label repair.