Skip to content
C2PA

A Signed AI Video, and the Bug That Threw the Signature Away

Sighting.aiAugust 27, 20269 min read

There is a file on our measurement bench called `veo3_hf34data_b3cc5867.mp4`. It is video that no camera recorded. What makes it worth writing about is not the pixels — plenty of software makes plausible pixels now — but that the file says so itself, in cryptography, in a form anyone can check without trusting us or trusting whoever sent it.

As of 27 August 2026 our video lane returns this for it:

verdict ai · aiLikelihood 97 · tier proof · Very High · decided by C2PA

Before that it returned an error, with this explanation: *"its claim signature did not verify, so nothing it asserts can be relied on."*

That sentence was false. The signature had verified. We shipped the bug and ran it in production, and the reason it happened is the most useful thing we can tell you about how signed media actually works.

What is inside the file

C2PA is a provenance standard that puts a manifest inside the media file itself. A manifest is a set of assertions about how the content was made, signed by whoever made it, plus a hash that binds those assertions to the bytes they travelled with.

For this file the manifest carries:

  • An ES256 signature by "Google Media Processing Services", issuer "Google LLC".
  • An assertion of type `c2pa.created`.
  • `digitalSourceType: trainedAlgorithmicMedia` — the IPTC vocabulary term meaning the content came out of a trained generative model.
  • The human-readable description "Created by Google Generative AI."
  • `assertion.bmffHash.match`: the manifest's hash over the MP4 box structure matches these exact bytes.

That last one is what makes the rest worth anything. A `bmffHash` covers the container as delivered. Re-encode the file, or even losslessly re-mux it, and the hash stops matching. So a match means these are the generator's own bytes, not a copy that survived somebody's dataset pipeline. Of the six generated clips in our video corpus, this is the only one where that holds.

What is not inside the file

Our corpus calls this clip Veo 3. The manifest does not. There is no model name anywhere in it. The name comes from the HuggingFace dataset the file was pulled out of, which is a label a person typed, not a fact a signature establishes.

The distinction is small and it is not pedantry. What is proven is that Google's generative pipeline made this file and signed it. Which model ran is hearsay we happen to believe. Anywhere the model's identity would carry weight — a policy that treats one vendor's output differently, a claim in a dispute — the proof does not reach that far, and we should not let convenient shorthand pretend it does.

The codes

A C2PA reader does not hand back "valid" or "invalid". It hands back a list of validation status codes, each recorded as a success or a failure. For this file, before we changed anything:

Passed:

  • `claimSignature.validated` — the signature is cryptographically sound.
  • `claimSignature.insideValidity` — the signature was made while the signing certificate was still within its validity window.
  • `timeStamp.validated` — the RFC 3161 timestamp countersigning that signature checks out.
  • `assertion.bmffHash.match` — the manifest binds these bytes.

Failed:

  • `signingCredential.expired` — the certificate has since lapsed.
  • `signingCredential.untrusted` — the signer chained to no trust list, because we were carrying none.

Read that list quickly and "expired certificate" looks like the headline. It is the least interesting line in it, and treating it as the headline is exactly the error we made.

Expiry is not forgery

Certificates expire on purpose. A signing certificate is a statement that some authority vouched for a key over a stated period, and the period is short so that a compromised key stops being useful. Expiry is hygiene. It says nothing about whether a particular signature was honest.

So the question a verifier has to answer is not "is this certificate valid now". It is "was it valid at the moment the signature was made". Answering that requires a trustworthy record of when, because the signer's own claim about the date is worth nothing — anyone forging a signature would forge the date alongside it.

That record is what RFC 3161 timestamping provides. Before publishing, the signer sends a hash of the signature to a timestamp authority, an independent party that countersigns it with the current time under its own certificate. Later, anyone can check the countersignature and conclude that the signature already existed by that moment. A file signed in 2024 does not become a forgery in 2026 because a certificate lapsed in between. Freezing that moment is the entire purpose of the timestamp.

The reader already knew all of this. `claimSignature.insideValidity` and `timeStamp.validated` were both sitting in the success list while the rung said the signature had not verified.

Two things were wrong, and one of them was the interesting one

The first was that we shipped no trust bundle at all. Without one, `signingCredential.trusted` can never appear and every signer on earth reads as untrusted. We knew that cost us a confidence label. What we had not worked out was that it also cost us the timestamp: the timestamp authority was untrusted too, so the reader had no trusted record of when the signature was made, and only for that reason did it report the expiry as fatal. Supplying both published lists — claim signers and timestamp authorities — made the expiry failure disappear on its own. That was measured, not reasoned about.

The second was a category error in our own code, and it would have outlived the first. The rung kept a set of credential failures it treated as fatal, and `signingCredential.expired` was in it unconditionally. The fix is two lines:

```python fatal = failure & _CREDENTIAL_FATAL if _INSIDE_VALIDITY in success: fatal = fatal - {_CREDENTIAL_EXPIRED} ```

If the reader itself found that the signature was made inside the certificate's validity window, expiry is no longer allowed to be fatal.

The concession runs one way only. An expired credential still cannot reach `valid`, so it can never earn the `proof` tier, so it can never be the basis for calling anything authentic. It lands on `untrusted-chain`, where it may still accuse. That asymmetry is the standing rule in this system rather than a special case for this file: any lane may accuse, none may exonerate without positive provenance of the exact bytes.

Why the trust list was missing

Not because nobody thought of it. The bundle is a file of public certificates, and ignore rules — the kind that exist in every repository to keep private keys from being committed — matched it and dropped it from the build context. Twice. Both times the container started healthy. There is no error state for "trust list absent": nothing crashes, every signature simply reads untrusted, and the system looks like it is working while trusting nobody. The Dockerfile now fails the build outright if the bundle is not in the image.

The bundle itself is 52 certificates, fetched on 27 August 2026 from the C2PA's own conformance repository: 30 claim signers and 22 timestamp authorities. Both go in one file because the library verifies timestamp certificates against the same trust section as claim signatures; there is no separate anchor field for them.

Adding an anchor to that file is not a dependency bump. Every certificate on it is licensed to exonerate — the only way anything in this product can be called human-made is a capture manifest whose signer chains to that bundle — so a diff to it grants somebody the ability to have us call synthetic footage authentic. Removals are safe in the direction that matters: an unrecognised signer degrades to `untrusted-chain`, which still accuses and never exonerates.

One file is one file

The measured result for signed AI video is 1 of 1, across a corpus of nine clips in which the engine made no wrong accusations. That is n = 1: one generator, one file. It demonstrates that the path works end to end on real bytes, and it is not a coverage rate. It may not be quoted as one.

The common case is the other five. Five of the six generated clips in the corpus carry no manifest at all, so nothing in the video lane can speak about them and they return `uncertain`, 50, with a range of 20 to 80. That is the honest answer and it is also what most video in the wild looks like.

And a signature is not automatically an answer. The corpus includes a clip from the C2PA public test files that verifies perfectly against its own bytes and names a hardware-attested capture SDK. It still returns `uncertain`. Its active manifest carries no `c2pa.actions` and no `digitalSourceType` — it is a signature over a file that never says how the content was made, which is not provenance of a capture. Its signer sits on the C2PA's frozen interim list rather than the conformance list, so it reads `untrusted-chain` as well: it could accuse, and may never exonerate.

The gap we have not closed

Revocation is not checked. The reader's OCSP fetch is off, and it is off for a reason we confirmed by experiment rather than inferred: a manifest may name any URL, and fetching one while parsing attacker-supplied bytes turned our detection endpoint into a server-side request forgery primitive. So a certificate on our trust list that is later revoked keeps working here. Before the trust list existed that was harmless, because nothing could reach the `proof` tier and untrusted status only cost confidence. Now that this bundle is the sole gate on exoneration, the gap has teeth. Closing it properly means shipping a revocation list the same way the bundle is shipped — fetched by a maintainer, pinned, reviewed — rather than opening a network fetch at request time. Until then, refreshing the bundle is how a revoked anchor gets removed, which is one more reason that refresh is a review.

C2PAVideo DetectionProvenance