Skip to content

Integrate C2PA #239

Description

@justin13888

There is an official Rust library: https://github.com/contentauth/c2pa-rs

Integrate traits and types in the relevant gamut libraries (e.g. gamut-exif).


Scope (settled)

docs/non-image-media.md — normative for scope — already splits this: gamut locates, bounds,
carries and reserves
the manifest store; validation belongs to c2pa-rs. This epic is
gamut's half only.

  • No crypto in the shipped graph. No COSE, X.509, RFC 3161 or trust lists in any gamut crate.
    c2pa-rs is a consumer-side dependency and a dev-only oracle. gamut never reports a validity
    verdict and no API may imply one.
  • The store is opaque bytes plus byte ranges. No JUMBF interior, no CBOR, no claim or
    assertion model in this epic.
  • Home is the metadata facade, not gamut-exif. The body above is wrong on this: a manifest
    store is a JUMBF superbox (ISO/IEC 19566-5), never an IFD value. It becomes a fourth carrier
    plus per-format raw payloads, exactly as EXIF/XMP/ICC work today. In TIFF/DNG an IFD tag points
    at
    the store, but the payload is opaque to the IFD layer just as ICC and XMP already are.

Acceptance

  • MetadataBlock::C2pa / Metadata::c2pa / EncodedMetadata::c2pa land as a fourth carrier
    (all three already #[non_exhaustive] — semver-minor), with the "why a carrier and not an
    extension" rationale in gamut-metadata/README.md.
  • A manifest is never copied forward across a re-encode or a metadata-only rewrite. Both
    invalidate the hard binding; a derivative needs a new manifest carrying the parent as an
    ingredient. The facade drops or refuses by explicit policy, never silently passes through —
    and this exception to the "extract → embed → extract is a true equality" keystone is
    documented.
  • Every container that can carry a store can locate it and report the exclusion set an
    external signer needs: byte ranges for c2pa.hash.data (§18.5), box paths for
    c2pa.hash.bmff.v3 (§18.6, §A.5.6). One primitive serves both reading and post-encode
    patching; EncodeImage is not touched.
  • Encoders accept a reserved placeholder of caller-chosen size, and nothing after placement
    moves a byte — gamut-jpeg's appmeta::patch_stream ordering pinned, is_crate_owned
    classifying APP11 explicitly (a JPEG may carry non-C2PA APP11 segments — JPEG 360, JPEG
    Privacy & Security — which must not be swept into the store, §15.12.1.2).
  • Multi-segment reassembly reuses the IccAssembler pattern and box walking reuses
    gamut_isobmff::BoxReader — no second implementation of either.
  • Hostile input: cumulative size budget (with_max_metadata_bytes precedent), nesting-depth
    cap, bounded reassembly; malformed payloads skip rather than error.
  • Byte accounting stays complete: a located store is a claimed span, not an unclassified
    run, in gamut-ifd's SegmentReport and HeifContainer::segments.
  • A file with no embedded store but a dcterms:provenance URL in its XMP reports remote
    provenance, not none (§11.5, §15.5.3.1 — that key is for external manifests only).
    gamut-xmp/gamut-metadata: report remote provenance from dcterms:provenance #449
  • gamut inspect reports presence, size and offsets, and states that it does not validate.
    gamut-cli: report a C2PA manifest store in gamut inspect, and state that it is not validated #448
  • mise run check-ffi-features still passes and mise run check-cross wasm32-unknown-unknown is green — proof no crypto reached the shipped graph.
  • Dev-only differential oracle against c2pa-rs in a workspace-excluded tooling/ crate,
    both directions: gamut reserves → external signer completes → c2pa-rs validates; and
    c2pa-rs embeds → gamut locates the identical byte range.
  • references/c2pa/README.md in house style, staking every clause used.
  • Semver recorded: minor for gamut-metadata / gamut-png / gamut-webp / gamut-jpeg, whose
    metadata structs are #[non_exhaustive]; major for gamut-dng (DngMetadata) and
    gamut-riff (MetadataChunks), which are deliberately exhaustive.
  • mise run test, lint, fmt-check, coverage (≥80%), mutants pass.

Progress

#427, #428 and #429 landed 2026-08-31 (PRs #431, #432, #433). Verified on the three merged
together over master, not only per-branch: fmt-check, check-ffi-features, check-cross wasm32-unknown-unknown (2m53s — the acceptance criterion above, which extended CI only runs on
master), clippy -D warnings and cargo test over gamut-metadata + gamut-heic (213 tests).

Open where it matters: gamut-heic's locator bounds a store by its JUMBF LBox alone, which is
content-dependent — a wrong offset can read as a valid bound and silently trim the store rather
than fail. The TBox == jumb check that would close it is deliberately not asserted: both traces
for that constant are JPEG XL clauses attributing the box to ISO/IEC 18181-2 §9.3 rather than
defining it. Recorded as a deferred row in gamut-heic/STATUS.md, and #447 carries the fixture
that settles it empirically.

Child issues

Each stakes its own container clause of the C2PA spec.

Ordering: #427 + #428 first → (#429, #441, #440 in parallel) → #442#443#444#445
#446.
#447 tracks whichever container landed first and grows with each; #448 follows the first
container that can locate a store, and #449 only needs #428.

Three things easy to get wrong

  • DNG's data hash needs two disjoint exclusion ranges, not one. §18.5.5: besides the store
    payload, "the count field of the C2PA Manifest Store's IFD Entry … should be included in the
    exclusion ranges"
    , so an update manifest can change the store's size. The entry sits at a
    different file offset from the payload. Also note §18.7.3.3 removed general box hash for
    TIFF entirely — c2pa.hash.data is the only binding for TIFF/DNG.
  • A JPEG C2PA segment's own header is inside the exclusion, not hashed. §18.5.3: "the
    APP11 marker (FFEB) and the segment's length (Lp) of all APP11 segments containing the
    JUMBF data shall be included in the exclusion range."
    Because §A.3.1 requires the segments be
    contiguous, one range covers them all. Do not hash the marker bytes and exclude only the
    payload.
  • Asset metadata should be included in the binding, not excluded. §9.2.6: a claim generator
    "should, where possible, include asset metadata (i.e., metadata outside a C2PA Manifest such as
    EXIF or XMP) in the hard binding, in order to protect its integrity."
    Excluding it anyway is
    permitted but forces the informational status assertion.dataHash.additionalExclusionsPresent.
    The store's own range plus format extras is the only required exclusion.

A correction to docs/non-image-media.md

Lines 201 and 301 say the ISOBMFF carriage is "uuid box c2ma/c2um". That conflates two
nesting levels. c2ma/c2um are the leading four ASCII bytes of JUMBF type UUIDs
(c2ma = 63326D61…) naming boxes inside the manifest store; they never appear as ISOBMFF box
types. Accurate wording:

ISOBMFF top-level uuid box, user type D8FEC3D6-1B0E-483C-9297-5828877EC481, whose payload
carries the JUMBF manifest store (c2pa superbox), itself containing c2ma/c2um manifest
superboxes.

Also worth recording there: there is no item-based (infe/iloc/ipco) C2PA placement in
the spec — Appendix A defines only the top-level uuid box for every BMFF-based asset, HEIF and
AVIF named explicitly.

References to vendor

New directory references/c2pa/ with a README.md in the house style (see
references/iptc/README.md). The C2PA documents are CC BY 4.0 — vendorable verbatim, keeping
the copyright line, the CC BY notice and the "AS IS" disclaimer. All clause numbers above are from
C2PA Technical Specification 2.4 (April 2026).

file source
C2PA_Specification_2.4.pdf C2PA Technical Specification 2.4 — https://spec.c2pa.org/specifications/specifications/2.4/specs/_attachments/C2PA_Specification.pdf
C2PA_Specification_2.4.html same, canonical HTML (clause anchors) — https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html
C2PA_Schemas_2.4.zip the normative CDDL and JSON Schemas — https://spec.c2pa.org/specifications/specifications/2.4/specs/_attachments/C2PA_Schemas.zip

