Skip to content

Move claim signing to Backend service for C2PA Assurance Level 1 - #5

Merged
profradha merged 4 commits into
mainfrom
claude/c2pa-level-1-conformance-f3h06i
Aug 26, 2026
Merged

Move claim signing to Backend service for C2PA Assurance Level 1#5
profradha merged 4 commits into
mainfrom
claude/c2pa-level-1-conformance-f3h06i

Conversation

@profradha

Copy link
Copy Markdown
Member

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 a Sig_structure to 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)

  • New service that holds the claim signing key in encrypted form (AES-256-GCM)
  • Implements key rotation via versioned directories with an active symlink
  • Provides /v1/sign endpoint to sign Sig_structure payloads
  • Provides /v1/identity endpoint to return the public credential (certificate chain, algorithm, key ID)
  • Implements HMAC-SHA256 authentication of Edge subsystem requests
  • Fetches RFC 3161 time-stamps from a TSA and returns them with signatures

Edge Subsystem (Browser)

  • Removed embedded private key and certificate
  • Now calls Backend service to sign claims instead of signing locally
  • Implements two-phase signing: prepare() returns a Sig_structure, then complete() receives the signature and time-stamp
  • Added signer.ts client for communicating with claim-signer service

C2PA Engine (crates/imagecore)

  • Added comprehensive certificate validation (trust.rs) with RFC 5280 path validation
  • Added RFC 3161 time-stamp parsing and verification (timestamp.rs)
  • Added signature verification for both COSE and X.509 signatures (verify.rs)
  • Expanded X.509 certificate parser (x509.rs) to extract all fields needed for validation
  • Added identity.rs to describe the signing identity without holding the key
  • Added clock.rs for time representation across different formats
  • Added crjson.rs for crJSON serialization of validation results
  • Removed signer.rs (embedded key) and build.rs (key baking)

Test Infrastructure

  • Added test PKI (conformance/test-credentials/) with CA, issuing CA, claim signer, and TSA certificates
  • Added testpki.rs feature-gated test key material
  • Added c2pa-harness binary for conformance testing that validates assets and emits crJSON
  • Added integration tests exercising the full two-phase signing flow

Conformance Documentation

  • Added conformance/ directory with security architecture document, requirements matrix, and enrolment runbook
  • Added vulnerability scanning gate (gate.py) enforcing 90-day CRITICAL/HIGH fix deadline
  • Added scripts for generating evidence, SBOMs, and verifying no key material in Edge subsystem

Notable Implementation Details

  • The manifest signing flow now spans two network calls: Edge sends Sig_structure to Backend, Backend returns signature and time-stamp
  • Padding in the COSE unprotected header reserves space for the time-stamp before its size is known
  • Certificate validation is deterministic and requires no external network calls (trust anchors and validation time are supplied as inputs)
  • The test PKI is shaped identically to production certificates so all code paths are exercised in CI
  • All conformance requirements are mapped to specific files/commands in the requirements matrix

https://claude.ai/code/session_012yYPaqvECJ3Q7khzeVtSR8

claude added 4 commits August 26, 2026 14:55
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
@profradha
profradha merged commit aca8076 into main Aug 26, 2026
4 checks passed
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.

2 participants