Skip to content

c2pa.soft-binding block value cannot be encoded as a CBOR bstr #2689

Description

@borisasadanin

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedTriaged and accepted for workbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions