Skip to content

MPF-M-002: verify the C2PA manifest bound to a delivered document - #32

Merged
Tippel-AI merged 1 commit into
mainfrom
feat/c2pa-documents
Sep 1, 2026
Merged

MPF-M-002: verify the C2PA manifest bound to a delivered document#32
Tippel-AI merged 1 commit into
mainfrom
feat/c2pa-documents

Conversation

@Tippel-AI

Copy link
Copy Markdown
Owner

Closes #18.

A correction to the issue's premise

#18 assumed this needed a third-party library, because c2pa-rs has no HTML
handler. That was too broad, and I had repeated it in the docs. The accurate
statement is narrower:

c2pa-rs will not embed a manifest into HTML — but it will produce and verify
one that stands beside it. set_no_embed() when signing, manifest_data=
when reading.

Which is exactly the C2PA 2.4 §A.7 arrangement. So this is built entirely on the
official CAI bindings and the dependency question the issue raised does not arise.

Verified against c2pa-python 0.37.8 / c2pa-rs 0.90.15:

verify korrekt      -> failures=['signingCredential.untrusted']
verify manipuliert  -> failures=['signingCredential.untrusted', 'assertion.dataHash.mismatch']

Why this rule earns its place

The binding is a hash over the bytes the server sent. A minifier, an
HTML-rewriting CDN, a template change — each turns a valid provenance claim into
an invalid one while the page still renders perfectly. Nothing errors, no log
line appears. That is the delivery-chain regression this project exists for, in
the format most likely to suffer it.

The golden case says it in four characters: 1932 became 1888 after signing,
the page is visually identical, and the binding fails.

The document probe

Fetches bytes rather than driving a browser, and that is correctness rather than
economy: a UI probe reports what a browser rendered, and a browser normalises
markup before anything is readable. The rendered document and the delivered one
are different bytes, and only one of them is what the manifest signs.

Two refusals worth naming:

  • A manifest on another origin is refused, not followed. A provenance claim
    that depends on a third party being reachable stops being checkable when they
    are not.
  • A manifest link answering 404 is an error, not a shrug. A dangling
    provenance link is worse than none — it reads as marked and verifies as nothing.

Verdicts

Fixture Outcome
signed-valid.html + manifest verified → PASS
tampered.html + same manifest invalid → FAIL
signed-wrong-type.html wrong_source_type → FAIL — validly signed, declares a camera capture
unsigned.html manifest_missing → FAIL

That last one changed: a missing manifest on a format that cannot embed one is
manifest_missing, not unreadable. "There is nowhere a manifest could be" is a
fact about the asset; "unreadable" blames the evidence.

Also

Artifact gained sidecar_manifest (excluded from serialisation, like data)
and sidecar_source. The source is kept because header and element are not
equivalent in practice — a header survives an HTML rewrite that would strip or move
the element, so a report saying which the chain actually used tells an operator
something a pass alone does not.

docs/RULES_SOURCES.md §10.6 records the corrected tooling position rather than
quietly replacing the old one.

475 tests, ruff + mypy strict clean.

🤖 Generated with Claude Code

Closes #18.

C2PA 2.4 §A.7 binds a manifest to a document that cannot embed one — HTML above
all — by hashing the delivered bytes, and the document points at the manifest
through an RFC 8288 Link: response header or a <link rel="c2pa-manifest">
element. That is the delivery-chain regression this project exists to catch, in
the format most likely to suffer it: a minifier, an HTML-rewriting CDN or a
template change turns a valid provenance claim into an invalid one while the page
still renders perfectly. Nothing errors, no log line appears, and the operator
finds out when somebody from outside asks.

A correction to the record first. Issue #18 assumed this needed a third-party
library, on the grounds that c2pa-rs has no HTML handler. That was too broad. The
accurate statement is narrower: c2pa-rs will not *embed* a manifest into HTML, but
it will produce and verify one that stands beside it — `set_no_embed()` when
signing, `manifest_data=` when reading. Which is exactly the A.7 arrangement. So
this is built entirely on the official CAI bindings, and the dependency question
the issue raised does not arise. Verified against c2pa-python 0.37.8 / c2pa-rs
0.90.15: a document edited by four characters after signing reports
assertion.dataHash.mismatch, and the unedited one does not.

