CWR

Reading C2PA metadata by hand

JUMBF, CBOR and COSE_Sign1, walked through byte by byte on a real signed image. No SDK, no WASM — just enough of three specifications to read what a file claims about itself.

Last reviewed

Every hex dump below comes from C.jpg, a signed fixture from the c2pa-rs test suite. 132,518 bytes, of which 45,872 are provenance metadata — about a third of the file. That ratio surprises people.

The reader described here powers the inspector on this site. It is roughly 500 lines of TypeScript with no dependencies, which is worth knowing before reaching for the WASM SDK: if you only need to read a manifest, the format is far more approachable than its reputation.

1. Where the bytes live

C2PA does not define its own container. It stores a JUMBF block, and each image format carries that block wherever it keeps application data. In PNG that is a caBX chunk. In WebP it is a C2PA RIFF chunk. In JPEG it is APP11, and JPEG is the awkward one, because a JPEG segment cannot exceed 65,533 bytes and manifests routinely do.

The first APP11 segment in the fixture, at offset 0x14:

ff eb b3 3a                                       marker FFEB, length 0xb33a = 45882
4a 50                                             'JP'  common identifier
02 11                                             box instance number (En)
00 00 00 01                                       packet sequence (Z), 1-based
00 00 b3 30  6a 75 6d 62                          LBox 45872, TBox 'jumb'

After the two-byte length come eight bytes of framing: the identifier JP, a box instance number, and a packet sequence number. The JUMBF payload starts at byte nine.

To reassemble, collect every APP11 segment, group by instance number, sort by sequence, and concatenate the payloads. Here one segment suffices — 45,882 minus the two length bytes minus the eight framing bytes gives exactly the 45,872 the box header declares. On a larger manifest you get a dozen segments and sorting by Z stops being optional.

One trap: do not assume markers are contiguous. Scan for 0xFF, skip standalone markers (0x01, 0xD00xD9) which carry no length field, and stop at 0xDA, start-of-scan, after which the bytes are entropy-coded image data and any 0xFFEB you find is a coincidence.

2. JUMBF is just boxes

JUMBF (ISO/IEC 19566-5) borrows ISOBMFF framing, the same idea as MP4: a four-byte big-endian length, a four-byte ASCII type, then payload. The length counts the header.

00 00 b3 30                                       LBox = 45872 (includes these 8 bytes)
6a 75 6d 62                                       TBox = 'jumb' (superbox)

Two special cases: LBox == 1 means a 64-bit length follows the type, and LBox == 0 means the box runs to the end of its parent. Handle both or you will eventually loop forever on a legitimate file.

A jumb superbox opens with a jumd description box. That is where the semantics live:

63 32 70 61 00 11 00 10 80 00 00 aa 00 38 9b 71   content type UUID
03                                                toggles
63 32 70 61 00                                    label "c2pa", NUL-terminated

Note the first four bytes of the UUID: 63 32 70 61 is ASCII c2pa. C2PA picks UUIDs whose leading bytes spell the type, which makes the tree readable in a hex editor. You will meet c2ma (a manifest), c2as (an assertion store), c2cl (a claim) and c2cs (a claim signature).

The toggles byte gates the optional fields that follow. Bit 1 means a label is present, bit 2 an ID, bit 3 a signature. Here 0x03 sets bits 0 and 1, so a label follows immediately. Read the toggles before reading anything after them, or the offsets drift.

3. The tree

Walking the whole thing produces this:

jumb [c2pa] "c2pa"                                        45864 B
  jumd
  jumb [c2ma] "contentauth:urn:uuid:b2b1f7fa-…"            45826 B
    jumd
    jumb [c2as] "c2pa.assertions"                          29215 B
      jumd
      jumb "c2pa.thumbnail.claim.jpeg"                     28565 B
        jumd
        bfdb                                                  12 B
        bidb                                               28486 B
      jumb [json] "stds.schema-org.CreativeWork"              190 B
      jumb [cbor] "c2pa.actions"                              224 B
      jumb [cbor] "c2pa.hash.data"                            163 B
    jumb [c2cl] "c2pa.claim"                                  716 B
    jumb [c2cs] "c2pa.signature"                           15788 B

The shape is worth internalising. A store holds manifests; a manifest holds an assertion store, one claim and one signature. Assertions are the content, the claim is a list of cryptographic references to them, and the signature covers the claim.

Note where the bytes actually are. The embedded thumbnail is 28,486 of the 45,872 — 62% of the manifest is a JPEG preview. The claim, the part carrying the meaning, is 716 bytes.

Also note that c2pa.thumbnail has no ASCII content tag. Its children are bfdb and bidb, the standard JUMBF binary description and data boxes, whose UUID is not a C2PA one and therefore does not spell anything. If you build the ASCII-tag shortcut, expect junk on standard box types and do not branch on it.

