MPF-M-002: verify the C2PA manifest bound to a delivered document - #32
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #18.
A correction to the issue's premise
#18 assumed this needed a third-party library, because
c2pa-rshas no HTMLhandler. That was too broad, and I had repeated it in the docs. The accurate
statement is narrower:
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-python0.37.8 /c2pa-rs0.90.15: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:
1932became1888after signing,the page is visually identical, and the binding fails.
The
documentprobeFetches 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:
that depends on a third party being reachable stops being checkable when they
are not.
provenance link is worse than none — it reads as marked and verifies as nothing.
Verdicts
signed-valid.html+ manifestverified→ PASStampered.html+ same manifestinvalid→ FAILsigned-wrong-type.htmlwrong_source_type→ FAIL — validly signed, declares a camera captureunsigned.htmlmanifest_missing→ FAILThat last one changed: a missing manifest on a format that cannot embed one is
manifest_missing, notunreadable. "There is nowhere a manifest could be" is afact about the asset; "unreadable" blames the evidence.
Also
Artifactgainedsidecar_manifest(excluded from serialisation, likedata)and
sidecar_source. The source is kept because header and element are notequivalent 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 thanquietly replacing the old one.
475 tests, ruff + mypy strict clean.
🤖 Generated with Claude Code