Move claim signing to Backend service for C2PA Assurance Level 1 - #5
Merged
Conversation
The editor compiled its signing key into the WebAssembly module. It was honest
about the consequence, but honesty is not conformance: objective O.2 of the
C2PA Generator Product Security Requirements asks for a claim signing key that
is encrypted at rest, encrypted in memory except while signing, access-
controlled by least privilege, and rotatable. A key served to every visitor
fails all four, which put Assurance Level 1 - and therefore the Conforming
Products List, and therefore any certificate a validator would recognise -
permanently out of reach.
So the product is now a Distributed implementation. The browser builds the
asset, the assertions and the claim; a new service signs. About a kilobyte of
claim crosses the network and never a pixel, so the no-upload promise holds -
and it is now enforced rather than asserted: check-no-key-material.sh fails the
build if the shipped .wasm contains a private-key header, the bytes of the test
key, or a dependency edge on a private-key parser.
Architecture
apps/editor/ the Edge subsystem, moved from src/ and index.html
services/claim-signer/ the Backend: sealed keystore, HMAC caller auth,
TLS 1.3 only, RFC 3161 client, key rotation
crates/c2pa-harness/ asset + trust lists + validation time -> crJSON
conformance/ the GPSA, the requirements matrix, the runbook,
a test PKI, and the scripts that produce evidence
Engine
Split sign_jpeg into prepare/complete so the signature can arrive over the
network between them, reserving the signature box at a fixed width across
all three renders.
Time-stamping: sigTst2, with pad/pad2 shrunk to the byte once the token
arrives, and full section 15.8 validation. An Assurance Level 1 certificate
lasts 366 days, so without this every signed image would expire with it.
Trust: RFC 5280 path validation against a C2PA Trust List and TSA Trust
List at a caller-supplied validation time, with the Certificate Policy
extensions (c2pa-al, c2pa-cpl-record, c2pa-kp-claimSigning) read and shown.
Signature verification for ECDSA P-256/P-384 and RSA PKCS#1/PSS.
crJSON serialisation, shared by the browser and the harness so the two
cannot disagree.
Conformance requirements against the specification
specVersion in claim_generator_info, allActionsIncluded always set, and
digitalSourceType applied centrally so a new action cannot omit it - and
never on c2pa.opened, where it is prohibited.
Supply chain
CycloneDX SBOMs for every component, cargo-audit and npm audit, and a
90-day CRITICAL/HIGH gate backed by a committed ledger so the clock
survives a fresh runner. It runs before the release build, not beside it.
Interface
Identity is reported in three states rather than two - trusted, not on the
trust list, and not checked - because collapsing the middle one is the
failure C2PA exists to prevent. Assurance Level and CPL record id are shown
where the certificate carries them. Without a configured signer the editor
works exactly as before and exports unsigned, and says so.
216 tests pass; clippy is clean across the workspace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012yYPaqvECJ3Q7khzeVtSR8
C2PA conformance: not a real failure. The job produced all five crJSON documents correctly, then exited 1 because `05-tampered-pixels.jpg` is deliberately invalid and `c2pa-harness batch` reports "not every asset validated" in its exit status. That contract is right for `validate` - a caller asking whether an asset is good needs the answer in the exit status - but it is the wrong success criterion for *generating evidence*. The Program's own asset library is full of files that must fail, and evidence that the validator catches them is as valuable as evidence that it passes the good ones. generate-evidence.sh now succeeds when every asset produced a document, and fails when one did not. Supply chain: a real bug. `cargo cyclonedx` has no `--output-prefix`; it writes each document beside its own Cargo.toml and offers no output-directory option at all. The flag was invented rather than checked. Verified against cargo-cyclonedx 0.5.9 and the documents are collected afterwards instead. This also means the cargo-audit and npm audit steps never ran, so the 90-day gate is still unproven in CI - the next run is its first real exercise. Stray *.cdx.json is gitignored, so an interrupted SBOM run cannot leave files that get committed by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012yYPaqvECJ3Q7khzeVtSR8
The gate passed CI's first real run, but for the wrong reason, and that is a defect in the one control O.3 and O.4 rest on. `cargo audit` reports the CVSS *vector* - `CVSS:3.1/AV:N/AC:H/...` - and leaves `severity` null. The gate treated an unparsed vector as "severity unknown" and let it through, so every cargo finding passed regardless of how bad it was. A genuine CRITICAL would have sailed straight past. gate.py now computes the CVSS v3.1 base score from the vector, per Appendix A of the specification including its roundup rule. `--self-test` checks it against vectors with published scores - Log4Shell 10.0, Heartbleed 7.5, the `rsa` advisory 5.9, and a zero-impact vector - and runs before every evaluation, because a scorer that quietly returned None for everything would make the gate pass unconditionally and look exactly like a clean scan. Malformed input stays unscored rather than scoring zero: "I cannot read this" and "this is harmless" are different answers. With real scoring the one open advisory now classifies as Medium (5.9) rather than unknown, so it still does not block - now because it was measured. The GPSA records both open findings and why neither blocks. RUSTSEC-2023-0071 is a timing sidechannel on RSA *private-key* operations; this workspace links `rsa` only in the validator's signature checking, holds no RSA private key type at all, and signs with ECDSA. Verified rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012yYPaqvECJ3Q7khzeVtSR8
Bytecode from running gate.py during verification. Ignored now so it cannot come back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012yYPaqvECJ3Q7khzeVtSR8
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.
This change restructures the C2PA claim generator from a browser-only implementation to a distributed architecture where the signing key is held by a Backend service, enabling compliance with C2PA Conformance Program Assurance Level 1 requirements.
Summary
The claim signing key has been moved out of the browser and into a dedicated Backend service (
services/claim-signer). The browser (Edge subsystem) now sends aSig_structureto the Backend for signing, rather than holding and using the key directly. This architectural change is required by Objective O.2 of the C2PA Generator Product Security Requirements, which mandates that the signing key be encrypted at rest, encrypted in memory except while signing, access-controlled by least privilege, and rotatable—none of which are achievable with a key compiled into WebAssembly.Key Changes
Backend Service (
services/claim-signer)activesymlink/v1/signendpoint to signSig_structurepayloads/v1/identityendpoint to return the public credential (certificate chain, algorithm, key ID)Edge Subsystem (Browser)
prepare()returns aSig_structure, thencomplete()receives the signature and time-stampsigner.tsclient for communicating with claim-signer serviceC2PA Engine (
crates/imagecore)trust.rs) with RFC 5280 path validationtimestamp.rs)verify.rs)x509.rs) to extract all fields needed for validationidentity.rsto describe the signing identity without holding the keyclock.rsfor time representation across different formatscrjson.rsfor crJSON serialization of validation resultssigner.rs(embedded key) andbuild.rs(key baking)Test Infrastructure
conformance/test-credentials/) with CA, issuing CA, claim signer, and TSA certificatestestpki.rsfeature-gated test key materialc2pa-harnessbinary for conformance testing that validates assets and emits crJSONConformance Documentation
conformance/directory with security architecture document, requirements matrix, and enrolment runbookgate.py) enforcing 90-day CRITICAL/HIGH fix deadlineNotable Implementation Details
Sig_structureto Backend, Backend returns signature and time-stamphttps://claude.ai/code/session_012yYPaqvECJ3Q7khzeVtSR8