The new `document` probe fetches bytes rather than driving a browser, and that is
a correctness requirement rather than a shortcut. A UI probe reports what a
browser rendered; a browser normalises markup before anything is readable, so the
rendered document and the delivered one are different bytes and only one of them
is what the manifest signs.

Two refusals worth naming. A manifest hosted on another origin is refused rather
than followed — a provenance claim that depends on a third party being reachable
stops being checkable when they are not. And a manifest link that answers 404 is
an error rather than a shrug, because a dangling provenance link is worse than
none: it reads as marked and verifies as nothing.

`Artifact` gained `sidecar_manifest` (excluded from serialisation, like `data`)
and `sidecar_source`. The source is kept because header and element are not
equivalent in practice — a header survives an HTML rewrite that would strip or
move the element, so a report saying which one the chain actually used tells an
operator something a pass alone does not.

A missing manifest on a format that cannot embed one is now reported as
manifest_missing rather than unreadable. The distinction is real: "there is
nowhere a manifest could be" is a fact about the asset, while "unreadable" blames
the evidence.

Four generated fixtures and two golden cases, including the one that matters —
`1932` became `1888` after signing, the page renders identically, and the binding
fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Tippel-AI
Tippel-AI merged commit c09595f into main Sep 1, 2026
8 checks passed
@Tippel-AI
Tippel-AI deleted the feat/c2pa-documents branch September 1, 2026 05:28
Tippel-AI added a commit that referenced this pull request Sep 1, 2026
**MPF-M-002 was missing from the rule table, and a scope limit still denied it.**
Both are my error and they have the same cause: PR #32 made three README edits in
one script, the third assertion failed, and nothing was written. I fixed the third
separately and never noticed the first two had not landed. So the front page
listed six rules while seven shipped, and told readers the tool does not check
C2PA marking on web pages twelve lines above an example config that configures
exactly that.

The rule-table guard could not catch it: it asserted documented ⊆ shipped, not the
reverse, and it matched rule ids anywhere in the file rather than in the table — so
a missing row passed because the same id appeared in the scope limit that denied
it. Both directions now, scoped to the table, mutation-tested.

The table also gained a "Fails the build?" column. A reader deciding whether to
put this in CI needs to know that two rules warn rather than fail, and why: the
obligation is real, the judgement is not a string comparison.

**The transcript was re-captured.** It predated three behaviour changes, so it
showed verdicts the tool no longer produces. The new one shows two skips, which is
a better advertisement than the old one: MPF-T-001 refusing to score an 18-token
reply, and MPF-L-001 saying an images API is not where a person meets the content
and naming the probe that would answer the question.

**docs/RULES_SOURCES.md is now linked from the README**, with an English preface.
It is the document that answers "this is one person's reading of an ambiguous
regulation" — 884 lines of it, in German, and nothing pointed at it. The body stays
German because that is the language the sources were read in and a translation
would put a second interpretation between the reader and the argument; the
paragraph numbers are the part that matters and they are language-neutral.

**The README now says the project was built with heavy AI assistance.** The commit
history makes the pace obvious, so it is better said than inferred — along with
what that does and does not change, and a pointer at the two documents where the
judgement calls are inspectable.

Also: a contributor path from the README; the empty negative-test scaffolding
replaced with a table of where those cases actually live, since "where are the
negative tests" is a fair question to ask a compliance tool; the pre-0.1.0
self-audit marked superseded rather than deleted, because deleting a stale record
is the habit this project argues against everywhere else; internal launch drafts
removed from the public docs directory; CITATION.cff no longer claims Apache-2.0
for material that is CC-BY; `markproof init` no longer stamps someone else's SPDX
header onto the user's config; and the WeasyPrint renderer carries the Article
50(2) two-limbs qualification the other two already had.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Check the C2PA manifest bound to a rendered document (C2PA 2.4 §A.7)

1 participant