4. Enough CBOR

Claims and most assertions are CBOR (RFC 8949). You do not need a full implementation, but you cannot skip the encoding either. Every item begins with one byte: the top three bits are the major type, the low five are the argument.

The first sixteen bytes of the claim:

a8                     major 5 (map), arg 8      → map with 8 pairs
68                     major 3 (text), arg 8     → 8-byte string
   64 63 3a 74 69 74 6c 65                       → "dc:title"
65                     major 3 (text), arg 5     → 5-byte string
   43 2e 6a 70 67                                → "C.jpg"

So the claim opens {"dc:title": "C.jpg", …} and seven pairs follow. Arguments under 24 are the value itself; 24 through 27 mean a 1, 2, 4 or 8-byte length follows; 31 means indefinite length, terminated by a break byte.

Two decisions matter if you are writing the decoder. Keep byte strings as raw bytes — do not decode them as text, because COSE protected headers are themselves CBOR inside a byte string and you will need to re-parse them. And preserve tags rather than unwrapping them, because tag 18 is how you recognise a COSE signature at all.

5. The signature

The signature box holds a COSE_Sign1 structure (RFC 9052). The first sixteen bytes carry almost everything you want:

d2                     major 6 (tag), arg 18     → tag 18 = COSE_Sign1
84                     major 4 (array), arg 4    → [protected, unprotected, payload, signature]
59 0d 56               major 2 (bytes), 2-byte len → 3414-byte protected header
   a2                  map with 2 pairs           ┐
   01                  key 1 = alg                │ this is CBOR
   38 24               major 1, arg 0x24 = 36     │ nested inside
                       → -1 - 36 = -37 = PS256    │ the byte string
   18 21               key 33 = x5chain           │
   82                  array of 2                 → two certificates
   59 06 b4            1716-byte byte string      ┘
      30               DER SEQUENCE — a certificate

Algorithm -37 is PS256, RSA-PSS with SHA-256. The COSE registry uses negative integers for signature algorithms: -7 is ES256, -35 and -36 are ES384 and ES512, -37 through -39 are the RSA-PSS family.

Label 33 is x5chain. It is either a single certificate or an array of them, so handle both shapes. It can also appear in the unprotected header, so merge the two before looking.

For displaying who signed, a full X.509 parse is more than you need. Scanning the DER for attribute OIDs — 06 03 55 04 03 for commonName, 55 04 0a for organizationName — and reading the string that follows gets you readable names in twenty lines. Order is preserved, and since TBSCertificate puts issuer before subject, the first CN is the issuer and the last is the leaf. In this fixture that gives Intermediate CA and C2PA Signer.

6. The field that actually says “AI”

All that structure exists to deliver one assertion. c2pa.actions records what was done to the asset, and inside it digitalSourceType states how the content originated, using IPTC vocabulary:

  • trainedAlgorithmicMedia — created by a generative model
  • compositeWithTrainedAlgorithmicMedia — generative output mixed with other material
  • algorithmicMedia — produced algorithmically, not necessarily by a model
  • digitalCapture — from a camera or other capture device

Match the composite value before the generated one, since one string contains the other. This fixture reports algorithmicMedia, which is correct — it is a synthetic test image, not model output.

7. What I did not implement, and why that is the interesting part

Reading a manifest is not validating one. The reader described here does neither of the two things that would make it a verifier:

It does not recompute the hash bindings. The claim references each assertion by hash, and c2pa.hash.data binds the manifest to the actual pixel bytes. Without recomputing them, you cannot tell whether the image was altered after signing.

It does not validate the certificate chain. Names extracted from a certificate are strings in a file. Anyone can generate a certificate that says C2PA Signer. What separates a genuine manifest from a fabricated one is whether the chain terminates in a CA on the C2PA trust list — and that requires the trust list, chain building, and revocation checking.

Which is why every report the inspector produces says so. A tool that presents unvalidated assertions as verified provenance is worse than no tool, because it converts a file's own claims into apparent third-party confirmation. If you build one of these, state your scope on the output, not in the documentation nobody reads.

There is a second asymmetry worth carrying away, and it is not a software problem. Absence of a manifest means nothing. Provenance metadata does not survive a screenshot, most re-encoding, or upload to the majority of platforms. It is missing from most images in circulation regardless of how they were made. Presence tells you something; absence tells you nothing at all.

Source

The full reader is four files — the mechanism reference covers the surrounding context, and the parsers themselves are described in the repository README. Verify any implementation against the c2pa-rs fixtures before trusting it; a parser that has never met a real signed file is a parser that does not work.