Skip to content

Releases: richardwooding/c2pa

v0.5.0 — reject a signature that does not cover the claim

Choose a tag to compare

@richardwooding richardwooding released this 28 Aug 15:25
1f6adb4

A security fix to Validate, and the test infrastructure that found it.

Fixed — a signature could cover bytes that were not the claim

verifyCOSE injected the claim box's bytes only when the COSE payload was detached, and never compared an attached payload against the claim box:

if msg.Payload == nil {
    msg.Payload = m.claimBytes
}
// ...the else branch was unchecked

Real C2PA signers always detach the payload, so nothing legitimate reached the other branch. But a forged manifest could attach a payload it had signed and ship an entirely different c2pa.claim box. The signature verified over the attacker's bytes, while the claim that was parsed, reported through Info, and hash-checked came from the substituted box — yielding claimSignature.validated, signingCredential.trusted, and Valid == true for a manifest whose signature covered none of its stated provenance.

An attached payload is now required to equal the claim box; otherwise Validate records claimSignature.mismatch.

Impact. Only files that attach a COSE payload are affected, which real signers do not produce — so no legitimate asset changes verdict. If you consume Validate for trust decisions, take this release.

Added — a generated test corpus

The suite previously had five binary fixtures and synthesised failure paths by byte-mutating them, which cannot express an expired certificate, an ES256 signature, or a timestamp that fails one specific way. There is now a generator — JUMBF superbox/jumd writer, assertion store, 1.x and 2.x claims, COSE_Sign1, JPEG APP11 / PNG caBX framing, and a hand-rolled RFC 3161 / CMS token writer — that builds valid assets from scratch and then applies named mutations.

It is entirely test-only: no new fixtures are committed, no new dependencies, still no cgo, and the package stays flat.

  • 16 positive cases across 2 containers x 4 signature algorithms x 2 claim shapes. ES256, ES384 and EdDSA had never been exercised — only PS256.
  • 33 negative cases pinned to exact status codes, covering the certificate profile checks, hard-binding dispatch, assertion hashes, and every reachable timeStamp.* failure.
  • Two new fuzz targets seeded from generated assets. FuzzBMFFParse and FuzzBMFFHash were also added to the nightly matrix, which had been running 7 of 9 targets since BMFF support landed.

Statement coverage rose from 78.9% to 82.8%, concentrated where it was weakest: timestamp.go 74.9% → 82.2%, chain.go 70.3% → 86.9%.

Notes

No public API change: Read, Validate, Info, the options and the status codes are all unchanged. This is a minor rather than a patch because it changes validation outcomes for a verifier library.

Four status codes remain declared but unreachable — claim.multiple, timeStamp.outsideValidity, and assertion.boxesHash.match/.mismatch (the latter two dead by design while c2pa.hash.boxes reports general.unsupported).

Full changelog: v0.4.0...v0.5.0

v0.4.0 — read the C2PA 2.x shapes real files use

Choose a tag to compare

@richardwooding richardwooding released this 27 Aug 07:57
2958938

Reads the C2PA 2.x shapes that current writers — OpenAI's and Google's signers among them — actually put in a file, and that the library previously read back empty or as malformed.

Fixed — timestamps in a bare CMS ContentInfo

sigTst and sigTst2 may hold either an RFC 3161 TimeStampResp or a bare CMS ContentInfo. Both descents probed with a two-RawValue struct, which matches any two-element SEQUENCE — including a ContentInfo — so a ContentInfo token was descended into as a response, failed, and was rejected. Read returned a zero SignedAt and Validate recorded timeStamp.mismatch ("malformed timestamp token") for every timestamp of that shape, which is what Google- and OpenAI-signed images carry.

The two shapes are now told apart by their first element (an OID for a ContentInfo, a SEQUENCE for a PKIStatusInfo) in one shared helper used by both call sites.

signingTime also looked only at sigTst, while Validate's extraction already handled sigTst2. A c2pa.claim.v2 signature carries its timestamp only in sigTst2, so Read's SignedAt stayed zero for exactly those files. It now reads either container.

Fixed — claim_generator_info

An array of entries in 1.x, a single entry in 2.x. A c2pa.claim.v2 carries only the 2.x shape and no flat claim_generator, so Info.ClaimGenerator came back empty for every such file. Both shapes are now read.

Added — Info.SoftwareAgent

softwareAgent names the model rather than the signing service (e.g. gpt-image 2.0 on OpenAI's c2pa.created action) and had no field. It is read as the 1.x string, the 2.x inline entry, or a 2.x softwareAgentIndex into the assertion's softwareAgents array, and is assigned per manifest the way ClaimGenerator is, so the two fields always describe the same manifest.

Tests

New fixture testdata/c2pa_2x_openai.png — an OpenAI-generated PNG carrying all three 2.x shapes; reverting the fixes under TestC2PA2xOpenAIFixture reproduces all three symptoms. The timestamp tests unwrap the existing signed fixture's own token to get the bare ContentInfo shape and assert it reaches timeStamp.validated; the remaining cases synthesise CBOR.

No API removals or behaviour changes beyond the above.

Full changelog: v0.3.0...v0.4.0

v0.3.0 — BMFF (MP4/MOV/HEIC/HEIF/AVIF) support

Choose a tag to compare

@richardwooding richardwooding released this 27 Aug 07:59
94e4505

Backfilled release notes — the v0.3.0 tag was pushed 2026-08 without a GitHub release entry.

Adds BMFF containers (MP4, MOV, HEIC, HEIF, AVIF) to the pure-Go (no cgo) toolkit, alongside the existing JPEG and PNG support.

Added — BMFF Container

The manifest is read from the top-level C2PA uuid box: purpose-aware, merkle-offset tolerant, padding-trimmed. Read and Validate both accept it.

Added — c2pa.hash.bmff.v2 / .v3 hard-binding verification

Validate verifies the BMFF hard binding per spec §18.6: exclusion xpath / length / data / subset / version / flags predicates, then a single ascending pass hashing each non-excluded top-level box as an 8-byte big-endian offset marker plus its bytes minus exclusions.

New status codes: assertion.bmffHash.match, assertion.bmffHash.mismatch, assertion.bmffHash.malformed.

Changed

  • BEHAVIOR CHANGE — a BMFF hard binding on a non-BMFF container is now hardBinding.missing instead of an informational "unsupported". The old status let a bmff-only manifest validate with nothing actually checked.
  • v1 c2pa.hash.bmff is ignored per spec §18.6.1; a manifest whose only binding is v1 reports hardBinding.missing.
  • Fragmented / Merkle BMFF assets are reported as informational unsupported.
  • Ingredient manifests no longer run hard-binding checks against the active asset's bytes — their binding refers to the ingredient's original bytes, so the result is informational instead of a spurious mismatch.
  • The COSE x5chain lookup also accepts the pre-1.3 c2pa-rs text key "x5chain" in either header.

Tests

Fixtures from c2pa-rs (Apache-2.0 / MIT): a signed MP4 that serves as the hash oracle and validates end to end, its unsigned twin, and a v1-legacy MP4. Two new fuzz targets (FuzzBMFFParse, FuzzBMFFHash); FuzzRead / FuzzValidate extended to the new container.

Full changelog: v0.2.0...v0.3.0

v0.2.0 — full pure-Go C2PA validation

Choose a tag to compare

@richardwooding richardwooding released this 11 Jun 08:00
133d45f

First release with cryptographic validation. The library is now a two-mode pure-Go (no cgo) C2PA / Content Credentials toolkit for JPEG & PNG.

Added — Validate

A full, opt-in verifier alongside the existing fast, unverified Read:

r := c2pa.Validate(ctx, c2pa.JPEG, reader, opts...)
// r.Valid, r.Statuses (C2PA §15 codes), r.SignerChain, r.SignedAt, r.Info

It performs the complete C2PA validation algorithm in pure Go:

  • COSE signature — detached-payload COSE_Sign1 verification (ES256/384/512, PS256/384/512, EdDSA).
  • Certificate chain + C2PA profile — chains the signer to the trust list and enforces the C2PA cert profile (EKU, key usage, no weak algorithms), pinned to the verified signing time.
  • Hash bindingsc2pa.hash.data (asset content hash, with exclusions) and per-assertion hashed_uri integrity.
  • RFC 3161 timestamp — full CMS signature verification, TSA chain, and message-imprint binding to the signature.
  • Revocation — OCSP/CRL, opt-in (WithOnlineRevocation), soft-fail.
  • Ingredients — recursive nested-manifest validation with depth cap and cycle detection.

Trust anchors are the official C2PA conformance lists, embedded via go:embed and overridable with WithSigningTrust / WithTimestampTrust. Options: WithOnlineRevocation, WithClock, WithMaxIngredientDepth, WithMaxScan, WithHTTPClient.

Read is unchanged — same fast, never-fail, unverified behaviour.

Changed

  • Go 1.25+ is now required (the floor is set by golang.org/x/crypto, pulled in for OCSP).
  • New dependency: golang.org/x/crypto. Still no cgo.

Full changelog: v0.1.0...v0.2.0

v0.1.0 — initial release: pure-Go read-only C2PA reader

Choose a tag to compare

@richardwooding richardwooding released this 27 Aug 08:00

Backfilled release notes — the v0.1.0 tag was pushed 2026-06-11 without a GitHub release entry.

Initial release: a small, pure-Go, read-only reader for C2PA / Content Credentials provenance manifests embedded in JPEG and PNG files.

Extracted from file-search-on, where it powers the is_c2pa / c2pa_* search attributes.

What it does

Surfaces what a file claims about its provenance — creating tool, title, declared format, whether it declares AI-generated content, and the claimed signer identity + signing time — by parsing the embedded JUMBF manifest (ISO 19566-5), CBOR-decoding the active manifest's claim and c2pa.actions assertion, and decoding the COSE_Sign1 signature envelope.

info := c2pa.Read(context.Background(), c2pa.JPEG, f) // or c2pa.PNG
info.Present, info.ClaimGenerator, info.Title, info.Format,
info.AIGenerated, info.SignedBy, info.SignedAt

Read is best-effort and never returns an error: a missing or malformed manifest yields Info{Present: false}. It reads at most c2pa.MaxScan (16 MiB) and honours the context, surrendering promptly mid-scan on cancel.

⚠️ Unverified — read, not validate

At this version the library is the equivalent of reading EXIF, or an email From: header: it reports the file's claims and does not authenticate them. No COSE signature verification, no certificate-chain check against the C2PA trust list — SignedBy is who the file claims signed it. Useful for search, indexing, triage and inventory; not for trust decisions.

(Cryptographic validation arrived later, in v0.2.0's Validate.)

Public API

Read(ctx, container, r) Info; WalkBoxes(ctx, jumbf, fn) for assertions Read doesn't model (depth-capped against adversarial nesting); Info; MaxScan.

Requirements

Go 1.23+. Pure Go, no cgo — depends only on fxamacker/cbor and veraison/go-cose. Context-aware and fuzzed (FuzzRead, FuzzWalkBoxes).