Skip to content
ecdsa.com
← All articles

C2PA Content Credentials: manifests, signatures, limits

We signed a JPEG with c2pa-python 0.38.1 (c2pa-rs 0.91.3), validated it as an untrusted and a trusted signer, changed one byte, and pushed it through an ordinary image pipeline. The specification text quoted is C2PA 2.4, the current version.

Short answer. A C2PA manifest is a signed statement about a media file: a claim that lists assertions (what was done, such as "created by a camera" or "edited with generative AI", plus a hash of the file's bytes), signed with COSE by a key whose X.509 certificate sits in the signature. Validators report a manifest as Well-Formed, Valid (the signature and the hashes check out) or Trusted (the signer's certificate chains to a trust anchor, by default the C2PA Trust List). An RFC 3161 timestamp keeps it valid after the certificate expires. What it does not do: prove that the picture shows the truth, or survive tools that re-encode the file. Treat a valid credential as "this signer vouches for these bytes", and its absence as no information at all.

What is in a manifest

The C2PA specification (version 2.4, April 2026) builds a manifest from a few parts:

  • Assertions, each a labelled CBOR or JSON box. c2pa.actions.v2 lists what happened to the asset (since 2.1, "Either a c2pa.created or a c2pa.opened is now mandatory"), with a digitalSourceType from the IPTC vocabulary that distinguishes a camera capture from generative AI. c2pa.hash.data is the hard binding: a hash over the asset's bytes, excluding the range where the manifest itself is stored. A thumbnail, ingredients (the assets this one was made from) and metadata may follow.
  • The claim: "A digitally signed and tamper-evident data structure that references a set of assertions", by hashed URI, so changing any assertion breaks the claim.
  • The claim signature: a COSE_Sign1 over the claim, with the signer's certificate chain in the protected header. "Exactly one instance of an identity credential shall appear", and since version 2.0 that credential is an X.509 certificate.
  • An optional timestamp from an RFC 3161 authority, over the signature.

All of it is serialized as JUMBF boxes inside the file (an APP11 segment in JPEG, a box in MP4 or HEIF) or stored remotely and referenced by URL. When an asset is edited, the editor adds a new manifest and records the previous one as an ingredient, so the store becomes a chain of manifests.

The allowed signature algorithms are ES256, ES384, ES512, PS256, PS384, PS512 and Ed25519 (§13.2.1); keys must match the algorithm, and RSA keys must have at least 2048 bits. For live video the specification recommends deterministic algorithms, so that identical segments stay cacheable.

Signing a JPEG

The lab creates a test CA with pyca/cryptography 49.0.0 and a P-256 signer certificate with the EKUs a C2PA validator looks for, c2pa-kp-claimSigning (1.3.6.1.4.1.62558.2.1) and, for older validators, id-kp-emailProtection. It then signs a generated 640×360 JPEG with a DigiCert timestamp:

lab.py (excerpt): manifest definition and signing (c2pa-python 0.38.1)
manifest = {
    "claim_generator_info": [{"name": "ecdsa.com C2PA lab", "version": "1.0"}],
    "title": "sunrise.jpg",
    "assertions": [
        {"label": "c2pa.actions.v2", "data": {"actions": [
            {"action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"}
        ]}}
    ],
}
signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo(alg=b"es256", sign_cert=chain_pem, private_key=key_pem, ta_url=b"http://timestamp.digicert.com"))
builder = c2pa.Builder.from_json(manifest)
signed = io.BytesIO()
builder.sign(signer, "image/jpeg", io.BytesIO(original), signed)

The certificate profile matters: the signer certificate carries digitalSignature in Key Usage, CA:FALSE, and the claim-signing EKU; the root is a CA with keyCertSign. A certificate without a suitable EKU, or a self-signed leaf, fails validation even if the signature is mathematically correct. X.509 certificate fields explains those extensions.

Validation: Valid, Trusted, Invalid

The same script reads the file back four times: without any trust configuration, with the test root as a trust anchor, with one byte of image data changed, and after decoding and re-encoding the image with Pillow 12.0.0 as an upload pipeline would:

lab.py (excerpt): reading with a trust anchor, tampering, re-encoding
trusted = c2pa.Context.from_dict({"trust": {"trust_anchors": pem(root).decode()}, "verify": {"verify_trust": True}})
read("signed, test root as trust anchor", signed, trusted)

# Change one byte of the compressed image data (after the JPEG Start of Scan marker).
sos = signed.rfind(b"\xff\xda")
tampered = bytearray(signed)
tampered[sos + 200] ^= 0x01
read("one byte of image data changed", bytes(tampered), trusted)

# What many upload pipelines do: decode and re-encode, dropping metadata.
Image.open(io.BytesIO(signed)).save(out := io.BytesIO(), "JPEG", quality=85)
read("re-encoded by an image pipeline", out.getvalue(), trusted)
Output (python:3.14.8-slim-trixie)
c2pa-python 0.38.1, 0.91.3
JPEG: 13376 bytes unsigned, 52438 bytes with the manifest (+39062)
--- signed, no trust anchors configured: validation_state = Valid
    status codes: timeStamp.validated, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, assertion.dataHash.match, signingCredential.ocsp.skipped, timeStamp.untrusted, signingCredential.untrusted
    signer: Example Newsroom / Example Newsroom Photo Desk, alg Es256, time 2026-10-22T06:43:11+00:00
    assertions: ['c2pa.actions.v2']
    assertion store: ['c2pa.actions.v2', 'c2pa.hash.data', 'c2pa.thumbnail.claim']
    certificate chain in the signature: 981 bytes DER
--- signed, test root as trust anchor: validation_state = Trusted
    status codes: timeStamp.validated, signingCredential.trusted, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, assertion.dataHash.match, signingCredential.ocsp.skipped, timeStamp.untrusted
--- one byte of image data changed: validation_state = Invalid
    status codes: timeStamp.validated, signingCredential.trusted, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, signingCredential.ocsp.skipped, timeStamp.untrusted, assertion.dataHash.mismatch
--- re-encoded by an image pipeline: no C2PA manifest found

How to read it:

  • Valid but untrusted. Every cryptographic check passed (claimSignature.validated, three assertion.hashedURI.match for the three assertions, assertion.dataHash.match for the image bytes), but signingCredential.untrusted: anyone can create a CA and sign. The specification defines this state precisely: a manifest is Valid when it is well-formed, unmodified, its signature validates "inside validity" and the credential is not revoked; it is Trusted only when, in addition, the signing credential "receives the success code of signingCredential.trusted" (§14.3).
  • Trusted once the validator knows the root. In production that root comes from the C2PA Trust List, "a C2PA-managed list of X.509 certificate trust anchors that issue certificates to hardware & software signers", which validators shall include for the c2pa-kp-claimSigning EKU; users may add their own anchors.
  • Invalid after one changed byte: the signature is still fine, the hard binding is not (assertion.dataHash.mismatch). A crop, a filter or a re-compression has the same effect, which is why editors must write a new manifest rather than keep the old one.
  • Gone after re-encoding. The manifest lives in metadata; a pipeline that decodes pixels and writes a new file drops it, like EXIF. Nothing in the output says a credential was ever there.

The manifest added 39 KB to a 13 KB image: besides the claim and our 981-byte certificate chain, it carries a thumbnail that c2pa-rs generated and the timestamp token with DigiCert's certificates. timeStamp.untrusted appears because the lab did not configure the C2PA TSA Trust List: the timestamp is cryptographically valid, its authority simply was not in the validator's list.

Timestamps and certificate lifetime

The specification is direct: "manifests without time-stamps cease to be valid when the signing credential expires or becomes revoked", and when a timestamp is present and trusted, validators "shall use the attested time, and not the current time" to check the certificates (§10.3.2.5.1, §15.8.2). This is the same construction as in Authenticode: the certificate only has to be valid at the moment of signing, and the timestamp authority proves when that was. Devices that sign offline need another solution; Google's Pixel camera, for example, uses "on-device trusted timestamps" so that photos "can be trusted after the certificate expires, even if they were captured when your device was offline" (Google Security Blog).

Where trust comes from

The signer's identity is the certificate subject, so the value of a credential depends on who issued that certificate and what they checked. C2PA runs a conformance program and the trust lists; validators configure them by EKU and may add anchors with notBefore and notAfter dates. Hardware matters too: Google reports that the Pixel 10 Camera app "achieved Assurance Level 2, the highest security rating currently defined by the C2PA Conformance Program", with claim signing keys "generated and stored using Android StrongBox in the Titan M2 security chip" and certificates issued only after Android Key Attestation proves a genuine device and app. Keys in secure hardware are covered in hardware-backed keys.

Only the signed assertions are the signer's statement. The specification gives an example: "the signer is not responsible for the accuracy of a GPS location appearing in EXIF metadata, even if that metadata is covered by the hard binding" (§9.2.6); only metadata placed in a c2pa.metadata assertion is attributed to the signer.

What Content Credentials do not do

  • They do not prove that an image is true. A camera credential says this device produced these bytes; a staged scene or a photo of a screen is still a camera capture. A generative-AI credential is a statement by the tool that chose to add it.
  • They are easy to remove. Our re-encode dropped the manifest without an error, and people often remove metadata on purpose, for privacy or size. The specification's answer is soft binding: a fingerprint or invisible watermark that can find the manifest again in a repository. A "Durable Content Credential" is one "for which there exists one or more soft bindings that enable its discovery in a manifest repository" (§2.4.1). Whether a given platform stores and looks up manifests that way is up to the platform.
  • Absence means nothing. Most images on the web have no credentials; a missing manifest is not evidence of manipulation.
  • They can leak more than intended. Ingredient chains, device names and locations travel with the file. Creators can redact assertions when an asset becomes an ingredient (§6.8), and tools should let people choose what is included.

If you build with C2PA

  1. Preserve or re-sign in your pipeline. If your service resizes or compresses uploads, either keep the original with its credential, or create a new manifest with the original as an ingredient and an honest c2pa.edited or c2pa.resized action, signed with your own certificate.
  2. Show validation states truthfully. Distinguish Trusted from merely Valid, show the signer's name from the certificate, and show nothing special when there is no manifest.
  3. Validate with the C2PA trust lists and current software. Status codes such as signingCredential.untrusted or timeStamp.untrusted usually mean configuration, not forgery.
  4. Get the certificate right: c2pa-kp-claimSigning EKU, key in an HSM or secure hardware, and timestamps on every signature.
  5. Do not make decisions on absence. A missing or stripped credential is the normal case.

Sources