wai: actually write the C2PA manifest the energy binding describes #79
Merged
dcharlot
merged 2 commits from 2026-09-07 00:10:39 -04:00
wai/c2pa-emitter into main 2026-09-06
wai: run the C2PA emitter in CI, and drop its system-crypto dependency
Some checks failed
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m53s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m15s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m0s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m30s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 30s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 14s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m14s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 39s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m27s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 6m37s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 1m14s
standards workspaces build + test / map-rs (pull_request) Successful in 1m12s
standards workspaces build + test / mesh-rs (pull_request) Successful in 1m2s
standards workspaces build + test / openpay (pull_request) Successful in 2m25s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 37s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 18s
standards workspaces build + test / arl-rs (pull_request) Successful in 10m57s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 48m58s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 33m18s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 32m58s
standards workspaces build + test / sense-rs (pull_request) Successful in 28m20s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 6m15s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 2m23s
WAI conformance / cargo-class corpora · windows (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · windows (pull_request) Has been cancelled
Two gaps in the emitter as first written, both of the same kind: a claim with nothing checking it. **The tests never ran.** `c2pa_emit` is off by default and the four-platform `cargo-classes` job builds a fixed feature list that does not include it, so the seven tests compiled locally and nowhere else. A feature no workflow enables has no coverage at all — the suite passes and the tests in it simply do not run. This adds a job that enables it. One platform, deliberately. Everything the existing matrix exists to prove is about byte-identical reconstruction across architectures; a signed manifest makes no such claim and would not become more true for being emitted on four machines. **It linked system OpenSSL.** The c2pa SDK defaults to an openssl backend plus four HTTP stacks. Every wai workflow is built on "pure Rust, no system dependencies" — that is what lets the conformance matrix run on Linux, macOS and Windows with no setup — and an openssl-linked optional feature quietly puts a system library back in the crate's reach. Switched to the SDK's `rust_native_crypto` backend with `default-features = false`, which also drops the reqwest/ureq/wstd/wasi chain that local emission never uses. `openssl`, `openssl-sys`, `reqwest` and `ureq` are all absent from the graph, and the seven tests pass unchanged. Both properties are now asserted rather than assumed, because both are the kind that decay silently: - a step fails if `openssl` or `openssl-sys` ever reaches the `c2pa_emit` graph, since an openssl-linked build would still pass every test here while making the crate undistributable to a bare runner; - a step fails if `c2pa` ever becomes reachable from `provenance`, since that would hand a large dependency tree to consumers who wanted only the binding types, and falsify the "off by default" claim in the extension. The guards were checked to discriminate, not merely to pass: `cargo tree -i` was confirmed to detect a dependency that IS present, so a guard that silently stopped working could not read as a green check. Co-Authored-By: Claude Opus 5 <[email protected]>
wai: actually write the C2PA manifest the energy binding describes
`energy_binding` and `reconstruction_binding` define assertion *shapes*. Nothing emitted them. A shape no implementation writes is a proposal, and the extension document was describing a manifest that did not exist anywhere — so this adds `c2pa_emit`, which signs a real asset with the C2PA SDK and puts all three assertions in the manifest, behind an off-by-default `c2pa_emit` feature. Writing it turned up four things that only surface when you try, each of which is now normative in §6 of the extension: **A reconstruction is not `c2pa.created`.** C2PA 2.x requires every created action to carry a `digitalSourceType`, and that vocabulary describes how the *content* came to exist — captured, human-drawn, model-generated. A decoder does not know that; it reconstructed pixels from a codestream and whatever was encoded could have been any of them. Reaching for `trainedAlgorithmicMedia` because a codec has a neural stage would assert that a compressed photograph is AI-generated content, which is false, and nothing in the vocabulary means "decoded; origin not visible from here". A decode is a **conversion**: the codestream is a `parentOf` ingredient, the manifest opens it, `c2pa.converted` records what was done, and no action claims a source type at all. **The codestream has to travel with it.** The whole construction turns on a verifier re-decoding. A manifest carrying the reconstruction digest but not what produces it hands over a digest and no way to reach it. The digest says which decode the joules describe; the parent ingredient says what to decode to get there. **The assertions are `created`, not `gathered`** — C2PA's own distinction between a claim the signer stands behind and material merely carried along, which is the same distinction the acquisition ladder draws inside the figure. Load-bearing, not editorial: a validator looks for the inception action among created assertions, so leaving the actions assertion gathered (what `add_action` does on its own) yields a manifest that signs happily and every validator then rejects as `assertion.action.malformed` — on the far side of a signature. **Signed is not trusted.** An ephemeral certificate reaches `Valid`, never `Trusted`. `untrusted_local_signer` is named for its limit. The emitter enforces one rule the SDK cannot. Third-party assertion bodies are opaque to C2PA, so it will sign a manifest whose energy figure is measured over one decode while the reconstruction assertion commits to another — the self-refutation §2 describes — and the result validates. `ReconstructionManifest::new` refuses that pair at construction, before a signature can cover it, and `recover` applies the same rule on the way back in. `recover` also reports the unaccompanied standard assertion — perfectly valid C2PA, refused here — as its own error rather than a generic absence, because that is the exact situation the companion exists to be distinguished from. Assertion bodies are parsed from the same `to_json` functions the extension documents pin, so the emitter cannot drift from the specified shape. Verified: 7 tests, signing a real PNG and reading the manifest back with an independent `Reader`. The trust test pins the single failure code as well as the verdict, so a real defect cannot hide behind the expected one — which is not hypothetical: the missing inception action was found exactly that way. `cargo tree -i c2pa` under `--features provenance` finds nothing, so the default build is untouched. Co-Authored-By: Claude Opus 5 <[email protected]>