(The 2.4 index page's download menu only lists PDFs up to 2.3, but both 2.4 _attachments/ URLs
above resolve.)

Not vendored — paywalled, transcribe the clauses used: ISO/IEC 19566-5:2023 (JUMBF; the
base container standard, CHF 135 — https://www.iso.org/standard/84635.html) · ISO/IEC 18477-3
(JPEG XT box file format; the source of the APP11 field framing) · ISO/IEC 18181-2:2024 (JPEG XL
container, jumb box).

Procurement decision to make before the gamut-jpeg slice. ISO/IEC 19566-5 Annex D.2 is
the only normative text for the APP11 field layout (CI/En/Z/LBox/TBox), and it is
paywalled. Every other constant in this issue is sourced from the CC BY C2PA spec. Writing JPEG
segmentation against the reference implementation instead of the ISO text would violate
"specification as source of truth" — so either buy 19566-5 or descope JPEG. Note in particular
that c2pa-rs writes box instance number En = 0x0211 with the comment "can be any unique ID"
that value is a c2pa-rs convention, not normative, and must not be transcribed as if it
were spec.

Already vendored, no action: references/jpeg/itu-t81.pdf (marker syntax §B.2.4.6) ·
references/png/ (chunk layout, 4.7.2) · references/isobmff/ (14496-12 box syntax) ·
references/tiff/ (IFD entry layout) · references/webp/ (RIFF chunk order).

Oracle

c2pa-rs (Apache-2.0/MIT) as a dev-only differential oracle in a workspace-excluded
tooling/c2pa-oracle, invoked by manifest path — the tooling/gamut-dng-real-conformance shape.
Being a Rust crate it needs no third_party/ submodule. Build it
--no-default-features --features rust_native_crypto: the default openssl feature is pulled
vendored and would compile OpenSSL from C source.

Two facts that shaped the split above, both verified in c2pa-rs 0.90.16:

  • Builder's *_embeddable path plus composed_manifest(bytes, "application/c2pa") yields the
    raw manifest-store bytes for a host to embed itself, and CallbackSigner carries a
    reserve_size — c2pa-rs already models signing as reserve-then-fill, which is exactly the seam
    gamut needs to expose.
  • There is no cheap parse-only mode: ValidationState::Invalid is also what you get when
    verification is disabled, so c2pa-rs cannot serve as a "parse but don't judge" front end. That
    is why gamut owns the locate/bound step rather than shelling out for it.
  • Do not build on c2pa::jumbf_io — its public functions name crate-private traits
    (CAIRead/CAIReadWrite), a private-in-public leak.

Out of scope

  • Signing, signature verification, trust lists, timestamping, validity verdicts — c2pa-rs.
  • The JUMBF interior: claims, CBOR, assertion schemas, ingredient ancestry. A later issue, if a
    consumer needs typed observability.
  • JPEG XL — §A.3.9 puts the store in a top-level jumb box, and a bare FF 0A codestream
    cannot carry one at all. Blocked here regardless: jxl-rs surfaces no box bytes on decode
    (gamut-jxl/STATUS.md:120-122).
  • C2PA in non-image containers — docs/non-image-media.md, Extend gamut to non-image formats #217 / Extend gamut-metadata to audio and video files #216.

Note on the minimized comment below: it is from a non-maintainer, proposes an unvendored
third-party CI as an acceptance oracle, and asserts a downstream dependency nothing in this repo
corroborates. It is not a requirements source — oracles here are dev-only and live under
tooling/ against a vendored reference.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions