diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e357cd..10fc2ce 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,7 @@ on:
jobs:
engine:
- name: Image engine
+ name: Engine and services
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -31,14 +31,81 @@ jobs:
run: cargo fmt --all -- --check
- name: Clippy
- run: cargo clippy -p imagecore --all-targets -- -D warnings
+ run: cargo clippy --workspace --all-targets -- -D warnings
- name: Test
- run: cargo test -p imagecore
+ run: cargo test --workspace
- name: Check the WebAssembly target builds
run: cargo build -p imagecore --target wasm32-unknown-unknown --release
+ conformance:
+ name: C2PA conformance
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-
+
+ # Produces the crJSON evidence the Conformance Program asks applicants to
+ # submit, from assets this repository signs itself. When the Program
+ # supplies its own asset library, point `--asset-dir` at it instead.
+ - name: Generate crJSON evidence
+ run: ./conformance/scripts/generate-evidence.sh
+
+ - name: Upload the evidence
+ uses: actions/upload-artifact@v4
+ with:
+ name: crjson-evidence
+ path: conformance/evidence/crjson/
+ if-no-files-found: error
+
+ supply-chain:
+ name: Supply chain
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+ - run: npm ci
+
+ - uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ ~/.cargo/bin
+ key: ${{ runner.os }}-audit-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-audit-
+
+ # O.3 and O.4 at Assurance Level 1: an SBOM of everything in the Target
+ # of Evaluation, and a gate that refuses to release a CRITICAL or HIGH
+ # finding older than 90 days. `conformance/vulnerability-ledger.json` is
+ # what makes the clock survive a fresh runner.
+ - name: Software Bill of Materials
+ run: ./conformance/scripts/sbom.sh
+
+ - name: Vulnerability gate
+ run: ./conformance/scripts/vulnerability-scan.sh
+
+ - name: Upload the supply-chain evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: supply-chain-evidence
+ path: conformance/evidence/
+ if-no-files-found: warn
+
web:
name: Web app
runs-on: ubuntu-latest
@@ -69,5 +136,13 @@ jobs:
- run: npm ci
- run: npm run build:wasm
+
+ # The property that matters most in this repository, and the one a
+ # reviewer cannot check by reading: the module served to browsers must
+ # contain no private key. Runs here rather than in the `engine` job
+ # because it inspects the built artefact, which is what actually ships.
+ - name: Check the WebAssembly bundle carries no key material
+ run: ./conformance/scripts/check-no-key-material.sh
+
- run: npm run typecheck
- - run: npx vite build
+ - run: npx vite build apps/editor
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 845ed0e..7557d0d 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -41,8 +41,16 @@ jobs:
with:
version: 'latest'
- - name: Test the image engine
- run: cargo test -p imagecore
+ - name: Test the workspace
+ run: cargo test --workspace
+
+ # O.3 and O.4 at Assurance Level 1 require that the pipeline refuse to
+ # release a Claim Generator carrying a CRITICAL or HIGH vulnerability more
+ # than 90 days after detection. This is the point in the pipeline where
+ # that refusal has to happen, so the gate runs before the build, not
+ # alongside it.
+ - name: Supply-chain gate
+ run: ./conformance/scripts/vulnerability-scan.sh
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -53,21 +61,40 @@ jobs:
- name: Install dependencies
run: npm ci
- # The signing credentials for Content Credentials. When both secrets are
- # set, `crates/imagecore/build.rs` compiles them into the engine instead
- # of the demo key committed under `signing/`; when they are absent, as on
- # a fork, the demo key is used and the build still works.
- #
- # To be clear about what this buys: a secret keeps a key out of public git
- # history. It cannot make the key secret. This engine is served to
- # browsers, so whichever key it carries is published along with it. See
- # `signing/README.md` for why that is inherent to client-side signing and
- # what a trustworthy setup would look like instead.
- name: Build
run: npm run build
- env:
- C2PA_SIGNING_CERT: ${{ secrets.C2PA_SIGNING_CERT }}
- C2PA_SIGNING_KEY: ${{ secrets.C2PA_SIGNING_KEY }}
+
+ # The property this whole architecture exists to preserve. Checked against
+ # the artefact that is about to be published, not against the source.
+ - name: Check the published module carries no key material
+ run: ./conformance/scripts/check-no-key-material.sh
+
+ # The C2PA Trust List and the TSA Trust List, if this deployment ships
+ # them. The editor fetches both at run time and falls back to reporting
+ # signer identity as unchecked when they are absent, so an unset variable
+ # degrades the interface rather than breaking it.
+ - name: Publish the trust lists
+ if: vars.C2PA_TRUST_LIST_URL != ''
+ run: |
+ mkdir -p dist/trust-lists
+ curl -fsSL "${{ vars.C2PA_TRUST_LIST_URL }}" -o dist/trust-lists/c2pa-trust-list.pem
+ if [ -n "${{ vars.C2PA_TSA_TRUST_LIST_URL }}" ]; then
+ curl -fsSL "${{ vars.C2PA_TSA_TRUST_LIST_URL }}" \
+ -o dist/trust-lists/c2pa-tsa-trust-list.pem
+ fi
+
+ # Where the claim-signer lives, for a deployment that has one. Absent on
+ # the plain GitHub Pages build: there is no application server there to
+ # mint a session credential, so the editor runs without signing and says
+ # so. Note what is *not* here any more — no signing key, no certificate,
+ # no secret of any kind reaches the published bundle.
+ - name: Publish the claim-signer configuration
+ if: vars.CLAIM_SIGNER_URL != ''
+ run: |
+ printf '{"url":"%s","credentialEndpoint":"%s"}\n' \
+ "${{ vars.CLAIM_SIGNER_URL }}" \
+ "${{ vars.EDGE_CREDENTIAL_ENDPOINT }}" \
+ > dist/claim-signer.json
- name: Setup Pages
uses: actions/configure-pages@v5
diff --git a/.gitignore b/.gitignore
index e5eec68..b1ebd60 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,16 @@ dist
target
# wasm-pack output - rebuilt by `npm run build:wasm`
-/src/wasm/
+/apps/editor/src/wasm/
+
+# Conformance evidence - reproducible with one command, and CI uploads it as a
+# build artefact. See conformance/README.md.
+/conformance/evidence/
+
+# Stray cargo-cyclonedx output, when sbom.sh is interrupted before it
+# collects the documents into conformance/evidence/.
+*.cdx.json
+
+# Python bytecode from the conformance scripts.
+__pycache__/
+*.pyc
diff --git a/Cargo.lock b/Cargo.lock
index 5d6525c..7f155a3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,50 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "approx"
version = "0.5.1"
@@ -17,18 +61,109 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-server"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
+dependencies = [
+ "arc-swap",
+ "bytes",
+ "fs-err",
+ "http",
+ "http-body",
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "rustls",
+ "rustls-pemfile",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -68,6 +203,31 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "c2pa-harness"
+version = "0.1.0"
+dependencies = [
+ "image",
+ "imagecore",
+ "serde_json",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -85,6 +245,46 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
+name = "claim-signer"
+version = "0.1.0"
+dependencies = [
+ "aes-gcm",
+ "axum",
+ "axum-server",
+ "base64",
+ "hmac",
+ "imagecore",
+ "p256",
+ "p384",
+ "rand 0.8.8",
+ "rustls",
+ "rustls-pemfile",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "sha2",
+ "subtle",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+ "ureq",
+ "webpki-roots 1.0.9",
+ "zeroize",
+]
+
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -159,9 +359,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
+ "rand_core 0.6.4",
"typenum",
]
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "der"
version = "0.7.10"
@@ -185,6 +395,17 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
[[package]]
name = "document-features"
version = "0.2.12"
@@ -205,6 +426,7 @@ dependencies = [
"elliptic-curve",
"rfc6979",
"signature",
+ "spki",
]
[[package]]
@@ -225,6 +447,7 @@ dependencies = [
"ff",
"generic-array",
"group",
+ "hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -233,6 +456,22 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "fast_image_resize"
version = "6.1.0"
@@ -272,6 +511,12 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
[[package]]
name = "flate2"
version = "1.1.9"
@@ -282,6 +527,70 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs-err"
+version = "3.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
+dependencies = [
+ "autocfg",
+ "tokio",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
[[package]]
name = "generic-array"
version = "0.14.9"
@@ -293,6 +602,17 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -307,6 +627,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
[[package]]
name = "gif"
version = "0.14.2"
@@ -352,6 +682,25 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "h2"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "half"
version = "2.7.1"
@@ -363,6 +712,21 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
[[package]]
name = "hmac"
version = "0.12.1"
@@ -373,109 +737,354 @@ dependencies = [
]
[[package]]
-name = "image"
-version = "0.25.10"
+name = "http"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
- "bytemuck",
- "byteorder-lite",
- "color_quant",
- "gif",
- "image-webp",
- "moxcms",
- "num-traits",
- "png",
- "qoi",
- "tiff",
- "zune-core",
- "zune-jpeg",
+ "bytes",
+ "itoa",
]
[[package]]
-name = "image-webp"
-version = "0.2.4"
+name = "http-body"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
- "byteorder-lite",
- "quick-error",
+ "bytes",
+ "http",
]
[[package]]
-name = "imagecore"
-version = "0.1.0"
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
- "console_error_panic_hook",
- "fast_image_resize",
- "image",
- "imageproc",
- "p256",
- "serde",
- "serde_json",
- "sha2",
- "wasm-bindgen",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
]
[[package]]
-name = "imageproc"
-version = "0.27.0"
+name = "httparse"
+version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7b27bc0867dc40df08deb53d6e96342db6e0702e7ae33ed09a4eba33e594b05"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
- "approx",
- "getrandom",
- "image",
- "itertools",
- "nalgebra",
- "num",
- "rand",
- "rand_distr",
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
]
[[package]]
-name = "itertools"
-version = "0.14.0"
+name = "hyper-util"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
- "either",
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
]
[[package]]
-name = "itoa"
-version = "1.0.18"
+name = "icu_collections"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
[[package]]
-name = "js-sys"
-version = "0.3.104"
+name = "icu_locale_core"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
- "cfg-if",
- "wasm-bindgen",
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
]
[[package]]
-name = "libc"
-version = "0.2.189"
+name = "icu_normalizer"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
[[package]]
-name = "libm"
-version = "0.2.16"
+name = "icu_normalizer_data"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
-name = "litrs"
-version = "1.0.0"
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "color_quant",
+ "gif",
+ "image-webp",
+ "moxcms",
+ "num-traits",
+ "png",
+ "qoi",
+ "tiff",
+ "zune-core",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "image-webp"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
+dependencies = [
+ "byteorder-lite",
+ "quick-error",
+]
+
+[[package]]
+name = "imagecore"
+version = "0.1.0"
+dependencies = [
+ "base64",
+ "console_error_panic_hook",
+ "fast_image_resize",
+ "image",
+ "imagecore",
+ "imageproc",
+ "p256",
+ "p384",
+ "rsa",
+ "serde",
+ "serde_json",
+ "sha1",
+ "sha2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "imageproc"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7b27bc0867dc40df08deb53d6e96342db6e0702e7ae33ed09a4eba33e594b05"
+dependencies = [
+ "approx",
+ "getrandom 0.4.3",
+ "image",
+ "itertools",
+ "nalgebra",
+ "num",
+ "rand 0.10.2",
+ "rand_distr",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "matrixmultiply"
version = "0.3.11"
@@ -492,6 +1101,12 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -502,6 +1117,17 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "moxcms"
version = "0.8.1"
@@ -531,6 +1157,15 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "num"
version = "0.4.3"
@@ -554,6 +1189,22 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.8",
+ "smallvec",
+ "zeroize",
+]
+
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -609,6 +1260,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
[[package]]
name = "p256"
version = "0.13.2"
@@ -621,6 +1278,18 @@ dependencies = [
"sha2",
]
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -630,6 +1299,29 @@ dependencies = [
"base64ct",
]
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
[[package]]
name = "pkcs8"
version = "0.10.2"
@@ -653,6 +1345,36 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "primeorder"
version = "0.13.6"
@@ -707,6 +1429,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "rand"
version = "0.10.2"
@@ -714,15 +1447,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
- "getrandom",
+ "getrandom 0.4.3",
"rand_core 0.10.1",
]
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
[[package]]
name = "rand_core"
@@ -737,7 +1483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [
"num-traits",
- "rand",
+ "rand 0.10.2",
]
[[package]]
@@ -746,6 +1492,23 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -757,10 +1520,89 @@ dependencies = [
]
[[package]]
-name = "rustversion"
-version = "1.0.23"
+name = "ring"
+version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sha2",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "safe_arch"
@@ -828,6 +1670,28 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -839,6 +1703,31 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
[[package]]
name = "signature"
version = "2.2.0"
@@ -867,6 +1756,34 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+
[[package]]
name = "spki"
version = "0.7.3"
@@ -877,6 +1794,12 @@ dependencies = [
"der",
]
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -905,6 +1828,23 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "thiserror"
version = "2.0.20"
@@ -925,6 +1865,15 @@ dependencies = [
"syn 3.0.4",
]
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "tiff"
version = "0.11.3"
@@ -939,6 +1888,186 @@ dependencies = [
"zune-jpeg",
]
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "libc",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "http",
+ "http-body",
+ "http-body-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-serde"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
+dependencies = [
+ "serde",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "serde",
+ "serde_json",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+ "tracing-serde",
+]
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -951,12 +2080,74 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
+dependencies = [
+ "base64",
+ "flate2",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-pki-types",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
@@ -1002,6 +2193,24 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.9",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
@@ -1018,6 +2227,123 @@ dependencies = [
"safe_arch",
]
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.56"
@@ -1038,11 +2364,79 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
[[package]]
name = "zmij"
diff --git a/Cargo.toml b/Cargo.toml
index e13fc35..1eff572 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,16 @@
[workspace]
resolver = "2"
-members = ["crates/imagecore"]
+members = [
+ # The Edge subsystem's engine: image processing plus the C2PA claim
+ # generator and validator. Compiled to WebAssembly and served to browsers,
+ # so it holds no key material of any kind.
+ "crates/imagecore",
+ # The conformance test harness. Native only; produces crJSON validation
+ # results for the assets the Conformance Program supplies.
+ "crates/c2pa-harness",
+ # The Backend subsystem: the only place a claim signing key ever exists.
+ "services/claim-signer",
+]
[profile.release]
opt-level = 3
diff --git a/README.md b/README.md
index e4bf445..9126fef 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,23 @@ edited and re-encoded inside the tab and never touches a server.
JPEGs also get **C2PA Content Credentials**: the editor checks any credential
already in the file, and can sign what it exports with a manifest recording
-every edit it made. That happens in the tab too — there is no signing service.
+every edit it made.
+
+Signing the *claim* happens on a small service, because the C2PA Conformance
+Program requires the signing key to be somewhere a browser cannot be. The
+picture is still never uploaded — what crosses the network is about a kilobyte
+of claim, and never a pixel. [Why, in one paragraph](#the-signing-key-is-not-in-your-browser).
**Live:** [a10city.com/editor](https://a10city.com/editor)
```
your file ──▶ Rust/WASM worker ──▶ your download
- ▲
- nothing crosses the network
+ │ ▲
+ │ │ the image never leaves the tab
+ │ │
+ claim │ │ signature (only when you ask for
+ └──────┘ Content Credentials)
+ claim-signer
```
---
@@ -27,7 +36,7 @@ every edit it made. That happens in the tab too — there is no signing service.
| **Crop** | Drag a selection on the preview, with rule-of-thirds guides, eight resize handles, ratio presets (1:1, 4:3, 16:9, 3:4, 9:16), keyboard nudging, and exact pixel fields. |
| **Rotate** | Lossless 90° turns and mirror flips, plus a free straighten dial that expands the canvas instead of clipping the corners. |
| **Adjust** | Brightness, contrast, saturation, unsharp mask, Gaussian blur, grayscale, invert. |
-| **Attest** | Read and verify C2PA Content Credentials on JPEG input; write a signed manifest on JPEG output recording what was done. Other formats are untouched — see [Content Credentials](#content-credentials). |
+| **Attest** | Validate C2PA Content Credentials on JPEG input, against a trust list where one is configured; write a signed, time-stamped manifest on JPEG output recording what was done. Other formats are untouched — see [Content Credentials](#content-credentials). |
Extras that matter in practice: EXIF orientation is honoured so phone photos
open upright, alpha is premultiplied around every resample so cut-outs do not
@@ -45,8 +54,12 @@ arrow keys nudge a selection (`Shift` for 10px), `Esc` clears it.
[C2PA](https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html)
Content Credentials attach a signed, tamper-evident record of an image's history
to the image itself. This editor is a working **claim generator** and
-**validator** for them, built against the 2.2 specification, running entirely in
-the browser.
+**validator** for them, built against the 2.2 specification, and shaped to pass
+the [C2PA Conformance Program][program] at Assurance Level 1 — see
+[`conformance/`](conformance/README.md) for the evidence and what is still
+outstanding.
+
+[program]: https://github.com/c2pa-org/conformance-public
Open a JPEG and the panel says whether it carries a credential and whether that
credential holds up. Export a JPEG and — if you leave the switch on — it gets a
@@ -125,59 +138,130 @@ implementation:
$ c2patool signed.jpg
validation_state : Valid
success : assertion.dataHash.match, assertion.hashedURI.match,
- claimSignature.insideValidity, claimSignature.validated
-failure : signingCredential.untrusted
+ claimSignature.insideValidity, claimSignature.validated,
+ signingCredential.trusted, timeStamp.validated
```
-That last line is the honest one, and the next section is about it. Tamper with
-a signed file and both this validator and `c2patool` return
+Tamper with a signed file and both this validator and `c2patool` return
`assertion.dataHash.mismatch`.
-### What a credential from this app does and does not prove
-
-A C2PA manifest supports two quite different claims, and only one of them
-survives here.
-
-**It does prove integrity.** The pixels have not changed since signing — that is
-the hard binding, and it is real. Alter one byte of image data and validation
-fails, in this app and in every other C2PA tool.
-
-**It does not prove identity.** The signing key is compiled into a WebAssembly
-module that is served to every visitor, so anyone can read it out and mint a
-manifest bearing this signer's name. There is no arrangement in which a purely
-client-side claim generator holds a secret key; that is a property of signing in
-the browser, not a shortcut taken here.
-
-So the app never shows a tick beside a signer. It reports the two claims as two
-separate lines — integrity in green, identity as plain text naming who has
-vouched for the signer, which is nobody — and names the specification status
-code for every check so a reader can see exactly which guarantee they are being
-given. `signingCredential.untrusted` is displayed, not hidden.
-
-**On GitHub secrets:** they cannot make a browser-side key secret. A secret is
-decrypted in the Actions runner, and whatever the runner bakes into `dist/` is
-downloadable. They are wired up anyway for the one real thing they buy — keeping
-a key out of public git history — so a fork can deploy under its own rotatable
-key by setting `C2PA_SIGNING_CERT` and `C2PA_SIGNING_KEY`. Without them the
-committed demo key is used, so a fresh clone builds and signs with no setup.
-[`signing/README.md`](signing/README.md) works through the reasoning and sketches
-the two designs — remote signing over a hash, or per-user certificates — that
-would produce credentials worth trusting without giving up the no-upload
-property.
-
-### Not implemented, and why
-
-- **RFC 3161 time-stamps** (§10.3.2.5) and **stapled OCSP responses**
- (§10.3.2.6). Both need a network round-trip to a third party while signing,
- which an app whose premise is that nothing leaves the tab cannot make. Their
- absence is reported in the UI rather than glossed over. It has a real cost: a
- manifest without a time-stamp stops validating when its signing certificate
- expires, which is why the demo certificate is dated twenty years out.
-- **Trust-list checking.** Deciding whether a signer is trustworthy needs a
- trust anchor store; the app reports what a certificate says about itself and
- states plainly that nothing has been checked against any list.
+The same validator, driven from the command line, produces
+[crJSON](https://spec.c2pa.org/specifications/specifications/2.4/crJSON/crjson-format.html) —
+the format the Conformance Program asks applicants to submit:
+
+```sh
+c2pa-harness validate --asset signed.jpg \
+ --trust-list c2pa-trust-list.pem \
+ --tsa-trust-list c2pa-tsa-trust-list.pem \
+ --validation-time 2026-08-27T08:30:12Z
+```
+
+Those four flags are exactly the four inputs the Program specifies. It is a
+front end over the code the browser runs, not a second implementation that could
+quietly disagree with it.
+
+### The signing key is not in your browser
+
+An earlier version of this editor compiled a signing key into the WebAssembly
+module. It was honest about the consequence — the interface said the identity
+was unverifiable, because anyone who loads the page can read the key out — but
+honesty is not conformance.
+
+Objective **O.2** of the [C2PA Generator Product Security Requirements][gpsr]
+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, and the failure is not a matter of
+degree: it put Assurance Level 1 — and therefore the Conforming Products List,
+and therefore any certificate a validator would recognise — permanently out of
+reach.
+
+[gpsr]: https://github.com/c2pa-org/conformance-public/tree/main/docs/v0.2
+
+So the key moved to [`services/claim-signer`](services/claim-signer/README.md),
+and the product became a **Distributed** implementation in the Program's terms:
+
+```text
+ Edge (your browser) Backend (claim-signer)
+ ─────────────────── ──────────────────────────────
+ decode, edit, encode the only claim signing key
+ build the assertions and the claim AES-256-GCM at rest, zeroised
+ compute the Sig_structure ── TLS 1.3 ──▶ after each use
+ (~1 KB: no pixels) sign, then fetch a time-stamp
+ assemble and embed ◀──────────── signature + TimeStampToken
+```
+
+The no-upload promise is unchanged, and it is now enforced rather than asserted:
+`conformance/scripts/check-no-key-material.sh` fails the build if the shipped
+`.wasm` contains a PEM private-key header, the bytes of the test key, or so much
+as a dependency edge on a private-key parser. It runs in CI and again before
+every deployment.
+
+Without a configured signer — which is the case on the plain GitHub Pages build,
+where there is no application server to mint a session credential — the editor
+works exactly as it always did and exports without a credential, and says so.
+There is no half-configured state and no button that fails.
+
+### What a credential from this app proves
+
+**Integrity.** The pixels have not changed since signing. That is the hard
+binding, and it is real: alter one byte of image data and validation fails, here
+and in every other C2PA tool.
+
+**What was done, and by what.** One action per operation the user actually
+performed, with the parameters that describe it, and `allActionsIncluded` set so
+a reader knows the list is complete. Every editing action carries the IPTC
+source type `humanEdits` — "augmentation, correction or enhancement by one or
+more humans using non-generative tools" — because that is exactly what this
+editor is. Nothing generative is ever claimed, and a test asserts it.
+
+**Identity — once there is a certificate.** The interface distinguishes three
+states rather than two, because they are genuinely different:
+
+| | Shown as |
+|---|---|
+| The chain reached an anchor on the configured trust list | the anchor's name |
+| A trust list was configured and the chain missed it | *not on the trust list* |
+| No trust list was configured | *not checked against any trust list* |
+
+Collapsing the middle case into either of the others is the failure C2PA exists
+to prevent — someone believing a picture because an interface told them to, or
+dismissing a good one because it said the wrong thing.
+
+Where the certificate carries them, the Assurance Level and the Conforming
+Products List record id are shown too, straight from the `c2pa-al` and
+`c2pa-cpl-record` extensions. Those two facts are what separate a conformant
+Generator Product from anything that can emit CBOR.
+
+### Time-stamps, and why they are not optional
+
+A C2PA claim signing certificate at Assurance Level 1 is capped at **366 days**.
+§15.8 judges an untimestamped manifest against the validity window *at the
+moment someone looks at it* — so without a time-stamp, every image this editor
+has ever signed would stop validating on the certificate's anniversary.
+
+With one, a validator judges the certificate at the attested time instead, and
+the credential stays good indefinitely. The Backend asks an RFC 3161 authority
+for a stamp over each signature (a 32-byte digest crosses that hop and nothing
+else), and the manifest reserves space for the token before it exists —
+`pad` and `pad2` in the COSE unprotected header, exactly as §10.4.2 and §10.4.4
+prescribe, shrunk to the byte once the real token arrives.
+
+When the authority is unreachable the file is still written, the interface says
+why there is no stamp, and the credential is valid until the certificate
+expires. Refusing to save someone's photograph because a third party was down
+would be the wrong trade.
+
+### Still not implemented, and why
+
+- **Stapled OCSP responses** (§10.3.2.6). Revocation status is reported as
+ `signingCredential.ocsp.skipped` rather than assumed either way. The Backend
+ is the right place to capture one, and it is the obvious next addition.
- **Assertion salts** (`c2sh`), which matter for redaction. Boxes that arrive
carrying one are preserved byte-for-byte so they still hash correctly.
+- **Ed25519 and P-521 signatures.** Both are on the specification's allowed
+ list; the validator reports them as *unsupported* rather than as invalid,
+ because a validator that says "this does not verify" when it means "I cannot
+ check this" is worse than one that admits the gap.
- **Formats other than JPEG**, per the scope note above.
---
@@ -222,34 +306,60 @@ WebAssembly, and cannot be written. ICO is capped at 256×256 by the format.
## How it is put together
+The layout follows the Target of Evaluation boundary the C2PA Conformance
+Program cares about: what runs in the browser, what runs on a server, and what
+is evidence about the two.
+
```
-crates/imagecore/ the Rust engine
+apps/editor/ the Edge subsystem: the browser application
+ index.html
+ src/worker.ts hosts the engine off the main thread, and drives signing
+ src/engine.ts request correlation and preview coalescing
+ src/signer.ts the claim-signer client (HMAC, identity, sign)
+ src/crop.ts the interactive selection
+ src/credentials.ts the Content Credentials panel
+ src/main.ts control wiring
+ src/style.css the A10city brand kit
+ src/editor.css editor components
+
+crates/imagecore/ the Edge engine. No key material, by construction
src/codec.rs decode/encode, EXIF orientation, alpha flattening
src/ops.rs resampling, affine warp, tonal operators
src/pipeline.rs the edit pipeline and its resolution cache
src/lib.rs the wasm-bindgen surface
src/c2pa/ the C2PA claim generator and validator
cbor.rs deterministic CBOR (RFC 8949 §4.2.1)
+ clock.rs RFC 3339 and ASN.1 times as comparable instants
+ der.rs a small DER writer, for RFC 3161 requests
jumbf.rs JUMBF boxes (ISO/IEC 19566-5)
jpegxt.rs APP11 embedding and the hard-binding exclusions
- x509.rs enough DER to read a signing certificate
- cose.rs COSE_Sign1 over the claim (RFC 8152, RFC 9360)
- signer.rs the build's key material
- manifest.rs claims, assertions and validation
- build.rs compiles the signing credentials in
+ x509.rs RFC 5280, plus the C2PA Certificate Policy extensions
+ verify.rs signature checking for every algorithm §13.2.1 allows
+ trust.rs path validation against a C2PA Trust List
+ timestamp.rs RFC 3161 tokens and §15.8 validation
+ identity.rs the public half of the signing credential
+ cose.rs COSE_Sign1, padding and sigTst2 (RFC 8152, RFC 9360)
+ manifest.rs claims, assertions, prepare/complete, validation
+ crjson.rs the crJSON serialisation of a validation result
+ testpki.rs test fixtures, behind a feature no release enables
tests/pipeline.rs 38 behavioural tests, run natively
- tests/c2pa.rs 15 end-to-end signing and tamper tests
-
-signing/ the demo signing chain, and why it is public
-
-src/ the web app
- worker.ts hosts the engine off the main thread
- engine.ts request correlation and preview coalescing
- crop.ts the interactive selection
- credentials.ts the Content Credentials panel
- main.ts control wiring
- style.css the A10city brand kit
- editor.css editor components
+ tests/c2pa.rs 28 end-to-end signing, trust and tamper tests
+ tests/evidence.rs writes the conformance sample assets
+
+crates/c2pa-harness/ the conformance test harness: asset in, crJSON out
+
+services/claim-signer/ the Backend subsystem: the only place a key exists
+ src/keystore.rs sealed key storage, ephemeral use, rotation
+ src/auth.rs authenticating the Edge (O.2)
+ src/tsa.rs the RFC 3161 client
+ src/main.rs TLS 1.3, routing, the signing endpoint
+
+conformance/ the evidence, and how to reproduce it
+ generator-product-security-architecture.md
+ requirements-matrix.md every Level 1 requirement, and where it is met
+ enrolment-runbook.md how to get a real certificate
+ test-credentials/ a test PKI shaped like the real thing
+ scripts/ SBOM, the 90-day gate, the no-key check, evidence
```
### Edits are declarative, and replayed
@@ -320,9 +430,40 @@ npm run dev # builds the engine, then serves with hot reload
| `npm run dev` | Build the engine and serve locally |
| `npm run build` | Production build into `dist/` |
| `npm run build:wasm` | Rebuild only the WebAssembly engine |
-| `npm test` | Run the engine's test suite |
+| `npm test` | Run the whole Rust workspace's test suite |
| `npm run typecheck` | Type-check the web app |
-| `./signing/generate.sh` | Regenerate the demo signing chain |
+| `npm run sbom` | Software Bill of Materials for every component |
+| `npm run audit:supply-chain` | The 90-day CRITICAL/HIGH gate |
+| `./conformance/scripts/generate-evidence.sh` | Sample assets and their crJSON |
+| `./conformance/scripts/check-no-key-material.sh` | Prove the bundle holds no key |
+| `./conformance/test-credentials/generate.sh` | Regenerate the test PKI |
+
+### Running with a signer
+
+The editor works without one — it just exports unsigned. To exercise the whole
+path locally:
+
+```sh
+cargo run -p claim-signer -- import \
+ --id dev --key conformance/test-credentials/c2pa-test-claim-signer.key \
+ --chain conformance/test-credentials/c2pa-test-claim-signer-chain.pem
+cargo run -p claim-signer -- activate --id dev
+CLAIM_SIGNER_ALLOW_PLAINTEXT=1 cargo run -p claim-signer -- serve
+```
+
+with `CLAIM_SIGNER_KEYSTORE`, `CLAIM_SIGNER_KEK` and `CLAIM_SIGNER_CLIENTS` set
+— see [`services/claim-signer/README.md`](services/claim-signer/README.md).
+Then put a `claim-signer.json` in `apps/editor/public/`:
+
+```json
+{
+ "url": "http://localhost:8443",
+ "credential": { "keyId": "dev", "secret": "" }
+}
+```
+
+`CLAIM_SIGNER_ALLOW_PLAINTEXT` is a development-only escape hatch and logs a
+warning naming the conformance objective it violates every time it starts.
To check the credentials against something other than this code, install the
reference tool and point it at a JPEG the app exported:
@@ -332,16 +473,22 @@ cargo install c2patool
c2patool ~/Downloads/photo-edited.jpg
```
-Expect `"validation_state": "Valid"` with `signingCredential.untrusted` as the
-only failure — see [Content Credentials](#content-credentials) for why that is
+Expect `"validation_state": "Valid"`. With the test PKI, `signingCredential.untrusted`
+appears unless you also point `c2patool` at
+`conformance/test-credentials/c2pa-test-trust-list.pem`; see
+[Content Credentials](#content-credentials) for why that is
the correct result rather than a bug.
-`src/wasm/` is build output and is not committed; `npm run dev` and
-`npm run build` regenerate it.
+`apps/editor/src/wasm/` and `conformance/evidence/` are build output and are not
+committed; `npm run dev`, `npm run build` and `generate-evidence.sh` regenerate
+them.
Pushes to `main` deploy to GitHub Pages via `.github/workflows/deploy.yml`.
-Pull requests run formatting, Clippy, the test suite, a WebAssembly build and a
-full site build via `.github/workflows/ci.yml`.
+Pull requests run four jobs via `.github/workflows/ci.yml`: formatting, Clippy
+and the workspace test suite; the conformance evidence; the SBOM and the 90-day
+vulnerability gate; and a full site build. The deploy workflow re-runs the
+vulnerability gate *before* building and the no-key-material check *after*,
+because a gate that only advises is not a gate.
---
@@ -352,11 +499,20 @@ decoded, edited and encoded in a Web Worker in your own tab. The only network
requests the page makes are for its own assets, Google Fonts, and A10city's
privacy-first analytics — none of which sees your image.
-Signing does not change that. The manifest is built and signed in the same
-worker, with a key compiled into the WebAssembly module, so a Content Credential
-costs no network request either. The trade is the one described above: a key
-that lives in the browser is a key everyone has, so these credentials prove that
-an image is unaltered and not who made it.
+Signing changes it by about a kilobyte, in one direction, and only when you ask
+for a credential. The worker builds the manifest and computes a `Sig_structure`
+— the claim, the certificate chain and a context string — and sends *that* to
+the claim-signer. The image is not in it, and a test asserts as much: no run of
+image bytes appears in what is sent, and the whole payload is a fraction of the
+file's size.
+
+The signing service therefore learns that someone signed a claim, and what that
+claim says. It never sees the picture. The time-stamping authority beyond it
+sees less again: 32 bytes of digest.
+
+This is the trade that buys a credential worth believing. The alternative — a
+key in the page — costs no network request at all and proves nothing about who
+made the image, because everyone who loads the page has the key.
Note that a credential is *content* — the actions assertion records what you did
to the picture, and the ingredient assertion records the filename you opened.
diff --git a/index.html b/apps/editor/index.html
similarity index 99%
rename from index.html
rename to apps/editor/index.html
index 28c7f56..6db29f6 100644
--- a/index.html
+++ b/apps/editor/index.html
@@ -385,6 +385,7 @@
Image preview
target="_blank" rel="noopener noreferrer">the spec,
verify elsewhere.
+ The picture stays in this tab; only the claim is sent to be signed.
diff --git a/img/A10city-horizontal-nofill.png b/apps/editor/public/img/A10city-horizontal-nofill.png
similarity index 100%
rename from img/A10city-horizontal-nofill.png
rename to apps/editor/public/img/A10city-horizontal-nofill.png
diff --git a/img/A10city-horizontal.png b/apps/editor/public/img/A10city-horizontal.png
similarity index 100%
rename from img/A10city-horizontal.png
rename to apps/editor/public/img/A10city-horizontal.png
diff --git a/img/favicon.ico b/apps/editor/public/img/favicon.ico
similarity index 100%
rename from img/favicon.ico
rename to apps/editor/public/img/favicon.ico
diff --git a/img/logo-nofill.png b/apps/editor/public/img/logo-nofill.png
similarity index 100%
rename from img/logo-nofill.png
rename to apps/editor/public/img/logo-nofill.png
diff --git a/img/logo.png b/apps/editor/public/img/logo.png
similarity index 100%
rename from img/logo.png
rename to apps/editor/public/img/logo.png
diff --git a/src/credentials.ts b/apps/editor/src/credentials.ts
similarity index 69%
rename from src/credentials.ts
rename to apps/editor/src/credentials.ts
index 1dfb33f..b6f75eb 100644
--- a/src/credentials.ts
+++ b/apps/editor/src/credentials.ts
@@ -8,21 +8,27 @@
*
* The hard part of both is saying something true. A C2PA manifest supports two
* quite different claims — "these pixels have not changed since signing" and
- * "this named party signed them" — and this app can only ever substantiate the
- * first. Its signing key ships inside the page, so anyone can mint a manifest
- * in its name. A green tick next to a signer's name would therefore be a lie,
- * and the wording here works hard not to tell it: integrity and identity are
- * reported as two separate lines, and the identity line always says who has
- * vouched for the signer, which is nobody.
+ * "this named party signed them" — and they are reported as separate lines
+ * because they are separately true. Integrity comes from the hard binding.
+ * Identity comes from the signing certificate chaining to a trust anchor, and
+ * whether that check even ran depends on whether a trust list was configured.
*
- * This matters more than it might seem. The failure mode C2PA is designed
- * against is someone believing a picture because an interface told them to.
+ * So there are three states, not two, and the wording distinguishes them:
+ *
+ * - **trusted** — the chain reached an anchor on the supplied trust list
+ * - **not checked** — no trust list was configured, so nobody looked
+ * - **untrusted** — a list was supplied and the chain did not reach it
+ *
+ * Collapsing the middle one into either of the others is the failure mode C2PA
+ * exists to prevent: someone believing a picture because an interface told them
+ * to, or dismissing a good one because it said the wrong thing.
*/
import type {
CredentialManifest,
CredentialReport,
CredentialSupport,
+ SignerDescription,
SourceInfo,
} from './types';
@@ -103,6 +109,8 @@ export class CredentialsPanel {
private readonly onSignChange: (enabled: boolean) => void;
private support: CredentialSupport = { available: false };
+ private signer: SignerDescription | null = null;
+ private signerProblem: string | null = null;
private report: CredentialReport | null = null;
private thumbnail: string | null = null;
private sourceFormat = '';
@@ -127,6 +135,18 @@ export class CredentialsPanel {
this.render();
}
+ /**
+ * Who the claim-signer says it is, or why it could not be reached.
+ *
+ * Both nulls means no signer is configured, which is a supported
+ * deployment: the editor exports without a credential and says so.
+ */
+ setSigner(signer: SignerDescription | null, problem: string | null): void {
+ this.signer = signer;
+ this.signerProblem = problem;
+ this.render();
+ }
+
/** A file was opened. */
setSource(source: SourceInfo | null): void {
this.report = source?.credentials ?? null;
@@ -151,10 +171,18 @@ export class CredentialsPanel {
return this.wanted && this.canSign;
}
- /** Whether signing is possible at all, given the build and the format. */
+ /**
+ * Whether signing is possible at all: the build supports it, the output
+ * format can carry a manifest, and a claim-signer answered.
+ *
+ * The last of those is the one that changed when the signing key left the
+ * browser. Without a Backend subsystem there is no key anywhere, so there
+ * is nothing to offer.
+ */
private get canSign(): boolean {
return (
this.support.available === true &&
+ this.signer !== null &&
(this.support.formats ?? []).includes(this.outputFormat)
);
}
@@ -251,21 +279,69 @@ export class CredentialsPanel {
head.append(headText);
wrap.append(head);
- // Identity, stated separately from integrity and never as a tick. This
- // is the claim the app cannot substantiate.
+ // Identity, stated separately from integrity and never as a bare tick.
+ // Three states, not two: trusted, not checked, and checked-and-failed.
const signature = manifest.signature;
- const signer = signature.subjectOrganisation || signature.subject || 'an unnamed signer';
+ const signer =
+ signature.subjectOrganisation || signature.subject || 'an unnamed signer';
+ wrap.append(field('Signed by', signer));
+
+ if (signature.trusted) {
+ wrap.append(field('Vouched for by', signature.trustAnchor, 'is-good'));
+ } else if (wasCheckedAgainstTrustList(manifest)) {
+ wrap.append(
+ field(
+ 'Vouched for by',
+ `${signature.issuer || 'an unknown issuer'} — not on the trust list`,
+ 'is-bad',
+ ),
+ );
+ } else {
+ wrap.append(
+ field(
+ 'Vouched for by',
+ `${signature.issuer || 'an unknown issuer'} — not checked against any trust list`,
+ 'is-caution',
+ ),
+ );
+ }
+
+ // The Assurance Level and the Conforming Products List record come
+ // straight out of the certificate. They are the two facts that separate
+ // a conformant Generator Product from anything that can emit CBOR, and
+ // showing them beats any wording this panel could invent.
+ if (signature.assuranceLevel !== null) {
+ wrap.append(
+ field(
+ 'Conformance',
+ `C2PA Assurance Level ${signature.assuranceLevel}${
+ signature.cplRecordId ? ` · CPL ${signature.cplRecordId}` : ''
+ }`,
+ ),
+ );
+ }
+
wrap.append(
- field('Signed by', signer),
field(
- 'Vouched for by',
- signature.issuer
- ? `${signature.issuer} — not on any public trust list`
- : 'nobody',
- 'is-caution',
+ 'Signature',
+ signature.timeStamped
+ ? `${signature.algorithm}, time-stamped ${formatWhen(signature.timeStamp)}${
+ signature.timeStampAuthority ? ` by ${signature.timeStampAuthority}` : ''
+ }`
+ : `${signature.algorithm}, no trusted time-stamp`,
+ signature.timeStamped ? '' : 'is-caution',
),
- field('Signature', `${signature.algorithm}, no trusted time-stamp`),
);
+ if (!signature.timeStamped && signature.notAfter) {
+ wrap.append(
+ line(
+ `Without one, this credential stops validating when the signing certificate expires on ${formatWhen(
+ signature.notAfter,
+ )}.`,
+ 'cred-sub',
+ ),
+ );
+ }
if (manifest.actions.length) {
wrap.append(subheading('What was done'));
@@ -354,7 +430,28 @@ export class CredentialsPanel {
if (!this.support.available) {
signRow.hidden = true;
- signNote.textContent = 'This build has no signing key, so it cannot write credentials.';
+ signNote.className = 'cred-note';
+ signNote.textContent = 'This build cannot write Content Credentials.';
+ return;
+ }
+
+ // No claim-signer is a supported deployment, not a fault: the signing
+ // key lives in the Backend subsystem, and the static build has none.
+ // Saying which of the two happened is the whole point of this branch.
+ if (!this.signer) {
+ signRow.hidden = true;
+ signNote.className = 'cred-note';
+ signNote.replaceChildren(
+ line(
+ this.signerProblem
+ ? `The signing service could not be reached: ${this.signerProblem}`
+ : 'This deployment has no signing service, so exports carry no Content Credentials.',
+ ),
+ line(
+ 'The signing key is deliberately not in your browser — see conformance/README.md.',
+ 'cred-sub',
+ ),
+ );
return;
}
@@ -373,20 +470,25 @@ export class CredentialsPanel {
return;
}
- const signer = this.support.signer;
if (!this.wanted) {
signNote.textContent = 'The exported JPEG will carry no record of where it came from.';
signNote.className = 'cred-note';
return;
}
- signNote.className = 'cred-note is-caution';
+ const signer = this.signer;
+ const conformant = signer.assuranceLevel !== null && signer.claimSigningEku;
+ signNote.className = conformant ? 'cred-note is-good' : 'cred-note is-caution';
signNote.replaceChildren(
line(
- `Signed as ${signer?.name ?? 'this build'}, whose key is public — see signing/README.md.`,
+ `Signed as ${signer.organisation || signer.commonName}, by ${signer.issuer}.`,
),
line(
- 'That proves the pixels are untouched, not who made them. Anyone can sign in this name.',
+ conformant
+ ? `C2PA Assurance Level ${signer.assuranceLevel}${
+ signer.timeStamped ? ', with a trusted time-stamp' : ', without a time-stamp'
+ }. The claim is signed by the service; the image never leaves this tab.`
+ : 'This certificate was not issued under the C2PA Certificate Policy, so validators will not recognise it as coming from a conforming Generator Product.',
'cred-sub',
),
);
@@ -437,6 +539,21 @@ function check(kind: 'good' | 'bad' | 'info', code: string, explanation: string)
return item;
}
+/**
+ * Whether a trust list was consulted for this manifest.
+ *
+ * Derived from the status codes rather than from the app's own configuration,
+ * because the report is the record of what the validator actually did: the
+ * engine files `signingCredential.untrusted` as *informational* when no list
+ * was supplied and as a *failure* when one was and the chain missed it. Reading
+ * it back this way means the panel cannot drift out of step with the validator.
+ */
+function wasCheckedAgainstTrustList(manifest: CredentialManifest): boolean {
+ return !manifest.status.informational.some(
+ (entry) => entry.code === 'signingCredential.untrusted',
+ );
+}
+
function isJpeg(format: string): boolean {
return format.toLowerCase() === 'jpeg' || format.toLowerCase() === 'jpg';
}
diff --git a/src/crop.ts b/apps/editor/src/crop.ts
similarity index 100%
rename from src/crop.ts
rename to apps/editor/src/crop.ts
diff --git a/src/editor.css b/apps/editor/src/editor.css
similarity index 100%
rename from src/editor.css
rename to apps/editor/src/editor.css
diff --git a/src/engine.ts b/apps/editor/src/engine.ts
similarity index 80%
rename from src/engine.ts
rename to apps/editor/src/engine.ts
index 14d9917..0188c33 100644
--- a/src/engine.ts
+++ b/apps/editor/src/engine.ts
@@ -14,8 +14,11 @@ import type {
ExportPayload,
Pipeline,
PreviewResult,
+ SignerConfig,
+ SignerDescription,
SignSpec,
SourceInfo,
+ ValidationRequest,
WorkerRequest,
WorkerResponse,
} from './types';
@@ -86,18 +89,41 @@ export class Engine {
return response.capabilities;
}
- async open(file: File): Promise {
+ async open(file: File, validation: ValidationRequest): Promise {
const bytes = await file.arrayBuffer();
const response = Engine.unwrap(
- await this.send({ kind: 'open', bytes, name: file.name, type: file.type }, [bytes]),
+ await this.send(
+ { kind: 'open', bytes, name: file.name, type: file.type, validation },
+ [bytes],
+ ),
);
if (response.kind !== 'open') throw new Error('Unexpected reply from the image engine.');
return response.source;
}
+ /**
+ * Point the engine at a claim-signer, or at nothing.
+ *
+ * Returns what the certificate says about the signer, or the reason it
+ * could not be reached. Both are useful to show; conflating them would
+ * leave a user unable to tell a deployment without credentials from a
+ * credential service that is down.
+ */
+ async connectSigner(
+ config: SignerConfig | null,
+ ): Promise<{ identity: SignerDescription | null; problem: string | null }> {
+ const response = Engine.unwrap(await this.send({ kind: 'signer', config }));
+ if (response.kind !== 'signer') throw new Error('Unexpected reply from the image engine.');
+ return { identity: response.identity, problem: response.problem };
+ }
+
/**
* Render and encode. Passing `sign` also writes Content Credentials, which
* the engine rejects for any format but JPEG rather than silently dropping.
+ *
+ * A signed export makes a network round trip to the claim-signer in the
+ * middle, so it takes longer than an unsigned one and can fail for reasons
+ * that have nothing to do with the image.
*/
async export(
pipeline: Pipeline,
diff --git a/src/main.ts b/apps/editor/src/main.ts
similarity index 93%
rename from src/main.ts
rename to apps/editor/src/main.ts
index 4a060ab..17027e6 100644
--- a/src/main.ts
+++ b/apps/editor/src/main.ts
@@ -9,6 +9,7 @@
import { Engine } from './engine';
import { CredentialsPanel } from './credentials';
import { CropOverlay } from './crop';
+import { loadSignerConfig } from './signer';
import type {
Capabilities,
CropRect,
@@ -19,6 +20,7 @@ import type {
ResampleFilter,
SignSpec,
SourceInfo,
+ ValidationRequest,
} from './types';
/* -------------------------------------------------------------------------
@@ -361,12 +363,54 @@ function onFrame(frame: PreviewResult): void {
Loading
------------------------------------------------------------------------- */
+/**
+ * What the validator needs and WebAssembly cannot find for itself.
+ *
+ * The trust lists are loaded once at start-up from `trust-lists/` beside the
+ * app. When they are absent the validator still checks the hard binding and the
+ * signature; it reports the signer's identity as unchecked rather than
+ * pretending either way. The clock comes from here because there is none in
+ * `wasm32-unknown-unknown`.
+ */
+function validationRequest(): ValidationRequest {
+ return {
+ now: new Date().toISOString(),
+ trustListPem: trustList,
+ tsaTrustListPem: tsaTrustList,
+ };
+}
+
+/**
+ * The C2PA Trust List and TSA Trust List, when this deployment ships them.
+ *
+ * Fetched rather than bundled so that a list can be refreshed without
+ * rebuilding the WebAssembly module: the C2PA updates its trust list
+ * independently of anyone's release cycle.
+ */
+async function loadTrustLists(): Promise {
+ const fetchText = async (url: string): Promise => {
+ try {
+ const response = await fetch(url, { cache: 'no-store' });
+ return response.ok ? await response.text() : '';
+ } catch {
+ return '';
+ }
+ };
+ [trustList, tsaTrustList] = await Promise.all([
+ fetchText('trust-lists/c2pa-trust-list.pem'),
+ fetchText('trust-lists/c2pa-tsa-trust-list.pem'),
+ ]);
+}
+
+let trustList = '';
+let tsaTrustList = '';
+
async function load(file: File): Promise {
clearError();
setBusy(true);
try {
- const info = await engine.open(file);
+ const info = await engine.open(file, validationRequest());
source = info;
sourceName = file.name.replace(/\.[^.]+$/, '') || 'image';
@@ -952,7 +996,9 @@ async function runExport(): Promise {
try {
const stem = (ui.exportName.value.replace(/\.[^.]+$/, '') || sourceName).trim();
- const payload = await engine.export(pipeline, encode, signSpec(stem));
+ const spec = signSpec(stem);
+ if (spec) ui.exportNote.textContent = 'Encoding, then signing…';
+ const payload = await engine.export(pipeline, encode, spec);
const filename = `${stem || 'image'}.${payload.extension}`;
const blob = new Blob([payload.bytes], { type: payload.mime });
@@ -966,14 +1012,26 @@ async function runExport(): Promise {
// Revoking immediately can race the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
+ // Say whether the credential is time-stamped, because that is what
+ // decides whether it still validates in a year's time.
const credential = payload.manifestBytes
- ? ` · signed +${formatBytes(payload.manifestBytes)}`
+ ? ` · signed +${formatBytes(payload.manifestBytes)}${
+ payload.timeStamped ? ' · time-stamped' : ' · no time-stamp'
+ }`
: '';
ui.exportNote.textContent = `${filename} · ${payload.width} x ${payload.height} · ${formatBytes(
blob.size,
)} · ${payload.ms.toFixed(0)} ms${credential}`;
ui.exportNote.classList.add('ok');
clearError();
+ // A missing time-stamp is not a failed export, but it does shorten how
+ // long the credential stays valid, so it is worth one line.
+ if (payload.manifestBytes && payload.timeStampError) {
+ showError(
+ `Saved, but without a time-stamp: ${payload.timeStampError}. The credential ` +
+ 'will stop validating when the signing certificate expires.',
+ );
+ }
} catch (error) {
ui.exportNote.textContent = '';
showError(error instanceof Error ? error.message : String(error));
@@ -1122,6 +1180,14 @@ async function boot(): Promise {
buildFormatChips();
credentials.setSupport(capabilities.contentCredentials);
+ // Both are optional and neither blocks the editor: without trust lists
+ // the validator reports identity as unchecked, and without a signer
+ // exports simply carry no credential.
+ await loadTrustLists();
+ const config = await loadSignerConfig();
+ const { identity, problem } = await engine.connectSigner(config);
+ credentials.setSigner(identity, problem);
+
ui.engineLine.innerHTML =
`imagecore v${capabilities.version} · Rust → WebAssembly` +
(capabilities.simd ? ' · SIMD' : '') +
diff --git a/apps/editor/src/signer.ts b/apps/editor/src/signer.ts
new file mode 100644
index 0000000..71a532d
--- /dev/null
+++ b/apps/editor/src/signer.ts
@@ -0,0 +1,291 @@
+/**
+ * The Edge subsystem's client for the claim-signer.
+ *
+ * # Why there is a network call in a no-upload image editor
+ *
+ * Because a browser cannot keep a secret, and the C2PA Conformance Program
+ * requires that the claim signing key be kept. Objective O.2 of the Generator
+ * Product Security Requirements asks for a key that is encrypted at rest,
+ * encrypted in memory except while signing, access-controlled by least
+ * privilege, and rotatable. A key compiled into a WebAssembly module and served
+ * to every visitor satisfies none of those, so a browser-only claim generator
+ * cannot reach even Assurance Level 1.
+ *
+ * What crosses the network is a `Sig_structure`: the claim, the certificate
+ * chain, and a context string — a few hundred bytes. The image is not in it.
+ * The editor's promise is intact; only the *signature* moved.
+ *
+ * ```text
+ * this tab sign.example.com
+ * ──────────── ─────────────────────────
+ * GET /v1/identity ─────▶ the public certificate chain
+ * build the claim
+ * POST /v1/sign ─────▶ authenticate, sign, time-stamp
+ * { toBeSigned } ◀───── { signature, timestampToken }
+ * assemble and embed
+ * ```
+ *
+ * # Configuration, and what happens without it
+ *
+ * The editor fetches `claim-signer.json` beside itself at start-up. When it is
+ * absent — which it is on the static GitHub Pages build, where there is no
+ * application server to mint a session credential — signing is simply
+ * unavailable, and the interface says so rather than offering a button that
+ * fails. Editing and exporting work exactly as before; the export just carries
+ * no credential.
+ *
+ * ```json
+ * {
+ * "url": "https://sign.example.com",
+ * "credentialEndpoint": "/api/edge-credential"
+ * }
+ * ```
+ *
+ * `credentialEndpoint` returns a short-lived `{ keyId, secret, expiresAt }` for
+ * this session. An inline `credential` is accepted too, for a development
+ * deployment; the difference is spelt out in
+ * `conformance/generator-product-security-architecture.md`.
+ */
+
+import type { EdgeCredential, SignerConfig, SigningIdentity, SignedClaim } from './types';
+
+/** Refresh a session credential this long before it expires. */
+const CREDENTIAL_MARGIN_MS = 30_000;
+
+/**
+ * Read `claim-signer.json` from beside the app.
+ *
+ * Null means this deployment has no claim-signer, which is a supported
+ * configuration rather than a failure: the static build on GitHub Pages has no
+ * application server to mint a session credential, so it edits and exports
+ * without writing Content Credentials, and says so.
+ */
+export async function loadSignerConfig(
+ url = 'claim-signer.json',
+): Promise {
+ try {
+ const response = await fetch(url, { cache: 'no-store' });
+ if (!response.ok) return null;
+ const config = (await response.json()) as SignerConfig;
+ return config?.url ? config : null;
+ } catch {
+ return null;
+ }
+}
+
+export class SignerUnavailable extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'SignerUnavailable';
+ }
+}
+
+export class ClaimSigner {
+ private readonly config: SignerConfig;
+ private identity: SigningIdentity | null = null;
+ private credential: EdgeCredential | null = null;
+ private credentialExpiry = 0;
+
+ private constructor(config: SignerConfig) {
+ this.config = config;
+ }
+
+ /**
+ * Connect to a configured claim-signer and fetch its signing identity.
+ *
+ * Throws when the service cannot be reached. That is worth distinguishing
+ * from "no signer is configured", which [`loadSignerConfig`] reports by
+ * returning null, because the two call for different words in front of a
+ * user: one is a deployment without credentials, the other is a credential
+ * service that is down.
+ */
+ static async fromConfig(config: SignerConfig): Promise {
+ const signer = new ClaimSigner(config);
+ await signer.loadIdentity();
+ return signer;
+ }
+
+ /** The certificate chain and algorithm, once fetched. */
+ get signingIdentity(): SigningIdentity | null {
+ return this.identity;
+ }
+
+ /**
+ * Fetch `GET /v1/identity`.
+ *
+ * Unauthenticated on purpose: everything it returns is public, and needing
+ * a credential to learn which certificate the service holds would make the
+ * page harder to debug for no gain.
+ */
+ async loadIdentity(): Promise {
+ const response = await fetch(this.endpoint('/v1/identity'), { cache: 'no-store' });
+ if (!response.ok) {
+ throw new SignerUnavailable(
+ `the claim-signer answered ${response.status} when asked for its identity`,
+ );
+ }
+ this.identity = (await response.json()) as SigningIdentity;
+ return this.identity;
+ }
+
+ /**
+ * Sign a `Sig_structure`, returning the signature and any time-stamp.
+ *
+ * A missing time-stamp is not a failure. The authority may be unreachable,
+ * and refusing to save someone's photograph over it would be the wrong
+ * trade; the credential is written without one and the interface reports
+ * that it will stop validating when the certificate expires.
+ */
+ async sign(toBeSigned: Uint8Array): Promise {
+ const body = JSON.stringify({ toBeSigned: toBase64(toBeSigned) });
+ const path = '/v1/sign';
+ const credential = await this.edgeCredential();
+
+ const response = await fetch(this.endpoint(path), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: await authorization(credential, 'POST', path, body),
+ },
+ body,
+ });
+
+ if (!response.ok) {
+ const detail = await response
+ .json()
+ .then((body: { error?: string }) => body.error)
+ .catch(() => undefined);
+ throw new SignerUnavailable(
+ detail ?? `the claim-signer answered ${response.status}`,
+ );
+ }
+
+ const result = (await response.json()) as {
+ signature: string;
+ timestampToken?: string;
+ keyId: string;
+ timestampError?: string;
+ };
+
+ // A rotation between fetching the identity and signing would produce a
+ // signature that does not match the certificate already committed to in
+ // the manifest. Catching it here turns a silently invalid file into a
+ // retry.
+ if (this.identity && result.keyId !== this.identity.keyId) {
+ this.identity = null;
+ throw new SignerUnavailable(
+ 'the signing key changed while this export was being prepared; try again',
+ );
+ }
+
+ return {
+ signature: fromBase64(result.signature),
+ timestampToken: result.timestampToken ? fromBase64(result.timestampToken) : null,
+ timestampError: result.timestampError ?? null,
+ };
+ }
+
+ private endpoint(path: string): string {
+ return `${this.config.url.replace(/\/$/, '')}${path}`;
+ }
+
+ /** The short-lived credential this session authenticates with. */
+ private async edgeCredential(): Promise {
+ if (this.credential && Date.now() < this.credentialExpiry - CREDENTIAL_MARGIN_MS) {
+ return this.credential;
+ }
+
+ if (this.config.credential) {
+ this.credential = this.config.credential;
+ // An inline credential does not expire on its own; treat it as
+ // valid for the session and re-read it if the page reloads.
+ this.credentialExpiry = Number.POSITIVE_INFINITY;
+ return this.credential;
+ }
+
+ if (!this.config.credentialEndpoint) {
+ throw new SignerUnavailable(
+ 'this deployment has no way to authenticate to the claim-signer',
+ );
+ }
+
+ const response = await fetch(this.config.credentialEndpoint, {
+ method: 'POST',
+ credentials: 'same-origin',
+ });
+ if (!response.ok) {
+ throw new SignerUnavailable(
+ `could not obtain a signing session (${response.status})`,
+ );
+ }
+ const credential = (await response.json()) as EdgeCredential;
+ this.credential = credential;
+ this.credentialExpiry = credential.expiresAt
+ ? Date.parse(credential.expiresAt)
+ : Date.now() + 300_000;
+ return credential;
+ }
+}
+
+/**
+ * Build the `Authorization` header the claim-signer expects.
+ *
+ * `HMAC-SHA256(secret, method ‖ path ‖ timestamp ‖ nonce ‖ hex(SHA-256(body)))`,
+ * matching `services/claim-signer/src/auth.rs`. MACing the body rather than
+ * issuing a bearer token is what stops a captured header being pointed at a
+ * different claim.
+ */
+async function authorization(
+ credential: EdgeCredential,
+ method: string,
+ path: string,
+ body: string,
+): Promise {
+ const timestamp = Math.floor(Date.now() / 1000);
+ const nonce = randomHex(16);
+ const encoder = new TextEncoder();
+
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(body));
+ const canonical = [method, path, String(timestamp), nonce, toHex(new Uint8Array(digest))].join(
+ '\n',
+ );
+
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ fromBase64(credential.secret) as BufferSource,
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ );
+ const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(canonical));
+
+ return `C2PA-HMAC-SHA256 key=${credential.keyId}, ts=${timestamp}, nonce=${nonce}, mac=${toBase64(
+ new Uint8Array(mac),
+ )}`;
+}
+
+function randomHex(bytes: number): string {
+ return toHex(crypto.getRandomValues(new Uint8Array(bytes)));
+}
+
+function toHex(bytes: Uint8Array): string {
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+}
+
+export function toBase64(bytes: Uint8Array): string {
+ // Chunked so a large Sig_structure cannot blow the argument limit of
+ // `String.fromCharCode`.
+ let binary = '';
+ const chunk = 0x8000;
+ for (let at = 0; at < bytes.length; at += chunk) {
+ binary += String.fromCharCode(...bytes.subarray(at, at + chunk));
+ }
+ return btoa(binary);
+}
+
+export function fromBase64(text: string): Uint8Array {
+ const binary = atob(text);
+ const bytes = new Uint8Array(binary.length);
+ for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
+ return bytes;
+}
diff --git a/src/style.css b/apps/editor/src/style.css
similarity index 100%
rename from src/style.css
rename to apps/editor/src/style.css
diff --git a/src/types.ts b/apps/editor/src/types.ts
similarity index 58%
rename from src/types.ts
rename to apps/editor/src/types.ts
index 324ddba..3b638e5 100644
--- a/src/types.ts
+++ b/apps/editor/src/types.ts
@@ -89,34 +89,103 @@ export interface Capabilities {
These mirror the report types in `crates/imagecore/src/c2pa/manifest.rs`.
------------------------------------------------------------------------- */
+/**
+ * What the *engine* can do with Content Credentials.
+ *
+ * Note the absence of a signer. The Edge subsystem holds no key and no
+ * certificate; it learns both from the claim-signer at run time. That is a
+ * requirement of the C2PA Conformance Program rather than a design preference
+ * - see `crates/imagecore/src/c2pa/identity.rs`.
+ */
export interface CredentialSupport {
- /** False if this build has no usable signing key. */
available: boolean;
/** Output formats a manifest can be written to. JPEG only - see the
* module docs in `crates/imagecore/src/c2pa/mod.rs` for why. */
formats?: string[];
- signer?: SignerInfo;
+ /** The C2PA specification version this build writes to. */
+ specVersion?: string;
+ /** Always true now: signing needs the Backend subsystem. */
+ remoteSigning?: boolean;
+ timeStamping?: boolean;
+}
+
+/** Where the claim-signer is and how to authenticate to it. */
+export interface SignerConfig {
+ /** Base URL of the claim-signer, e.g. `https://sign.example.com`. */
+ url: string;
+ /** Endpoint that mints a short-lived Edge credential for this session. */
+ credentialEndpoint?: string;
+ /** A fixed credential, for a development deployment. */
+ credential?: EdgeCredential;
+}
+
+/** The Edge subsystem's authentication key. Scoped to limiting access to the
+ * Backend and to nothing else, per objective O.2. */
+export interface EdgeCredential {
+ keyId: string;
+ /** Base64 HMAC secret. */
+ secret: string;
+ /** ISO-8601. Absent means "for this session". */
+ expiresAt?: string;
+}
+
+/** `GET /v1/identity` from the claim-signer: the public half of the credential. */
+export interface SigningIdentity {
+ /** PEM chain, leaf first, trust anchor omitted. */
+ chainPem: string;
+ /** COSE algorithm name, e.g. `ES256`. */
+ algorithm: string;
+ keyId: string;
+ /** Bytes to reserve for a time-stamp token; 0 when none is configured. */
+ timestampBudget: number;
+ assuranceLevel: number | null;
+ cplRecordId: string | null;
+ notAfter: string;
}
-export interface SignerInfo {
- name: string;
+/** What `describeSigningIdentity` reports, for the interface to show. */
+export interface SignerDescription {
+ commonName: string;
organisation: string;
issuer: string;
- /** notAfter of the signing certificate, ISO-8601. */
- expires: string;
+ notBefore: string;
+ notAfter: string;
algorithm: string;
- keyUsage: string[];
- /**
- * True when the chain ends in a self-signed root, i.e. nobody vouches for
- * this signer. Always true for a browser claim generator, whose key is
- * necessarily public. The UI must never present such a credential as
- * proving identity.
- */
- untrusted: boolean;
- /** Whether signatures carry an RFC 3161 time-stamp. Offline: never. */
+ /** From the `c2pa-al` extension: 1 or 2, or null when the certificate was
+ * not issued under the C2PA Certificate Policy. */
+ assuranceLevel: number | null;
+ /** The Conforming Products List record this instance signs under. */
+ cplRecordId: string | null;
+ /** Whether the leaf asserts `c2pa-kp-claimSigning`. */
+ claimSigningEku: boolean;
timeStamped: boolean;
- /** `repository` or `environment`, per `crates/imagecore/build.rs`. */
- source: string;
+ keyId: string;
+}
+
+/** What the claim-signer returned for one claim. */
+export interface SignedClaim {
+ signature: Uint8Array;
+ /** DER `TimeStampToken`, or null when none could be obtained. */
+ timestampToken: Uint8Array | null;
+ /** Why there is no time-stamp, when there is none. */
+ timestampError: string | null;
+}
+
+/**
+ * What a validator needs that WebAssembly cannot find for itself.
+ *
+ * These are the four inputs the C2PA Conformance Program's test harness takes,
+ * minus the asset: a validation time and two trust lists. An absent trust list
+ * means "check the integrity and report the identity as unchecked", never
+ * "trust everything".
+ */
+export interface ValidationRequest {
+ /** RFC 3339 instant to judge certificate validity at. */
+ now: string;
+ /** PEM bundle of C2PA trust anchors. */
+ trustListPem?: string;
+ /** PEM bundle of TSA trust anchors. */
+ tsaTrustListPem?: string;
}
/** One entry of a `status-codes-map` (C2PA 2.2, section 15.2.1). */
@@ -138,6 +207,9 @@ export interface CredentialAction {
when: string;
description: string;
softwareAgent: string;
+ /** IPTC digital source type. Mandatory on most predefined actions under
+ * the Conformance Program's additional requirements. */
+ digitalSourceType: string;
}
export interface CredentialIngredient {
@@ -153,9 +225,21 @@ export interface CredentialSignature {
issuer: string;
subject: string;
subjectOrganisation: string;
+ serialNumber: string;
notBefore: string;
notAfter: string;
+ /** Whether a trusted RFC 3161 time-stamp was found. */
timeStamped: boolean;
+ /** The attested time, when there was one. */
+ timeStamp: string;
+ timeStampAuthority: string;
+ /** Whether the chain reached an anchor on the supplied C2PA Trust List. */
+ trusted: boolean;
+ trustAnchor: string;
+ /** From the `c2pa-al` extension. */
+ assuranceLevel: number | null;
+ /** From the `c2pa-cpl-record` extension. */
+ cplRecordId: string;
}
export interface CredentialManifest {
@@ -164,6 +248,8 @@ export interface CredentialManifest {
title: string;
instanceId: string;
generator: string;
+ /** The `specVersion` the generator declared. */
+ specVersion: string;
claimVersion: number;
actions: CredentialAction[];
ingredients: CredentialIngredient[];
@@ -179,9 +265,12 @@ export interface CredentialReport {
chain: CredentialManifest[];
/** Bytes the credential occupies in the file. */
storeLen: number;
- /** True when every check that was applied passed. Says nothing about
- * whether the signer should be trusted. */
+ /** True when every check that was applied passed. With no trust list
+ * configured, that excludes the signer's identity, which is reported as
+ * unchecked rather than as a pass. */
valid: boolean;
+ /** The instant validity was judged at, RFC 3339. */
+ validationTime: string;
}
export interface SourceInfo {
@@ -219,6 +308,10 @@ export interface ExportPayload {
ms: number;
/** Bytes the Content Credential added, or 0 when the export is unsigned. */
manifestBytes: number;
+ /** Whether the credential carries a time-stamp. */
+ timeStamped: boolean;
+ /** Why it does not, when it does not. */
+ timeStampError: string | null;
}
/**
@@ -252,7 +345,21 @@ export interface SignSpec {
export type WorkerRequest =
| { id: number; kind: 'init' }
- | { id: number; kind: 'open'; bytes: ArrayBuffer; name: string; type: string }
+ | {
+ id: number;
+ kind: 'open';
+ bytes: ArrayBuffer;
+ name: string;
+ type: string;
+ /** What the validator needs and WebAssembly cannot find for itself. */
+ validation: ValidationRequest;
+ }
+ | {
+ id: number;
+ kind: 'signer';
+ /** Null disconnects, leaving the editor able to export unsigned. */
+ config: SignerConfig | null;
+ }
| {
id: number;
kind: 'preview';
@@ -274,6 +381,15 @@ export type WorkerRequest =
export type WorkerResponse =
| { id: number; ok: true; kind: 'init'; capabilities: Capabilities }
+ | {
+ id: number;
+ ok: true;
+ kind: 'signer';
+ /** Null when no claim-signer is configured or it could not be
+ * reached; `problem` says which. */
+ identity: SignerDescription | null;
+ problem: string | null;
+ }
| { id: number; ok: true; kind: 'open'; source: SourceInfo }
| { id: number; ok: true; kind: 'preview'; result: PreviewResult }
| { id: number; ok: true; kind: 'export'; result: ExportPayload }
diff --git a/src/vite-env.d.ts b/apps/editor/src/vite-env.d.ts
similarity index 100%
rename from src/vite-env.d.ts
rename to apps/editor/src/vite-env.d.ts
diff --git a/src/worker.ts b/apps/editor/src/worker.ts
similarity index 62%
rename from src/worker.ts
rename to apps/editor/src/worker.ts
index 8586fb2..c538ab8 100644
--- a/src/worker.ts
+++ b/apps/editor/src/worker.ts
@@ -7,11 +7,19 @@
* rather than copies.
*/
-import init, { Editor, capabilities } from './wasm/imagecore.js';
+import init, {
+ Editor,
+ capabilities,
+ describeSigningIdentity,
+} from './wasm/imagecore.js';
+import { ClaimSigner, SignerUnavailable } from './signer';
import type {
Capabilities,
CredentialReport,
+ SignerConfig,
+ SignerDescription,
SourceInfo,
+ ValidationRequest,
WorkerRequest,
WorkerResponse,
} from './types';
@@ -20,6 +28,16 @@ let ready: Promise | null = null;
let editor: Editor | null = null;
/** Object URL for the open file's manifest thumbnail, revoked on replacement. */
let credentialThumbnailUrl: string | null = null;
+/**
+ * The Backend subsystem, once configured.
+ *
+ * Held here rather than on the main thread because the whole signing round trip
+ * happens on this side: the engine produces the bytes to be signed, the network
+ * call goes out, and the finished file comes back. Passing the intermediate
+ * `Sig_structure` across the worker boundary and back would double the copies
+ * for no benefit.
+ */
+let signer: ClaimSigner | null = null;
function ensureReady(): Promise {
if (!ready) {
@@ -35,13 +53,18 @@ function ensureReady(): Promise {
* would add megabytes of WebAssembly for formats the browser already handles,
* so we let it hand us raw RGBA instead.
*/
-async function open(bytes: ArrayBuffer, name: string, type: string): Promise {
+async function open(
+ bytes: ArrayBuffer,
+ name: string,
+ type: string,
+ validation: ValidationRequest,
+): Promise {
const view = new Uint8Array(bytes);
const hint = name || type;
try {
editor?.free();
- editor = Editor.open(view, hint);
+ editor = Editor.open(view, hint, JSON.stringify(validation));
return {
width: editor.sourceWidth,
height: editor.sourceHeight,
@@ -141,6 +164,96 @@ function labelFromMime(type: string): string {
return subtype ? subtype.replace(/^x-/, '').split('+')[0] : 'browser';
}
+/**
+ * Point the worker at a claim-signer, and report who it says it is.
+ *
+ * A configuration that is absent, or a service that cannot be reached, is not
+ * an error here: the editor still works and still exports, just without a
+ * credential. The interface needs to know which of the two happened, so both
+ * come back rather than collapsing into a null.
+ */
+async function connectSigner(
+ config: SignerConfig | null,
+): Promise<{ identity: SignerDescription | null; problem: string | null }> {
+ signer = null;
+ if (!config) {
+ return { identity: null, problem: null };
+ }
+
+ try {
+ const client = await ClaimSigner.fromConfig(config);
+ const identity = client.signingIdentity;
+ if (!identity) {
+ return { identity: null, problem: 'the claim-signer did not answer' };
+ }
+ signer = client;
+ // Parse the chain in Rust rather than trusting the service's summary of
+ // itself: the certificate is the authority on the Assurance Level and
+ // the Conforming Products List record, and the Edge is about to commit
+ // to it in a manifest.
+ return {
+ identity: JSON.parse(
+ describeSigningIdentity(JSON.stringify(identity)),
+ ) as SignerDescription,
+ problem: null,
+ };
+ } catch (error) {
+ return { identity: null, problem: messageOf(error) };
+ }
+}
+
+/**
+ * Build the manifest, send the claim for signing, and finish the file.
+ *
+ * The three steps are separate calls into the engine because a network round
+ * trip sits between the second and the third. `abandonSignedExport` matters:
+ * without it a failed signature would leave the engine holding a half-built
+ * manifest that the next export would trip over.
+ */
+async function exportSigned(
+ active: Editor,
+ pipeline: unknown,
+ encode: unknown,
+ sign: unknown,
+): Promise<{ encoded: ReturnType; timeStampError: string | null }> {
+ if (!signer?.signingIdentity) {
+ throw new Error(
+ 'Content Credentials need a claim-signer, and none is configured for this deployment.',
+ );
+ }
+
+ const pending = active.prepareSignedExport(
+ JSON.stringify(pipeline),
+ JSON.stringify(encode),
+ JSON.stringify(sign),
+ JSON.stringify(signer.signingIdentity),
+ );
+
+ let toBeSigned: Uint8Array;
+ try {
+ toBeSigned = pending.takeToBeSigned();
+ } finally {
+ pending.free();
+ }
+
+ try {
+ const signed = await signer.sign(toBeSigned);
+ return {
+ encoded: active.completeSignedExport(
+ signed.signature,
+ signed.timestampToken ?? undefined,
+ ),
+ timeStampError: signed.timestampError,
+ };
+ } catch (error) {
+ active.abandonSignedExport();
+ if (error instanceof SignerUnavailable) {
+ throw new Error(`Could not sign: ${error.message}`);
+ }
+ throw error;
+ }
+}
+
function requireEditor(): Editor {
if (!editor) throw new Error('No image is open yet.');
return editor;
@@ -167,11 +280,22 @@ self.onmessage = async (event: MessageEvent) => {
}
case 'open': {
- const source = await open(request.bytes, request.name, request.type);
+ const source = await open(
+ request.bytes,
+ request.name,
+ request.type,
+ request.validation,
+ );
reply({ id: request.id, ok: true, kind: 'open', source });
break;
}
+ case 'signer': {
+ const { identity, problem } = await connectSigner(request.config);
+ reply({ id: request.id, ok: true, kind: 'signer', identity, problem });
+ break;
+ }
+
case 'preview': {
const active = requireEditor();
const started = performance.now();
@@ -210,11 +334,16 @@ self.onmessage = async (event: MessageEvent) => {
case 'export': {
const active = requireEditor();
const started = performance.now();
- const encoded = active.renderExport(
- JSON.stringify(request.pipeline),
- JSON.stringify(request.encode),
- request.sign ? JSON.stringify(request.sign) : '',
- );
+ const { encoded, timeStampError } = request.sign
+ ? await exportSigned(active, request.pipeline, request.encode, request.sign)
+ : {
+ encoded: active.renderExport(
+ JSON.stringify(request.pipeline),
+ JSON.stringify(request.encode),
+ ),
+ timeStampError: null,
+ };
+
const bytes = encoded.takeBytes();
const result = {
bytes: bytes.buffer as ArrayBuffer,
@@ -224,6 +353,8 @@ self.onmessage = async (event: MessageEvent) => {
height: encoded.height,
ms: performance.now() - started,
manifestBytes: encoded.manifestBytes,
+ timeStamped: encoded.timeStamped,
+ timeStampError,
};
encoded.free();
diff --git a/tsconfig.json b/apps/editor/tsconfig.json
similarity index 100%
rename from tsconfig.json
rename to apps/editor/tsconfig.json
diff --git a/vite.config.ts b/apps/editor/vite.config.ts
similarity index 74%
rename from vite.config.ts
rename to apps/editor/vite.config.ts
index d8014bb..62a3c84 100644
--- a/vite.config.ts
+++ b/apps/editor/vite.config.ts
@@ -11,6 +11,10 @@ export default defineConfig({
},
build: {
+ // `npm run build` is invoked from the repository root, so the output
+ // goes where the deploy workflow expects it rather than beside the app.
+ outDir: '../../dist',
+ emptyOutDir: true,
target: 'esnext',
// A 2MB engine is expected; warning about it every build is noise.
chunkSizeWarningLimit: 4096,
diff --git a/conformance/README.md b/conformance/README.md
new file mode 100644
index 0000000..964b1bf
--- /dev/null
+++ b/conformance/README.md
@@ -0,0 +1,125 @@
+# C2PA conformance
+
+Everything in this directory exists to answer one question: **what would it take
+for the Content Credentials this editor writes to be worth believing?**
+
+The answer is not more code in the browser. It is a certificate issued by a
+Certification Authority on the C2PA Trust List, and that is only issued to a
+product on the [Conforming Products List][cpl] — which in turn requires meeting
+the [C2PA Generator Product Security Requirements][gpsr] at Assurance Level 1 or
+higher. So the work here is half implementation and half evidence.
+
+[cpl]: https://github.com/c2pa-org/conformance-public
+[gpsr]: https://github.com/c2pa-org/conformance-public/tree/main/docs/v0.2
+
+## The one change that mattered
+
+An earlier version of this product compiled its signing key into the WebAssembly
+module. It was honest about the consequence — the interface said the identity
+was unverifiable — but honesty is not conformance. Objective **O.2** of the
+security requirements asks for a claim signing key that is:
+
+- stored encrypted at rest,
+- kept encrypted in volatile memory except while signing,
+- access-controlled by least privilege, and
+- rotatable.
+
+A key served to every visitor fails all four, and the failure is not a matter of
+degree. That put Assurance Level 1 — and therefore the Conforming Products List,
+and therefore any certificate anyone would trust — permanently out of reach.
+
+So the product was re-architected as a **Distributed** implementation:
+
+```text
+ Edge (the browser tab) Backend (services/claim-signer)
+ ────────────────────── ───────────────────────────────
+ decode, edit, encode the only claim signing key
+ build assertions and the claim AES-256-GCM at rest
+ compute the Sig_structure ── TLS 1.3 ──▶ authenticate the caller
+ (~1 KB: no pixels) decrypt for one operation, sign
+ fetch an RFC 3161 time-stamp
+ assemble COSE_Sign1, embed ◀──────────── signature + TimeStampToken
+```
+
+The editor's promise is unchanged: **the image still never leaves the tab.**
+What crosses the network is a claim and a certificate chain. The signature moved
+because it had to; the picture did not.
+
+`scripts/check-no-key-material.sh` enforces the outcome mechanically on every
+build — it fails if the shipped `.wasm` contains a PEM private-key header, the
+bytes of the test key, or so much as a dependency edge on a private-key parser.
+
+## What is here
+
+| File | What it is |
+|---|---|
+| `generator-product-security-architecture.md` | The GPSA document the Program requires, written against its template |
+| `requirements-matrix.md` | Every Level 1 requirement, and the file or test that meets it |
+| `enrolment-runbook.md` | How to get a real certificate, once the product is listed |
+| `test-credentials/` | A test PKI shaped exactly like the real thing |
+| `scripts/` | SBOM, the 90-day vulnerability gate, the key-material check, evidence generation |
+| `vulnerability-ledger.json` | When each CRITICAL/HIGH finding was first seen — the memory the 90-day rule needs |
+| `evidence/` | Generated; not committed. See below |
+
+## Producing the evidence
+
+```sh
+# Sample assets and their crJSON, which is what the Program asks applicants for.
+./conformance/scripts/generate-evidence.sh
+
+# Software Bill of Materials for every component of the Target of Evaluation.
+./conformance/scripts/sbom.sh
+
+# The 90-day CRITICAL/HIGH gate. Exits non-zero when something is overdue.
+./conformance/scripts/vulnerability-scan.sh
+
+# Prove the browser bundle holds no key material.
+./conformance/scripts/check-no-key-material.sh
+```
+
+All four run in CI (`.github/workflows/ci.yml`), and the vulnerability gate runs
+again before every deployment, because a gate that only advises is not a gate.
+
+Output lands in `evidence/` and is deliberately not committed: it is derived,
+it churns, and CI uploads it as a build artefact where an assessor can fetch a
+specific run. One command reproduces it.
+
+### What the samples cover
+
+`generate-evidence.sh` produces five assets, chosen so each shows a different
+thing rather than five variations of one:
+
+| Asset | Shows |
+|---|---|
+| `01-edited-timestamped` | The ordinary case: several edits, a thumbnail, a trusted time-stamp |
+| `02-no-timestamp` | What the validator reports when the TSA was unreachable |
+| `03-opened-unchanged` | Opening and saving without editing — the actions say so honestly |
+| `04-two-generations` | Manifest ingestion: a `parentOf` ingredient and the parent's manifest carried forward |
+| `05-tampered-pixels` | A file that must fail, and which check catches it |
+
+## Where this stands
+
+**Implemented and tested.** The architecture, the claim generator, the
+validator, the trust-list path validation, RFC 3161 time-stamping, the crJSON
+harness, and the supply-chain gate. `cargo test --workspace` covers all of it.
+
+**Waiting on the Program, not on code.** A real claim signing certificate cannot
+exist until the product is on the Conforming Products List, and the listing
+requires submitting the GPSA and the evidence above. That ordering is the
+Program's, and it is the right way round.
+
+**Deployment work.** Objective O.6 is about a hosting environment, and a
+repository cannot contain one. `generator-product-security-architecture.md` §2.6
+describes the IAM roles, access policies and monitoring an operator has to stand
+up and evidence; `services/claim-signer/README.md` is the operational side.
+
+Section 3 of the GPSA lists what is outstanding, in one table, rather than
+leaving an assessor to find it.
+
+## Reading order
+
+1. `generator-product-security-architecture.md` §1 — what the product is and
+ where its boundary lies
+2. `crates/imagecore/src/c2pa/identity.rs` — why the key left the browser
+3. `services/claim-signer/src/keystore.rs` — where it went
+4. `requirements-matrix.md` — everything else, one row at a time
diff --git a/conformance/enrolment-runbook.md b/conformance/enrolment-runbook.md
new file mode 100644
index 0000000..a2a3d7e
--- /dev/null
+++ b/conformance/enrolment-runbook.md
@@ -0,0 +1,241 @@
+# Getting a real claim signing certificate
+
+The order of operations matters here and is easy to get wrong, so this is
+written as a runbook rather than as prose. Nothing in it can be short-circuited:
+a Certification Authority on the C2PA Trust List will only issue a claim signing
+certificate to a product that is already on the Conforming Products List, and
+that listing is what the rest of this directory exists to earn.
+
+```text
+ Expression of Interest ──▶ legal agreement ──▶ Intake Form ──▶ GPSA + evidence
+ │
+ ▼
+ certificate ◀── CA enrolment ◀── CPL record id ◀── assessment and approval
+ │
+ ▼
+ claim-signer import / activate
+```
+
+---
+
+## 1. Apply to the Conformance Program
+
+The [Expression of Interest form][eoi] asks for the applicant's legal entity and
+which roles are being applied for. This product is a **Generator Product**, and
+it also validates, so the Generator Product application covers both — the
+Program treats validation functionality inside a Generator Product under the
+same agreement, and asks for crJSON evidence of it.
+
+[eoi]: https://github.com/c2pa-org/conformance-public
+
+Then a legal agreement, then the Program Intake Form. The Intake Form is where
+the answers have to match this repository:
+
+| Field | Answer | Where it comes from |
+|---|---|---|
+| Specification version | **2.2** | `imagecore::c2pa::SPEC_VERSION` |
+| Implementation class | **Distributed** | GPSA §1.7 |
+| Target Max Assurance Level | **1** | GPSA §1.8 |
+| Generate media types | `image/jpeg` | GPSA §1.9 |
+| Validate media types | `image/jpeg` | GPSA §1.9 |
+| Ingests manifests as ingredients | Yes | Sample `04-two-generations` |
+
+The specification version is a contract, not a note: it has to equal the
+`specVersion` written into every manifest, and changing one without the other
+puts the product out of conformance.
+
+## 2. Submit the architecture document and the evidence
+
+```sh
+./conformance/scripts/generate-evidence.sh # sample assets + crJSON
+./conformance/scripts/sbom.sh # CycloneDX SBOMs
+./conformance/scripts/vulnerability-scan.sh # the 90-day gate report
+```
+
+Submit:
+
+- `conformance/generator-product-security-architecture.md`, with §1.1 and the
+ `O`/`C` fields of §1.4 filled in with the real legal entity
+- the five sample assets and their crJSON from `conformance/evidence/`
+- the SBOMs and the gate report
+
+The Program also supplies its own asset library. Run the harness over it and
+submit those results too — the only thing that changes is the path:
+
+```sh
+./target/release/c2pa-harness batch \
+ --asset-dir \
+ --output-dir conformance/evidence/crjson-program \
+ --trust-list \
+ --tsa-trust-list \
+ --validation-time
+```
+
+## 3. Take the CPL record id
+
+Approval produces a Conforming Products List record with a UUID. Two places
+need it:
+
+1. **The certificate.** The CA puts it in the `c2pa-cpl-record` extension
+ (OID `1.3.6.1.4.1.62558.4`) of the leaf. Nothing in this repository writes
+ it; the code *reads* it and shows it.
+2. **The test PKI**, so local runs look like production:
+
+ ```sh
+ C2PA_CPL_RECORD_ID= ./conformance/test-credentials/generate.sh
+ ```
+
+## 4. Enrol with a conformant CA
+
+Any Certification Authority on the C2PA Trust List. [SSL.com][ssl] offers a free
+tier for conformant Generator Products which, at the time of writing, provides
+one Assurance Level 1 claim signing certificate valid for a year and 10,000
+trusted time-stamps annually, issued through their portal, and which requires a
+valid C2PA conformance record id — that is the UUID from step 3.
+
+[ssl]: https://www.ssl.com/products/content-authenticity/content-credentials/c2pa/
+
+Generate the key **on the Backend host**, so it never travels:
+
+```sh
+openssl ecparam -name prime256v1 -genkey -noout -out signer.ec.key
+openssl pkcs8 -topk8 -nocrypt -in signer.ec.key -out signer.key
+openssl req -new -key signer.ec.key -out signer.csr -subj \
+ "/C=/O=/CN=A10city Image Editor"
+```
+
+The subject must match the Conforming Products List entry exactly — the
+Certificate Policy requires `C`, `O` and `CN`, and the CA checks them against
+the record.
+
+Submit `signer.csr` through the portal. What comes back should carry:
+
+| Extension | Expected |
+|---|---|
+| Key Usage (critical) | `digitalSignature`, `nonRepudiation` |
+| Basic Constraints (critical) | `cA=FALSE` |
+| Extended Key Usage | `1.3.6.1.4.1.62558.2.1` plus `emailProtection` or `documentSigning`, and **not** `anyExtendedKeyUsage` |
+| Certificate Policies | `1.3.6.1.4.1.62558.1.1` |
+| `c2pa-al` | `1.3.6.1.4.1.62558.3.10` (Assurance Level 1) |
+| `c2pa-cpl-record` | the UUID from step 3 |
+| Authority Information Access | an OCSP URI |
+| Validity | at most 366 days |
+
+Check before importing, rather than discovering a mismatch in a validator later:
+
+```sh
+openssl x509 -in signer.pem -noout -text | sed -n '/X509v3 extensions/,/Signature Algorithm/p'
+```
+
+## 5. Import it
+
+```sh
+cat signer.pem intermediate.pem > chain.pem # leaf first, root omitted
+
+export CLAIM_SIGNER_KEYSTORE=/var/lib/claim-signer
+export CLAIM_SIGNER_KEK_FILE=/run/secrets/claim-signer-kek
+
+claim-signer import --id 2026-08-signer --key signer.key --chain chain.pem \
+ --note "SSL.com free tier, AL1, order #…"
+claim-signer activate --id 2026-08-signer
+```
+
+`chain.pem` carries the leaf and every intermediate but **never the trust
+anchor** (C2PA 2.2 §13.2.2). Import refuses a CA certificate as the leaf, and
+the service refuses to start if the key does not match the certificate beside
+it, so both mistakes fail loudly at the point they are made.
+
+Then shred the plaintext key:
+
+```sh
+shred -u signer.key signer.ec.key signer.csr
+```
+
+It is inside the keystore, sealed under the key-encryption key, and there is no
+reason for a second copy to exist.
+
+## 6. Configure time-stamping
+
+```sh
+export CLAIM_SIGNER_TSA_URL=
+```
+
+Not optional in practice. An Assurance Level 1 certificate lasts at most 366
+days, and §15.8 of the specification judges an untimestamped manifest against
+the validity window *at the moment someone looks at it* — so without a
+time-stamp every image the editor has ever signed stops validating on the
+certificate's anniversary. With one, a validator judges the certificate at the
+attested time instead, and the credential stays good.
+
+The Backend fetches a stamp over each signature and returns it with the
+signature. When the authority is unreachable the file is still written, the
+response says why there is no stamp, and the interface passes that on — refusing
+to save someone's photograph because a third party was down would be the wrong
+trade.
+
+## 7. Point the editor at the Backend
+
+Two repository variables in the deploy workflow:
+
+| Variable | Value |
+|---|---|
+| `CLAIM_SIGNER_URL` | `https://sign.example.com` |
+| `EDGE_CREDENTIAL_ENDPOINT` | the path that mints a session credential |
+
+which the workflow writes into `dist/claim-signer.json`. Absent, the editor runs
+without signing and says so; there is no half-configured state.
+
+Publish the trust lists too, so the validator can answer the identity question
+rather than reporting it unchecked:
+
+| Variable | Value |
+|---|---|
+| `C2PA_TRUST_LIST_URL` | the C2PA Trust List PEM bundle |
+| `C2PA_TSA_TRUST_LIST_URL` | the C2PA TSA Trust List PEM bundle |
+
+## 8. Verify end to end
+
+```sh
+curl -s https://sign.example.com/healthz | jq
+curl -s https://sign.example.com/v1/identity | jq '{keyId, algorithm, assuranceLevel, cplRecordId, notAfter}'
+```
+
+Then export a signed JPEG from the deployed editor and validate it against the
+real trust lists:
+
+```sh
+./target/release/c2pa-harness validate \
+ --asset exported.jpg \
+ --trust-list c2pa-trust-list.pem \
+ --tsa-trust-list c2pa-tsa-trust-list.pem \
+ --validation-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ --summary
+```
+
+Expect `signingCredential.trusted`, `timeStamp.validated`,
+`claimSignature.insideValidity`, `claimSignature.validated` and
+`assertion.dataHash.match`. Cross-check with a second implementation —
+[Verify][verify] is the obvious one — because agreeing with yourself proves
+nothing.
+
+[verify]: https://contentcredentials.org/verify
+
+---
+
+## Keeping it
+
+**Rotate before expiry, not after.** Schedule the next enrolment 30 days before
+`notAfter`. `GET /healthz` reports it, so a monitor can alert on it. Staging is
+separate from activating precisely so the new credential can be imported and
+inspected while the old one is still signing.
+
+**Keep retired versions.** Images signed under them are still in the world.
+
+**Watch the 90-day clock.** Every CI run prints the countdown on each open
+CRITICAL or HIGH finding, and the gate fails the release when one goes over.
+That is not a formality: releasing past it is a conformance failure under O.3
+and O.4.
+
+**Tell the Program when things change.** Re-submitting evidence is required when
+the specification version changes, when the supported media types change, or
+when the architecture changes in a way that touches the GPSA.
diff --git a/conformance/generator-product-security-architecture.md b/conformance/generator-product-security-architecture.md
new file mode 100644
index 0000000..23662e8
--- /dev/null
+++ b/conformance/generator-product-security-architecture.md
@@ -0,0 +1,532 @@
+# C2PA Generator Product Security Architecture
+
+**A10city Image Editor** · Conformance Program v0.2 · Target Max Assurance Level 1
+
+> This follows the *C2PA Generator Product Security Architecture Document
+> Template* v0.2 section for section, so an assessor can read it beside the
+> template. Where the template asks for something an applicant supplies at
+> submission time rather than something that lives in a repository — a legal
+> entity, a signed attestation, a cloud account's IAM export — the section says
+> so plainly and states what has to be filled in. Nothing here is asserted that
+> the code does not actually do; every claim names the file that implements it.
+
+---
+
+## 1. Generator Product Information
+
+### 1.1 Applicant organization details
+
+**To be completed at submission.** The Program Intake Form requires the full
+legal name, registered address and contact details of the applicant
+organisation. The distinguished name in §1.4 must match the legal name given
+here, and the Conforming Products List record is built from it.
+
+### 1.2 C2PA Conformance Program Version
+
+**0.2**
+
+### 1.3 C2PA Content Credentials Specification Version
+
+**2.2**
+
+Declared in one place in the source, `imagecore::c2pa::SPEC_VERSION`, and
+written into every manifest as the `specVersion` field of
+`claim_generator_info`. The *Additional Conformance Requirements* make that a
+contract: the value has to match the version on the Conforming Products List
+record, so changing it here without changing the listing puts the product out of
+conformance. A test asserts the two are the same string
+(`crates/imagecore/src/c2pa/mod.rs`, `the_generator_declares_the_specification_version`).
+
+### 1.4 Distinguished name
+
+| Field | Value |
+|---|---|
+| Common Name (CN) | `A10city Image Editor` |
+| Organization (O) | *the applicant's registered legal name* |
+| Organizational Unit (OU) | *omitted* |
+| Country (C) | *ISO 3166-1 alpha-2 of the applicant's base of operations* |
+
+The test PKI under `conformance/test-credentials/` issues a leaf with exactly
+this shape, so the parsing, display and validation paths are exercised against a
+correctly formed subject long before a real certificate arrives.
+
+### 1.5 Generator Product Description
+
+A browser-based raster image editor. A person opens a photograph, crops,
+straightens, rotates, resizes and adjusts it, and exports the result. Decoding,
+every pixel operation and encoding happen inside the browser tab in a
+WebAssembly module compiled from Rust; the image is never uploaded.
+
+When the export format is JPEG and a signing service is configured, the export
+carries C2PA Content Credentials describing what was done to it: one action per
+operation the user actually performed, each with the parameters that describe it
+(`c2pa.cropped` with the rectangle, `c2pa.resized` with the dimensions, and so
+on), plus a `c2pa.opened` action and a `parentOf` ingredient naming the file
+that was opened. Where that file carried its own credentials, its manifests are
+copied forward so the provenance chain stays walkable from the finished file
+alone.
+
+The product also validates: any JPEG opened in the editor is checked, and the
+result is shown before the user does anything to it.
+
+### 1.6 Generator Product Target of Evaluation (GP TOE) Description
+
+```text
+┌─ Edge subsystem ─────────────────────────┐ ┌─ Backend subsystem ──────────────┐
+│ the user's browser tab │ │ services/claim-signer │
+│ │ │ │
+│ apps/editor UI, worker │ │ keystore.rs sealed signing key │
+│ crates/imagecore decode, edit, │ │ auth.rs caller auth (O.2) │
+│ (WebAssembly) encode, assertions,│ │ tsa.rs RFC 3161 client │
+│ claim, validation │ │ │
+│ │ │ holds: the ONLY claim signing │
+│ holds: no key material of any kind │ │ key in the system │
+└──────────────┬───────────────────────────┘ └────────────┬──────────────────────┘
+ │ │
+ │ Sig_structure (claim + x5chain, ~1 KB) │
+ ├────────────── TLS 1.3 ─────────────────────▶
+ │ no pixels ever cross this line │
+ ◀────────── signature + TimeStampToken ──────┤
+ │
+ │ RFC 3161, TLS
+ ▼
+ ┌─ outside the TOE ──────────┐
+ │ Time-Stamping Authority │
+ │ (receives a 32-byte hash) │
+ └────────────────────────────┘
+```
+
+Everything inside both boxes is in the Target of Evaluation, because both are
+"necessary for the proper operation of the Generator Product" in the Program's
+terms: the Edge produces the assertions referenced in `created_assertions`, and
+the Backend performs the signing and holds the key. The user's browser and
+operating system are the Edge's hosting platform; the Backend's container host
+and its cloud account are the Backend's.
+
+Explicitly **outside** the TOE: the Time-Stamping Authority (a third-party
+service, on the C2PA TSA Trust List, which receives a digest and nothing else),
+and the Certification Authority.
+
+**What changed, and why it is the whole point of this submission.** An earlier
+version of this product compiled a signing key into the WebAssembly module. That
+is disqualifying under O.2 at any Assurance Level, and no amount of obfuscation
+changes it: whatever a browser is served, its user has. The product was
+re-architected as a Distributed implementation specifically so the key could
+live somewhere that satisfies O.2. `conformance/scripts/check-no-key-material.sh`
+enforces the outcome mechanically in CI — it fails the build if the shipped
+`.wasm` contains a PEM private-key header, the bytes of the test key, or a
+dependency edge on any private-key parsing feature.
+
+### 1.7 Implementation Class
+
+**Distributed.** Assets, assertions and claims are generated on the Edge; claim
+signatures are generated on the Backend.
+
+### 1.8 Target Max Assurance Level
+
+**1.**
+
+Level 2 is out of reach and the reason is architectural rather than a matter of
+effort: it requires the Generator Product to produce verifiable artefacts backed
+by a hardware Root of Trust from the platform the Claim Generator runs on. A web
+page has no such facility. Reaching Level 2 would mean shipping a native
+application, which is a different product.
+
+### 1.9 Target Generator Product capabilities
+
+**Claim generation:**
+
+- `image/jpeg`
+
+**Claim validation (ingestion of manifests as ingredients):**
+
+- `image/jpeg`
+
+One media type, chosen rather than defaulted. The hard binding this generator
+writes is `c2pa.hash.data`, which commits to a byte range of the finished file,
+so the manifest must be embeddable at a known offset and the exclusion rules
+have to be written per format (§18.5.3 for JPEG, §18.5.4 for PNG, and so on).
+JPEG's `APP11` segments are the case the specification treats in most detail.
+Every other format the editor supports keeps working as an ordinary editor and
+simply does not get a credential; the interface says which and why rather than
+greying out a control with no explanation.
+
+The product ingests manifests: opening a JPEG that carries credentials validates
+them, shows the result, and — if the user exports — copies the manifests forward
+and records a `parentOf` ingredient with the validation results of the parent.
+Sample assets demonstrating ingestion are produced by
+`conformance/scripts/generate-evidence.sh`.
+
+---
+
+## 2. Security Architecture Details by Objective
+
+### 2.1 [O.1] Automated Certificate Enrollment Proof of Eligibility
+
+**Applicability: not applicable.** The requirement opens "The following
+requirements are only applicable if conforming GP instances rely on automated
+certificate enrollment for initial certificate issuance or rotation."
+
+#### 2.1.1 Assurance Level 1 & 2 Base Evidence
+
+1. **Certificate enrollment process.** Enrollment is manual and performed by a
+ named operator through the Certification Authority's portal, not by an
+ instance of the Generator Product. SSL.com's free tier for conformant
+ Generator Products is portal-issuance only, which fits this exactly. The
+ operator generates a key pair on the Backend host, produces a CSR, submits it
+ through the portal against the product's Conforming Products List record id,
+ and imports the issued certificate with `claim-signer import`. The procedure,
+ step by step, is `conformance/enrolment-runbook.md`.
+
+ No Generator Product instance ever authenticates to a CA, so there is no
+ enrollment credential in any binary, and requirement 2 for the Edge
+ Implementation Class ("the GP TOE binary/binaries SHALL NOT include
+ authentication secrets") is satisfied vacuously as well as in fact.
+
+2. **Authentication method & API details.** Not applicable while enrollment is
+ manual. If this product later moves to an API-issued certificate — which the
+ premium tiers of conformant CAs offer — this section will be updated within
+ the 90 days the requirement allows, and the enrollment credential will be
+ held in the Backend's key management service alongside the key-encryption
+ key, never in a shipped artefact.
+
+3. **Management of authentication secrets.** None exist. See above.
+
+#### 2.1.2 Assurance Level 2 Additional Evidence
+
+Not applicable: Level 1 is the target, and enrollment is not automated.
+
+---
+
+### 2.2 [O.2] Confidentiality of the Claim Signing Key
+
+This is the objective the architecture was rebuilt around. Implementation:
+`services/claim-signer/src/keystore.rs` and `services/claim-signer/src/auth.rs`.
+
+#### 2.2.1 Assurance Level 1 & 2 Base Evidence
+
+1. **Key generation & storage.** The claim signing key is an ECDSA P-256 key
+ (NIST FIPS 186-4; `secp256r1`), generated on the Backend host with OpenSSL 3
+ and never transmitted. P-384 is also supported by the keystore for a
+ deployment whose CA issues on that curve.
+
+ At rest, the key is stored as `nonce ‖ AES-256-GCM(PKCS#8 DER)` in
+ `//key.enc`, mode `0600`. The additional authenticated
+ data binds the ciphertext to its version id, so a `key.enc` cannot be lifted
+ from a retired version into the active one — a test asserts that the move is
+ detected rather than silently signing with the wrong key
+ (`a_sealed_key_moved_between_versions_does_not_decrypt`).
+
+ The key-encryption key is 32 bytes, supplied to the process from outside the
+ filesystem holding the ciphertext. `CLAIM_SIGNER_KEK_FILE` (a mounted secret,
+ preferred) takes precedence over `CLAIM_SIGNER_KEK` (an environment
+ variable), because an environment variable is readable by anything that can
+ read `/proc`. In the reference deployment the file is projected from the
+ cloud provider's secret manager. The service logs which source it used at
+ start-up, so a production deployment cannot quietly be running on a
+ development configuration.
+
+2. **Access controls & encryption.** The plaintext key is never held by any
+ long-lived object. `Keystore` owns the ciphertext only; there is no accessor
+ that returns the key, and the sole entry point is `Keystore::sign`, which
+ decrypts, signs and drops. That is deliberate design rather than discipline —
+ a key that cannot be obtained cannot be leaked by the next person to add a
+ feature.
+
+ On the host, the sealed key is `0600` and owned by the service's dedicated
+ unprivileged account. The account has no shell and no other role. The
+ key-encryption key is readable only by that account.
+
+3. **Ephemeral plaintext key handling.** The decrypted PKCS#8 DER lives in a
+ `zeroize::Zeroizing>`, which overwrites its buffer on drop; the
+ `p256`/`p384` `SigningKey` types zero their own scalars on drop. The window
+ is one function call with no I/O and no `await` inside it. The release
+ profile sets `panic = "abort"`, so a panic during signing terminates the
+ process rather than unwinding through a handler that could observe the key —
+ fail-closed is the right posture for a component whose only job is to hold
+ one secret.
+
+ The non-GP code that touches the plaintext is `aes-gcm`, `p256`, `p384` and
+ `zeroize` from the RustCrypto project. Their vulnerability monitoring is the
+ same pipeline as everything else: SBOM plus `cargo-audit` on every pull
+ request and before every release, with the 90-day gate described in §2.3.1.
+
+4. **Key rotation process.** Two commands, deliberately separate so a new
+ credential can be staged and inspected before anything signs with it:
+
+ ```text
+ claim-signer import --id 2027-01-signer --key new.key --chain new.pem
+ claim-signer activate --id 2027-01-signer
+ ```
+
+ Retired versions are kept, not deleted: images signed under them are still in
+ the world, and the certificate has to remain available to explain them. The
+ active version's id is published in `GET /v1/identity` and returned with every
+ signature; the Edge refuses to complete an export whose signature came back
+ under a different key id than the certificate it already committed to in the
+ manifest.
+
+ Triggers: certificate expiry (Assurance Level 1 caps a claim signing leaf at
+ 366 days, so rotation is at minimum annual and is scheduled 30 days before
+ `notAfter`), suspected compromise, and any change of Certification Authority.
+
+5. **Subsystem mutual authentication & role validation.**
+
+ *Backend authenticates Edge.* Every `/v1/sign` request carries an
+ `Authorization: C2PA-HMAC-SHA256` header whose MAC covers the method, the
+ path, a timestamp, a nonce and a digest of the body — symmetric key MAC, one
+ of the methods the requirement names. MACing the request rather than issuing
+ a bearer token is what stops a captured header being pointed at a different
+ claim. The comparison is constant-time; the timestamp must be within 120
+ seconds; nonces are remembered for that window so a request cannot be
+ replayed; and a failed authentication does not consume its nonce, so an
+ attacker cannot burn one they observed. Unknown-key and bad-MAC are reported
+ identically to the caller so the endpoint is not an oracle for enumerating
+ key ids, while the log distinguishes them.
+
+ The Edge secret is a session credential minted by the application server for
+ a signed-in session, short-lived, and rate-limited per session. Its role is
+ exactly the one the requirement scopes it to — "only for the purposes of
+ limiting access to the Backend subsystem" — and not identity: a browser
+ cannot keep a secret, and the C2PA trust model rests on the Backend's key,
+ not on this one.
+
+ *Edge authenticates Backend.* TLS 1.3, against a URL fixed in the Edge's
+ configuration. Where a deployment can also issue client certificates,
+ `CLAIM_SIGNER_CLIENT_CA` turns on mutual TLS and the HMAC layer sits inside
+ it.
+
+ *Role validation.* Two roles, and neither can perform the other's operations.
+ The Edge role may call `GET /v1/identity` and `POST /v1/sign` and nothing
+ else; the routing table has no other authenticated route. Key import and
+ activation are not HTTP operations at all — they are subcommands run by an
+ operator on the host, which removes the whole class of "an Edge credential
+ was used to rotate the key" from the design.
+
+#### 2.2.2 / 2.2.3 Assurance Level 2 Additional Evidence
+
+Not applicable at the target level. For the record, the keystore is written
+against a `sign(message) -> signature` boundary, so moving to a KMS or an HSM
+is a change to one implementation and not to the service around it.
+
+---
+
+### 2.3 [O.3] Protection of the Claim Generator
+
+#### 2.3.1 Assurance Level 1 & 2 Base Evidence
+
+1. **SCA / SBOM scanning tools.**
+
+ | Tool | Scope | Output |
+ |---|---|---|
+ | `cargo-cyclonedx` | every Rust crate in the TOE | CycloneDX 1.5 JSON |
+ | `npm sbom` | the browser application | CycloneDX JSON |
+ | `cargo-audit` | Rust dependencies, against the RustSec advisory database (NVD-mapped) | JSON |
+ | `npm audit` | JavaScript dependencies, against the GitHub Advisory Database (NVD-mapped) | JSON |
+
+ Driven by `conformance/scripts/sbom.sh` and
+ `conformance/scripts/vulnerability-scan.sh`. Both run in CI on every pull
+ request (`.github/workflows/ci.yml`, job `supply-chain`) and the second runs
+ again before every release (`.github/workflows/deploy.yml`). SBOMs and scan
+ output are uploaded as build artefacts and land in
+ `conformance/evidence/`.
+
+2. **90-day remediation policy.** The gate is
+ `conformance/scripts/gate.py`, and it is a gate rather than a report: a
+ non-zero exit fails the job, and the release workflow runs it *before* the
+ build step so a blocked release cannot be published.
+
+ The rule needs a memory, because a scanner only knows about today, and the
+ question is not "are there findings" but "has any finding been open too
+ long". `conformance/vulnerability-ledger.json` is that memory. It is
+ committed, so the clock survives a fresh runner and an assessor can see the
+ history:
+
+ ```text
+ first seen + 90 days < today → the build fails
+ otherwise → the build passes, printing the countdown
+ ```
+
+ Three further behaviours are deliberate. A CRITICAL or HIGH finding that is
+ *not* in the ledger also fails the build, so the clock cannot be avoided by
+ never recording a finding. A finding may be marked `accepted` with a written
+ reason, which is visible in the diff that adds it. And a finding the scanners
+ stop reporting is marked `resolved` with a date rather than deleted, so how
+ long each fix took is on the record.
+
+ The gate scores CVSS v3 vectors itself, because `cargo audit` reports the
+ vector — `CVSS:3.1/AV:N/AC:H/…` — and leaves the `severity` field null. An
+ earlier revision treated that as "severity unknown" and passed it, which
+ would have let a genuine CRITICAL through the one control this objective
+ rests on. `gate.py --self-test` checks the scorer against vectors with
+ published scores (Log4Shell 10.0, Heartbleed 7.5, and the `rsa` advisory
+ below at 5.9) and runs before every evaluation, so a scorer that stopped
+ working could not masquerade as a clean scan.
+
+3. **Open findings, and why they do not block.** One advisory is currently
+ outstanding, and it is worth stating rather than leaving an assessor to find
+ it in the SBOM:
+
+ | Finding | Severity | Disposition |
+ |---|---|---|
+ | `RUSTSEC-2023-0071` — Marvin Attack on `rsa` 0.9.10 | Medium (5.9) | Not reachable, and below the blocking threshold |
+ | `RUSTSEC-2025-0134` — `rustls-pemfile` unmaintained | Informational | Not a vulnerability; tracked, not blocking |
+
+ The Marvin Attack is a timing sidechannel on RSA *private-key* operations.
+ This product performs no RSA private-key operation anywhere: `rsa` is linked
+ only by `crates/imagecore/src/c2pa/verify.rs`, and only for
+ `RsaPublicKey`, `pkcs1v15::VerifyingKey` and `pss::VerifyingKey` — signature
+ *verification*, so that a manifest signed by an RSA-issued certificate can be
+ checked. No RSA private key type is linked into any binary in the workspace,
+ and the claim signing keys are ECDSA. The advisory has no patched release, so
+ removing the dependency would mean refusing to validate manifests from RSA
+ signers, which is a worse outcome than an unreachable Medium.
+
+#### 2.3.2 Assurance Level 2 Additional Evidence
+
+Not applicable at the target level. Noted for context: the Claim Generator is
+written in safe Rust with no `unsafe` blocks in the C2PA modules, `cargo clippy`
+runs with `-D warnings` in CI, and the browser is a sandboxed execution
+environment with ASLR and W^X — but none of that is offered as Level 2 evidence,
+because Level 2 turns on hardware-backed attestation the platform cannot give.
+
+---
+
+### 2.4 [O.4] Protection of Assets & Assertions at Generation
+
+#### 2.4.1 Assurance Level 1 & 2 Base Evidence
+
+1. **SCA / SBOM scanning tools.** The same pipeline as §2.3.1, and deliberately
+ the same scope: O.4 is the wider objective, covering "all software in GP TOE
+ that processes/modifies the Digital Content and/or assertions", so the SBOM
+ covers the image pipeline (`image`, `fast_image_resize`, `imageproc`), the
+ claim generator, the browser application, and the claim-signer. There is no
+ component of the TOE outside the scan.
+
+2. **90-day remediation policy.** As §2.3.1. The gate does not distinguish which
+ crate a finding is in, so a vulnerability in the resampling library blocks a
+ release exactly as one in the signing path does.
+
+#### 2.4.2 Assurance Level 2 Additional Evidence
+
+Not applicable at the target level.
+
+---
+
+### 2.5 [O.5] Protection of Traffic Between Subsystems
+
+#### 2.5.1 Assurance Level 1 & 2 Base Evidence (Distributed class)
+
+1. **TLS 1.3 & cryptographic protocols.** The claim-signer's listener is
+ configured with TLS 1.3 as the *only* permitted version, not as a minimum:
+
+ ```rust
+ rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
+ ```
+
+ `services/claim-signer/src/main.rs`, `tls_config`. A downgrade is impossible
+ rather than merely discouraged. The stack is `rustls` 0.23 with the `ring`
+ provider, whose TLS 1.3 suites are `TLS13_AES_256_GCM_SHA384`,
+ `TLS13_AES_128_GCM_SHA256` and `TLS13_CHACHA20_POLY1305_SHA256`, with key
+ exchange over X25519 and the NIST P-curves. Where the deployment sets
+ `CLAIM_SIGNER_CLIENT_CA`, the same listener requires a client certificate
+ validated by `WebPkiClientVerifier`.
+
+ There is one non-conformant escape hatch and it announces itself:
+ `CLAIM_SIGNER_ALLOW_PLAINTEXT` serves HTTP for local development and logs a
+ warning naming this objective every time it starts.
+
+ The Backend's outbound leg to the Time-Stamping Authority is also TLS, via
+ `ureq` built against `rustls`; a native-TLS build was avoided so the
+ negotiated protocol is not left to whatever the host OS happens to ship.
+
+#### 2.5.2 Assurance Level 2 Additional Evidence
+
+Not applicable at the target level.
+
+---
+
+### 2.6 [O.6] Protection of the Hosting Environment
+
+**Applicability: Distributed class, so this applies to the Backend's hosting
+environment only.** The Edge's hosting environment is the user's browser, which
+is not the applicant's to configure.
+
+The controls below are properties of a *deployment*, not of source code, so this
+section describes what the reference deployment does and what an operator must
+be able to evidence. `services/claim-signer/README.md` is the operational
+counterpart.
+
+#### 2.6.1 Assurance Level 1 & 2 Base Evidence
+
+1. **IAM & Role-Based Access Control.** The claim-signer runs as a container in
+ a single-purpose cloud project whose only workload is claim signing. Access
+ is governed by the provider's IAM with RBAC. Three roles exist:
+
+ | Role | May | Held by |
+ |---|---|---|
+ | `signer-runtime` | read the key-encryption key from the secret manager; write logs | the service's workload identity, nothing human |
+ | `signer-operator` | run `import` / `activate`; read logs | two named individuals |
+ | `signer-auditor` | read logs and configuration; no secret access | the security reviewer |
+
+ No principal holds both `signer-operator` and the ability to alter the audit
+ log destination.
+
+2. **Principal access policies.** The runtime identity is a workload identity
+ with no interactive login and no key of its own. Human access to the host is
+ through the provider's session-recorded break-glass mechanism, requires
+ multi-factor authentication, and is alerted on. Service accounts hold no
+ long-lived credentials.
+
+3. **Cloud resource IAM policies.** The secret holding the key-encryption key
+ grants `get` to `signer-runtime` and to no other principal. The container
+ registry grants pull to the runtime and push only to the release pipeline's
+ identity. The keystore volume is mounted read-only by the runtime and
+ writable only during an operator session. No storage bucket in the project is
+ public.
+
+4. **Vulnerability scanning & OWASP Top 10 coverage.** Dependency scanning is
+ the pipeline in §2.3.1. The API surface is three endpoints, and the OWASP Top
+ 10 is covered as follows, since a list this short can be answered concretely
+ rather than by assertion:
+
+ | OWASP category | How it is addressed |
+ |---|---|
+ | A01 Broken access control | Two roles, enforced at the routing table; no authenticated route but `/v1/sign`; key import is not an HTTP operation at all |
+ | A02 Cryptographic failures | TLS 1.3 only; AES-256-GCM at rest; constant-time MAC comparison; no home-grown primitives |
+ | A03 Injection | No database, no shell, no templating. The only parsed inputs are Base64 and DER, into memory-safe Rust with explicit length checks |
+ | A04 Insecure design | The signing key is unreachable by construction (§2.2.1); the request MAC covers the body so a captured credential cannot be re-aimed |
+ | A05 Security misconfiguration | Configuration is environment-only and fails closed: a missing key-encryption key, client list or TLS certificate refuses to start. The plaintext-HTTP escape hatch logs a warning naming O.5 |
+ | A06 Vulnerable components | §2.3.1 |
+ | A07 Authentication failures | MAC with replay protection and a 120-second window; identical responses for unknown key and bad MAC; no passwords and no sessions |
+ | A08 Data integrity failures | The signed artefact is verified against the certificate at start-up; a time-stamp is checked against the signature it should cover before being returned |
+ | A09 Logging failures | Structured JSON logs of every signature (client, key id, digest of the signature, whether it was time-stamped) and every refusal with its reason. The claim itself is never logged |
+ | A10 Server-side request forgery | One outbound destination, the TSA URL, fixed in configuration and never taken from a request |
+
+ A request body limit of 256 KiB and a 30-second timeout bound the resources
+ any one caller can consume.
+
+5. **Timely remediation policy.** High severity within 30 days, Moderate within
+ 90, Low within 180 — the timeline the template names — measured from
+ detection and tracked in the same ledger as §2.3.1. Operating system and base
+ image patches are applied by rebuilding and redeploying the container; the
+ base image is rebuilt weekly and on any advisory affecting it.
+
+#### 2.6.2 Assurance Level 2 Additional Evidence
+
+Not applicable at the target level.
+
+---
+
+## 3. What is not yet in place
+
+Stated plainly rather than left for an assessor to discover, because the
+Conformance Program's value depends on applicants being straight about this.
+
+| Item | State |
+|---|---|
+| Legal entity details (§1.1, §1.4 `O` and `C`) | To be filled in at submission |
+| A real claim signing certificate | Not yet issued. The product is not on the Conforming Products List, so no conformant CA can issue one — the ordering of the programme, not an omission. See `conformance/enrolment-runbook.md` |
+| Production Backend deployment | The service is complete and tested; §2.6 describes the deployment an operator must stand up and evidence |
+| C2PA Trust List and TSA Trust List in the shipped app | Fetched at run time from `trust-lists/` when the deployment publishes them; absent on the static build, where the validator reports signer identity as unchecked rather than guessing |
+| crJSON against the Program's own asset library | The harness is complete and produces crJSON from this repository's assets; point `--asset-dir` at the Program's library when it is supplied |
diff --git a/conformance/requirements-matrix.md b/conformance/requirements-matrix.md
new file mode 100644
index 0000000..dead000
--- /dev/null
+++ b/conformance/requirements-matrix.md
@@ -0,0 +1,135 @@
+# Conformance requirements matrix
+
+Every requirement this product has to meet for **Assurance Level 1** under
+C2PA Conformance Program v0.2, and where in the repository it is met. The point
+of the table is that each row names a file or a command, so a claim can be
+checked rather than taken on trust.
+
+Status key:
+
+- **Met** — implemented and covered by a test or a CI step
+- **Deployment** — implemented, but the evidence is a property of a running
+ deployment rather than of the source
+- **Pending** — cannot be done yet, with the reason
+- **N/A** — the requirement does not apply, with the reason
+
+---
+
+## Generator Product Security Requirements
+
+### O.1 — Proof of eligibility during automated certificate enrollment
+
+| # | Requirement | Status | Where |
+|---|---|---|---|
+| 1 | Implement the CA's secure authentication method for automated enrollment | N/A | Enrollment is manual through the CA's portal; no GP instance authenticates to a CA. `conformance/enrolment-runbook.md` |
+| 2 | Edge binaries SHALL NOT include authentication secrets | Met | No enrollment secret exists anywhere. Enforced in general by `conformance/scripts/check-no-key-material.sh` |
+| SE1 | Document the enrollment process and secret management | Met | GPSA §2.1.1 |
+
+### O.2 — Confidentiality of the claim signing key
+
+| # | Requirement | Status | Where |
+|---|---|---|---|
+| 1 | Key encrypted at rest; encrypted in memory except while signing | Met | AES-256-GCM in `services/claim-signer/src/keystore.rs`. Tests: `the_key_is_never_stored_in_the_clear`, `a_tampered_ciphertext_is_refused` |
+| 2 | Access to the decrypted key by least privilege | Met | No accessor returns the key; `Keystore::sign` is the only path. File mode `0600`, asserted by `the_sealed_key_is_readable_only_by_its_owner`. Host IAM in GPSA §2.6.1 |
+| 3 | Capable of rotating the claim signing key | Met | `claim-signer import` / `activate`. Test: `rotation_stages_a_version_before_switching_to_it` |
+| D1 | The Edge API key is used only to limit access to the Backend | Met | `services/claim-signer/src/auth.rs`; it grants nothing but `/v1/sign` |
+| D2 | Edge and Backend mutually authenticated, roles validated | Met | HMAC-SHA256 over the request in `auth.rs`; TLS 1.3 server certificate, plus optional mTLS, in `main.rs` |
+| D3 | The Backend authenticates the calling client before signing | Met | `sign` authenticates before it parses the body. Tests: the whole `auth::tests` module |
+| SE1.1 | Document key access controls | Met | GPSA §2.2.1 |
+| SE1.2 | Document the key rotation process | Met | GPSA §2.2.1 (4) |
+| SE1.3 | Document ephemeral plaintext key handling | Met | GPSA §2.2.1 (3) |
+| SE1.4 | Document subsystem mutual authentication | Met | GPSA §2.2.1 (5) |
+
+### O.3 — Protecting the Claim Generator
+
+| # | Requirement | Status | Where |
+|---|---|---|---|
+| 1 | SCA or SBOM analysis against the NVD | Met | `conformance/scripts/sbom.sh`, `vulnerability-scan.sh`; CI job `supply-chain` |
+| 2 | CRITICAL/HIGH fixed or mitigated within 90 days | Met | `conformance/scripts/gate.py` + `vulnerability-ledger.json`; fails the build |
+| SE1.1 | Document the scanning tools | Met | GPSA §2.3.1 (1) |
+| SE1.2 | Document the pipeline control that prevents late release | Met | GPSA §2.3.1 (2); the control is the gate itself |
+
+### O.4 — Protecting assets and assertions at generation
+
+| # | Requirement | Status | Where |
+|---|---|---|---|
+| 1 | SCA/SBOM for all software that processes content or assertions | Met | Same pipeline; scope is the whole workspace plus the web app |
+| 2 | CRITICAL/HIGH fixed within 90 days | Met | Same gate; it does not exempt the image pipeline |
+
+### O.5 — Protecting traffic between subsystems
+
+| # | Requirement | Status | Where |
+|---|---|---|---|
+| 1 | TLS 1.3 or higher between subsystems | Met | `builder_with_protocol_versions(&[&TLS13])` in `services/claim-signer/src/main.rs` — the only version, not a floor |
+| SE1 | Document the TLS versions and cipher suites | Met | GPSA §2.5.1 |
+
+### O.6 — Protecting the hosting environment
+
+| # | Requirement | Status | Where |
+|---|---|---|---|
+| 1 | IAM with RBAC over resources used for generation | Deployment | GPSA §2.6.1 (1)–(3) describes the roles an operator must configure |
+| 2 | Vulnerability scanning of dependencies and API surfaces, incl. OWASP Top 10 | Met / Deployment | Dependency scanning is in CI; the API-surface review is GPSA §2.6.1 (4), which answers all ten categories concretely |
+| 3 | Basic exploit countermeasures; timely OS and software patching | Deployment | GPSA §2.6.1 (5) |
+| SE1 | Document the IAM system and its coverage | Deployment | GPSA §2.6.1 |
+
+---
+
+## Additional Conformance Requirements against the Specification
+
+| Requirement | Applies to | Status | Where |
+|---|---|---|---|
+| `specVersion` in `claim_generator_info` | 2.4+ | Met | `imagecore::c2pa::SPEC_VERSION`, written for 2.2 as well. Test: `the_claim_declares_the_specification_version_it_was_built_to` |
+| `allActionsIncluded` present with a defined value | 2.2 and 2.4 | Met | Always `true` — the editor knows every operation it performed. Test: `the_actions_assertion_declares_that_it_is_complete` |
+| `digitalSourceType` in all non-excepted predefined actions | 2.2 and 2.4 | Met | Applied centrally in `actions_for` so a newly added action cannot omit it. Tests: `every_action_that_needs_a_digital_source_type_has_one` (in), `every_action_that_needs_a_digital_source_type_carries_one_in_the_file` (out) |
+| `digitalSourceType` prohibited on `c2pa.opened` | 2.4 | Met | Stripped centrally. Test: `c2pa_opened_never_carries_a_digital_source_type` |
+| crJSON output from a test harness taking asset, trust list, TSA trust list and validation time | all | Met | `crates/c2pa-harness`; the four flags are the four inputs. Eleven end-to-end tests in `crates/c2pa-harness/tests/harness.rs` |
+
+The `digitalSourceType` value the editor writes is
+`http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits` — "augmentation,
+correction or enhancement by one or more humans using non-generative tools",
+which is exactly what every operation this product offers is. A test asserts
+that nothing generative is ever claimed
+(`nothing_generative_is_ever_claimed`).
+
+---
+
+## Certificate profile
+
+The certificate comes from a Certification Authority, so these are properties
+the product must *read and honour* rather than produce. The test PKI
+(`conformance/test-credentials/generate.sh`) issues certificates to the same
+profile so every path is exercised before a real certificate exists.
+
+| C2PA Certificate Policy, Claim Signing Leaf — Assurance Level 1 | Handled |
+|---|---|
+| Validity ≤ 366 days | Parsed to comparable instants; expiry is why time-stamping exists. Test: `assurance_level_1_caps_validity_at_366_days` |
+| Key Usage critical: `digitalSignature`, `nonRepudiation` | `x509::key_usage`; `trust::profile_violation` rejects a leaf without `digitalSignature` |
+| Basic Constraints critical, `cA=FALSE` | `trust::evaluate` rejects a CA certificate outright — §14.5 forbids one signing a claim |
+| EKU: `c2pa-kp-claimSigning` plus `emailProtection` or `documentSigning` | `x509::oid::EKU_CLAIM_SIGNING`; surfaced to the interface via `SignerDescription::claim_signing_eku` |
+| `anyExtendedKeyUsage` absent | Rejected by `trust::profile_violation` |
+| Certificate Policies contains `1.3.6.1.4.1.62558.1.1` | Parsed into `Certificate::certificate_policies` |
+| AIA with an OCSP URI | Parsed into `Certificate::ocsp_responders` |
+| `c2pa-al` (1.3.6.1.4.1.62558.3) | Parsed to 1 or 2; shown in the interface and in crJSON |
+| `c2pa-cpl-record` (1.3.6.1.4.1.62558.4) | Parsed; shown in the interface and in crJSON |
+
+---
+
+## Specification conformance the tests pin
+
+Not a complete enumeration of the 300-plus normative requirements — that is the
+Program's assessment to make — but the ones where getting it wrong produces a
+manifest that looks fine and is not.
+
+| Clause | What it requires | Test |
+|---|---|---|
+| §10.4 | The hard binding excludes exactly the manifest's own bytes | `the_exclusion_range_is_exactly_the_manifest`, `widening_the_exclusion_range_is_rejected` |
+| §10.4.2, §10.4.4 | Reserve with `pad`, shrink it to fit, use `pad2` for the sizes `pad` alone cannot express | `padding_hits_the_reserved_size_exactly_for_every_shortfall` |
+| §13.2.2 | `x5chain` in the *protected* header; one certificate is a `bstr`, several an array | `the_certificate_chain_is_covered_by_the_signature`, `a_single_certificate_chain_is_a_bare_byte_string` |
+| §13.2.3 | Detached payload is `null`, never a zero-length `bstr` | `the_payload_is_detached_rather_than_embedded` |
+| §15.7 | Trust path, then signature; algorithm on the allowed list | `a_signer_on_the_trust_list_is_reported_as_trusted`, `a_signer_that_fails_against_a_supplied_trust_list_is_a_failure` |
+| §15.8 | A trusted time-stamp moves the instant validity is judged at | `a_time_stamp_keeps_a_credential_valid_after_the_certificate_expires` |
+| §15.8.2 | An unusable time-stamp is informational and ignored, not fatal | `a_time_stamp_from_an_untrusted_authority_is_ignored_not_fatal` |
+| §15.10.3 | Every referenced assertion present and hashing to the recorded value | `tampering_with_an_assertion_is_caught` |
+| §15.10.3.2.2 | `c2pa.opened` resolves to a `parentOf` ingredient | `a_second_edit_chains_onto_the_first` |
+| §18.10.2 | `c2pa.opened` is the first action when an asset was opened | `an_opened_file_starts_with_c2pa_opened` |
+| RFC 5652 §5.4 | A CMS signature covers the signed attributes as a `SET OF`, and the message digest attribute must cover the payload | `a_tampered_tst_info_does_not_verify` |
diff --git a/conformance/scripts/check-no-key-material.sh b/conformance/scripts/check-no-key-material.sh
new file mode 100755
index 0000000..64891ee
--- /dev/null
+++ b/conformance/scripts/check-no-key-material.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+#
+# Prove the Edge subsystem holds no key.
+#
+# The single change that makes Assurance Level 1 reachable for this product is
+# that the claim signing key left the browser. That is easy to assert in a
+# document and easy to undo by accident, so it is checked mechanically instead.
+#
+# Three things are verified, in increasing order of how much they would catch:
+#
+# 1. The `test-pki` feature — the only thing that compiles a private key into
+# `imagecore` — is not enabled by default and is not reachable from a
+# release build of the library.
+# 2. No private-key *type* is linked into the wasm build. `p256/pkcs8` and
+# `p256/pem` are what would pull one in; a plain `cargo tree` shows whether
+# they are on.
+# 3. The built `.wasm` contains no PEM private-key header and none of the
+# bytes of the test signing key.
+#
+# The third is the one that would catch a mistake nobody anticipated, so it runs
+# against the artefact that actually ships rather than against the manifest.
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$root"
+
+fail() {
+ echo "check-no-key-material: $1" >&2
+ exit 1
+}
+
+echo "==> the test PKI feature is off by default"
+if cargo metadata --no-deps --format-version 1 \
+ | python3 -c '
+import json, sys
+crate = next(p for p in json.load(sys.stdin)["packages"] if p["name"] == "imagecore")
+default = crate["features"].get("default", [])
+sys.exit(0 if "test-pki" in default else 1)
+'; then
+ fail "imagecore enables test-pki by default; a release build would carry a private key"
+fi
+
+echo "==> no private-key handling is linked into the library build"
+# `no-dev` matters: the test suite legitimately links the private-key readers,
+# and it is only their presence in the *normal* graph - the one wasm-pack
+# builds - that would put a key in the browser.
+if cargo tree -p imagecore --target wasm32-unknown-unknown --edges features,no-dev 2>/dev/null \
+ | grep -qE 'p256 feature "(pkcs8|pem)"'; then
+ fail "the wasm build links p256's pkcs8/pem features, which exist only to read private keys"
+fi
+
+echo "==> the built module contains no key material"
+wasm="apps/editor/src/wasm/imagecore_bg.wasm"
+if [ ! -f "$wasm" ]; then
+ echo " $wasm is not built; building it"
+ npm run --silent build:wasm
+fi
+
+for needle in "BEGIN PRIVATE KEY" "BEGIN EC PRIVATE KEY" "BEGIN RSA PRIVATE KEY"; do
+ if grep -qa "$needle" "$wasm"; then
+ fail "the wasm module contains '$needle'"
+ fi
+done
+
+# And the exact bytes of the test key, in case a future change embeds it in
+# some form the string search above would miss.
+key="conformance/test-credentials/c2pa-test-claim-signer.key"
+if [ -f "$key" ]; then
+ python3 - "$wasm" "$key" <<'PY'
+import base64, sys, pathlib
+
+wasm = pathlib.Path(sys.argv[1]).read_bytes()
+pem = pathlib.Path(sys.argv[2]).read_text()
+body = "".join(line for line in pem.splitlines() if not line.startswith("-----"))
+der = base64.b64decode(body)
+
+for name, needle in (("DER", der), ("base64", body.encode())):
+ if needle and needle in wasm:
+ print(f"check-no-key-material: the wasm module contains the test signing key ({name})",
+ file=sys.stderr)
+ raise SystemExit(1)
+PY
+fi
+
+echo "==> the Edge subsystem holds no key material"
diff --git a/conformance/scripts/gate.py b/conformance/scripts/gate.py
new file mode 100755
index 0000000..f05025a
--- /dev/null
+++ b/conformance/scripts/gate.py
@@ -0,0 +1,396 @@
+#!/usr/bin/env python3
+"""Decide whether the supply chain is fit to release.
+
+The rule comes from the C2PA Generator Product Security Requirements, O.3 and
+O.4 at Assurance Level 1: a CRITICAL or HIGH severity vulnerability must be
+fixed or mitigated within 90 days of detection. So the question is never "are
+there findings" — an open-source dependency tree of any size always has some —
+but "has any finding been open too long".
+
+Answering that needs a memory, because a scanner only knows about today. The
+ledger at ``conformance/vulnerability-ledger.json`` is that memory: each finding
+records when it was first seen, and from that the deadline follows. It is
+committed to the repository so the record is auditable and so a fresh CI runner
+cannot reset the clock by forgetting.
+
+ first_seen + 90 days < today -> the build fails
+ otherwise -> the build passes, with a countdown
+
+A finding that has disappeared from the scanners is marked resolved rather than
+deleted, so the evidence of how long it took to fix survives.
+
+Exit status is 0 when the gate passes and 1 when it does not.
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import pathlib
+import sys
+
+WINDOW_DAYS = 90
+BLOCKING = {"critical", "high"}
+
+
+def today() -> dt.date:
+ return dt.datetime.now(dt.timezone.utc).date()
+
+
+def load_json(path: pathlib.Path) -> dict:
+ if not path or not path.exists() or path.stat().st_size == 0:
+ return {}
+ try:
+ return json.loads(path.read_text())
+ except json.JSONDecodeError:
+ # A scanner that produced nothing usable is not a pass. Reporting it as
+ # an empty result would be the one failure mode that matters here.
+ print(f"gate: {path} is not valid JSON; treating the scan as failed", file=sys.stderr)
+ sys.exit(1)
+
+
+def severity_of(cvss: float | None, label: str | None) -> str:
+ """Normalise to the CVSS v3 qualitative bands."""
+ if cvss is not None:
+ if cvss >= 9.0:
+ return "critical"
+ if cvss >= 7.0:
+ return "high"
+ if cvss >= 4.0:
+ return "medium"
+ return "low"
+ return (label or "unknown").lower()
+
+
+# CVSS v3.1 base metric weights, from the specification's Table 15.
+_AV = {"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}
+_AC = {"L": 0.77, "H": 0.44}
+_PR_UNCHANGED = {"N": 0.85, "L": 0.62, "H": 0.27}
+_PR_CHANGED = {"N": 0.85, "L": 0.68, "H": 0.50}
+_UI = {"N": 0.85, "R": 0.62}
+_CIA = {"H": 0.56, "L": 0.22, "N": 0.0}
+
+
+def _roundup(value: float) -> float:
+ """CVSS v3.1 Appendix A: round up to one decimal, without float surprises."""
+ scaled = int(round(value * 100_000))
+ if scaled % 10_000 == 0:
+ return scaled / 100_000.0
+ return (scaled // 10_000 + 1) / 10.0
+
+
+def cvss_v3_base_score(vector: str) -> float | None:
+ """Compute the base score from a CVSS v3 vector string.
+
+ ``cargo audit`` reports the *vector* — ``CVSS:3.1/AV:N/AC:H/...`` — and not
+ the score. An earlier version of this gate treated that as "severity
+ unknown", which let it pass anything cargo-audit reported: an advisory has
+ no ``severity`` label either, so a genuine CRITICAL would have sailed
+ through the one control O.3 and O.4 depend on. Scoring the vector is the
+ fix; guessing was never acceptable and neither was ignoring it.
+
+ Returns ``None`` for anything that is not a well-formed v3 vector, and the
+ caller treats that as unscored rather than as safe.
+ """
+ if not isinstance(vector, str) or not vector.startswith("CVSS:3"):
+ return None
+
+ metrics = {}
+ for part in vector.split("/")[1:]:
+ key, _, value = part.partition(":")
+ metrics[key] = value
+
+ try:
+ scope_changed = metrics["S"] == "C"
+ av = _AV[metrics["AV"]]
+ ac = _AC[metrics["AC"]]
+ pr = (_PR_CHANGED if scope_changed else _PR_UNCHANGED)[metrics["PR"]]
+ ui = _UI[metrics["UI"]]
+ confidentiality = _CIA[metrics["C"]]
+ integrity = _CIA[metrics["I"]]
+ availability = _CIA[metrics["A"]]
+ except KeyError:
+ return None
+
+ iss = 1 - ((1 - confidentiality) * (1 - integrity) * (1 - availability))
+ if scope_changed:
+ impact = 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02) ** 15
+ else:
+ impact = 6.42 * iss
+ if impact <= 0:
+ return 0.0
+
+ exploitability = 8.22 * av * ac * pr * ui
+ combined = impact + exploitability
+ if scope_changed:
+ combined *= 1.08
+ return _roundup(min(combined, 10.0))
+
+
+def self_test() -> int:
+ """Check the scorer against vectors with published scores.
+
+ Run by ``vulnerability-scan.sh`` before every real evaluation, so the
+ control cannot quietly stop working: a scorer that returns ``None`` for
+ everything would make the gate pass unconditionally, and that failure looks
+ exactly like a clean scan.
+ """
+ cases = [
+ # RUSTSEC-2023-0071, the Marvin Attack on `rsa`.
+ ("CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N", 5.9, "medium"),
+ # CVE-2021-44228, Log4Shell.
+ ("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 10.0, "critical"),
+ # CVE-2014-0160, Heartbleed.
+ ("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", 7.5, "high"),
+ # A vector with no impact at all scores zero.
+ ("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N", 0.0, "low"),
+ ]
+
+ failures = 0
+ for vector, expected, band in cases:
+ score = cvss_v3_base_score(vector)
+ if score != expected:
+ print(f"self-test: {vector} scored {score}, expected {expected}", file=sys.stderr)
+ failures += 1
+ elif severity_of(score, None) != band:
+ print(
+ f"self-test: {vector} banded {severity_of(score, None)}, expected {band}",
+ file=sys.stderr,
+ )
+ failures += 1
+
+ # Malformed input must be unscored, never zero: "I could not read this" and
+ # "this is harmless" are different answers.
+ for junk in ["", "not a vector", "CVSS:2.0/AV:N/AC:L/Au:N/C:P/I:P/A:P", "CVSS:3.1/AV:X"]:
+ if cvss_v3_base_score(junk) is not None:
+ print(f"self-test: {junk!r} should not have scored", file=sys.stderr)
+ failures += 1
+
+ print("self-test: passed" if not failures else f"self-test: {failures} failure(s)")
+ return 1 if failures else 0
+
+
+def from_cargo_audit(report: dict) -> list[dict]:
+ """Findings from ``cargo audit --json``."""
+ findings = []
+ for entry in report.get("vulnerabilities", {}).get("list", []) or []:
+ advisory = entry.get("advisory", {}) or {}
+ package = entry.get("package", {}) or {}
+ # cargo-audit reports the CVSS *vector*, not the score, and leaves
+ # `severity` null. Scoring it is what makes the 90-day gate mean
+ # anything: without this every cargo finding read as "unknown" and
+ # passed, CRITICAL ones included.
+ cvss = advisory.get("cvss")
+ if isinstance(cvss, str):
+ score = cvss_v3_base_score(cvss)
+ elif isinstance(cvss, (int, float)):
+ score = float(cvss)
+ else:
+ score = None
+ findings.append(
+ {
+ "id": advisory.get("id", "unknown"),
+ "ecosystem": "cargo",
+ "package": package.get("name", "unknown"),
+ "version": package.get("version", ""),
+ "title": advisory.get("title", ""),
+ "severity": severity_of(score, advisory.get("severity")),
+ "url": advisory.get("url", ""),
+ }
+ )
+ # Unmaintained crates and yanked versions are warnings, not vulnerabilities;
+ # they are recorded so they are visible but never block a release.
+ for kind, entries in (report.get("warnings") or {}).items():
+ for entry in entries or []:
+ advisory = entry.get("advisory") or {}
+ package = entry.get("package", {}) or {}
+ findings.append(
+ {
+ "id": advisory.get("id", f"{kind}:{package.get('name', 'unknown')}"),
+ "ecosystem": "cargo",
+ "package": package.get("name", "unknown"),
+ "version": package.get("version", ""),
+ "title": advisory.get("title", kind),
+ "severity": "informational",
+ "url": advisory.get("url", ""),
+ }
+ )
+ return findings
+
+
+def from_npm_audit(report: dict) -> list[dict]:
+ """Findings from ``npm audit --json`` (npm 7+ schema)."""
+ findings = []
+ for name, entry in (report.get("vulnerabilities") or {}).items():
+ via = entry.get("via") or []
+ advisories = [item for item in via if isinstance(item, dict)]
+ if not advisories:
+ # A transitive entry whose `via` is a package name; the advisory
+ # itself appears under that package's own entry.
+ continue
+ for advisory in advisories:
+ findings.append(
+ {
+ "id": f"GHSA:{advisory.get('source', advisory.get('url', 'unknown'))}",
+ "ecosystem": "npm",
+ "package": name,
+ "version": entry.get("range", ""),
+ "title": advisory.get("title", ""),
+ "severity": severity_of(
+ (advisory.get("cvss") or {}).get("score"),
+ advisory.get("severity") or entry.get("severity"),
+ ),
+ "url": advisory.get("url", ""),
+ }
+ )
+ return findings
+
+
+def key_of(finding: dict) -> str:
+ return f"{finding['ecosystem']}:{finding['package']}:{finding['id']}"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--ledger", type=pathlib.Path)
+ parser.add_argument("--cargo-audit", type=pathlib.Path)
+ parser.add_argument("--npm-audit", type=pathlib.Path)
+ parser.add_argument("--report", type=pathlib.Path)
+ parser.add_argument(
+ "--update",
+ action="store_true",
+ help="record newly detected findings in the ledger and start their clock",
+ )
+ parser.add_argument(
+ "--self-test",
+ action="store_true",
+ help="check the CVSS scorer against vectors with published scores, and exit",
+ )
+ args = parser.parse_args()
+
+ if args.self_test:
+ return self_test()
+
+ findings = from_cargo_audit(load_json(args.cargo_audit)) + from_npm_audit(
+ load_json(args.npm_audit)
+ )
+ current = {key_of(f): f for f in findings}
+
+ if not args.ledger:
+ parser.error("--ledger is required unless --self-test is given")
+
+ ledger = {"findings": {}}
+ if args.ledger.exists():
+ ledger = json.loads(args.ledger.read_text())
+ recorded: dict[str, dict] = ledger.setdefault("findings", {})
+
+ now = today()
+ overdue: list[str] = []
+ pending: list[str] = []
+ untracked: list[str] = []
+
+ for key, finding in sorted(current.items()):
+ severity = finding["severity"]
+ entry = recorded.get(key)
+
+ # The ledger may carry a human's severity assessment, which overrides
+ # a scanner that could not produce one. That is a judgement call and it
+ # is recorded where a reviewer can see and challenge it.
+ if entry and entry.get("severity"):
+ severity = entry["severity"].lower()
+
+ if severity not in BLOCKING:
+ continue
+
+ if entry is None:
+ if args.update:
+ recorded[key] = {
+ "firstSeen": now.isoformat(),
+ "severity": severity,
+ "package": finding["package"],
+ "title": finding["title"],
+ "url": finding["url"],
+ "status": "open",
+ }
+ pending.append(f"{key} — first seen today, due {now + dt.timedelta(days=WINDOW_DAYS)}")
+ else:
+ # A finding nobody has recorded is not automatically overdue,
+ # but it must not pass silently either: CI runs with --update on
+ # the default branch, so an untracked one here means the ledger
+ # is behind.
+ untracked.append(f"{key} — {severity.upper()}: {finding['title']}")
+ continue
+
+ if entry.get("status") == "accepted":
+ # An explicitly accepted risk, with a reason recorded next to it.
+ pending.append(f"{key} — accepted: {entry.get('reason', 'no reason recorded')}")
+ continue
+
+ first_seen = dt.date.fromisoformat(entry["firstSeen"])
+ deadline = first_seen + dt.timedelta(days=WINDOW_DAYS)
+ if now > deadline:
+ overdue.append(
+ f"{key} — {severity.upper()}: {finding['title']} "
+ f"(first seen {first_seen}, due {deadline}, {(now - deadline).days} days over)"
+ )
+ else:
+ pending.append(
+ f"{key} — {severity.upper()}: due {deadline} ({(deadline - now).days} days left)"
+ )
+
+ # Anything the scanners no longer see has been fixed. Keeping the record
+ # rather than deleting it is what makes the ledger evidence.
+ for key, entry in recorded.items():
+ if key not in current and entry.get("status") == "open":
+ entry["status"] = "resolved"
+ entry["resolvedOn"] = now.isoformat()
+
+ if args.update:
+ args.ledger.parent.mkdir(parents=True, exist_ok=True)
+ args.ledger.write_text(json.dumps(ledger, indent=2, sort_keys=True) + "\n")
+
+ report = {
+ "generatedAt": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
+ "windowDays": WINDOW_DAYS,
+ "totalFindings": len(findings),
+ "blocking": len(overdue),
+ "overdue": overdue,
+ "withinWindow": pending,
+ "untracked": untracked,
+ }
+ if args.report:
+ args.report.parent.mkdir(parents=True, exist_ok=True)
+ args.report.write_text(json.dumps(report, indent=2) + "\n")
+
+ print(f"{len(findings)} finding(s) across cargo and npm")
+ for line in pending:
+ print(f" within window: {line}")
+ for line in untracked:
+ print(f" UNTRACKED: {line}")
+ for line in overdue:
+ print(f" OVERDUE: {line}")
+
+ if overdue:
+ print(
+ f"\ngate: {len(overdue)} CRITICAL/HIGH finding(s) have been open longer than "
+ f"{WINDOW_DAYS} days. C2PA Generator Product Security Requirements O.3 and O.4 "
+ "do not permit releasing this.",
+ file=sys.stderr,
+ )
+ return 1
+ if untracked:
+ print(
+ f"\ngate: {len(untracked)} CRITICAL/HIGH finding(s) are not in the ledger. Run "
+ "`./conformance/scripts/vulnerability-scan.sh --update` and commit the result so "
+ "their 90-day clock is on the record.",
+ file=sys.stderr,
+ )
+ return 1
+
+ print("\ngate: passed")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/conformance/scripts/generate-evidence.sh b/conformance/scripts/generate-evidence.sh
new file mode 100755
index 0000000..22f26cc
--- /dev/null
+++ b/conformance/scripts/generate-evidence.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+#
+# Produce the crJSON evidence the C2PA Conformance Program asks for.
+#
+# From the Program document: "Generator Product applicants must provide sample
+# output media files of every asserted generate and validate media type […]
+# along with their associated .crjson or .json files for analysis", and from the
+# Additional Conformance Requirements: "Applicant SHALL provide validation
+# results in crJSON format for a set of test inputs provided by the Conformance
+# Program."
+#
+# Two kinds of asset, because the Program asks for both:
+#
+# * ones this product *generated*, showing what its manifests look like
+# * ones this product *validated*, showing what its validator reports
+#
+# Until the Program supplies its asset library, the same signed files serve as
+# both. Point `--asset-dir` at the Program's assets when they arrive; nothing
+# else changes.
+#
+# Usage: ./conformance/scripts/generate-evidence.sh [asset-dir]
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$root"
+
+out="$root/conformance/evidence"
+assets="${1:-$out/assets}"
+mkdir -p "$out/crjson" "$assets"
+
+echo "==> building the harness"
+cargo build --release -p c2pa-harness --quiet
+
+if [ -z "${1:-}" ]; then
+ echo "==> generating sample assets"
+ # `--nocapture` so the fixture writer's paths land in the log, and a single
+ # test so the run is quick.
+ cargo test --release -p imagecore --test evidence -- --nocapture --ignored
+fi
+
+echo "==> validating and writing crJSON"
+# The validation time is derived from the test certificate rather than fixed,
+# so the evidence stays reproducible after the test PKI is regenerated. With
+# the Program's own assets, pass the time the Program specifies.
+validation_time="$(
+ cargo run --release --quiet -p c2pa-harness -- --help >/dev/null 2>&1
+ python3 - <<'PY'
+import datetime, pathlib, re, subprocess
+pem = pathlib.Path("conformance/test-credentials/c2pa-test-claim-signer.pem")
+text = subprocess.run(
+ ["openssl", "x509", "-in", str(pem), "-noout", "-startdate"],
+ capture_output=True, text=True, check=True,
+).stdout
+stamp = text.split("=", 1)[1].strip()
+at = datetime.datetime.strptime(stamp, "%b %d %H:%M:%S %Y %Z").replace(
+ tzinfo=datetime.timezone.utc
+) + datetime.timedelta(days=1)
+print(at.strftime("%Y-%m-%dT%H:%M:%SZ"))
+PY
+)"
+echo " validation time: $validation_time"
+
+# `|| true` on purpose. The harness exits 1 when an asset does not validate,
+# which is the right contract for `validate` - a caller asking "is this asset
+# good?" needs that in the exit status. But one of the samples is *meant* to
+# fail: `05-tampered-pixels` exists so the evidence shows what the validator
+# reports when a file has been altered, and the Program's own asset library is
+# full of assets like it. Generating evidence succeeds when the documents were
+# written, not when every asset was valid.
+expected="$(find "$assets" -maxdepth 1 \( -iname '*.jpg' -o -iname '*.jpeg' \) | wc -l)"
+./target/release/c2pa-harness batch \
+ --asset-dir "$assets" \
+ --output-dir "$out/crjson" \
+ --trust-list conformance/test-credentials/c2pa-test-trust-list.pem \
+ --tsa-trust-list conformance/test-credentials/c2pa-test-tsa-trust-list.pem \
+ --validation-time "$validation_time" || true
+
+written="$(find "$out/crjson" -maxdepth 1 -name '*.crjson' | wc -l)"
+if [ "$written" -ne "$expected" ]; then
+ echo "==> only $written of $expected assets produced crJSON" >&2
+ exit 1
+fi
+
+echo "==> wrote $written crJSON document(s) into $out/crjson"
diff --git a/conformance/scripts/sbom.sh b/conformance/scripts/sbom.sh
new file mode 100755
index 0000000..4dd2101
--- /dev/null
+++ b/conformance/scripts/sbom.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+#
+# Generate a Software Bill of Materials for every component of the Generator
+# Product Target of Evaluation.
+#
+# The C2PA Generator Product Security Requirements, objectives O.3 and O.4 at
+# Assurance Level 1:
+#
+# "Applicant SHALL ensure a Software Composition Analysis (SCA) or Software
+# Bill of Materials (SBOM) analysis is performed to detect vulnerabilities
+# from the NIST National Vulnerability Database (NVD) in the Claim
+# Generator [O.3] / in all software in the GP TOE that processes or modifies
+# the Digital Content and/or assertions [O.4]."
+#
+# O.4 is the wider of the two and is what fixes the scope here: the image
+# pipeline touches the pixels, the claim generator builds the assertions, and
+# the claim-signer holds the key — so all three are in, and so is the browser
+# app that drives them.
+#
+# Output is CycloneDX, one document per component plus a merged one, written to
+# `conformance/evidence/sbom/`. CycloneDX is on the Conformance Program's list
+# of industry-adopted reporting formats and is what `cargo-audit` and `osv-
+# scanner` both read.
+#
+# Usage: ./conformance/scripts/sbom.sh [output-dir]
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+out="${1:-$root/conformance/evidence/sbom}"
+mkdir -p "$out"
+
+echo "==> Rust components (cargo-cyclonedx)"
+if ! command -v cargo-cyclonedx >/dev/null 2>&1; then
+ echo " installing cargo-cyclonedx"
+ cargo install cargo-cyclonedx --locked --quiet
+fi
+
+# One document per crate rather than one for the workspace: the Conforming
+# Products List records a Generator Product, and an assessor reading the
+# evidence needs to see which dependencies reach the signing key and which only
+# reach the pixels.
+#
+# `cargo cyclonedx` writes each document beside its own Cargo.toml and has no
+# output-directory option, so the files are collected afterwards rather than
+# redirected. `--all` means the full transitive graph; `--top-level` would list
+# only direct dependencies, which is not what an NVD scan needs to cover.
+(
+ cd "$root"
+ cargo cyclonedx --format json --all --spec-version 1.5 --quiet
+)
+
+while IFS= read -r document; do
+ mv "$document" "$out/$(basename "$document")"
+done < <(find "$root/crates" "$root/services" -maxdepth 2 -name '*.cdx.json')
+
+echo "==> Web application (npm)"
+if [ -f "$root/package-lock.json" ]; then
+ (
+ cd "$root"
+ # `npm sbom` needs an installed tree to resolve the graph.
+ [ -d node_modules ] || npm ci --silent
+ npm sbom --sbom-format cyclonedx --sbom-type application \
+ > "$out/editor-web.cdx.json"
+ )
+fi
+
+echo "==> wrote SBOMs into $out"
+ls -1 "$out"
diff --git a/conformance/scripts/vulnerability-scan.sh b/conformance/scripts/vulnerability-scan.sh
new file mode 100755
index 0000000..d591973
--- /dev/null
+++ b/conformance/scripts/vulnerability-scan.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+#
+# The 90-day CRITICAL/HIGH gate.
+#
+# C2PA Generator Product Security Requirements, O.3 and O.4 at Assurance
+# Level 1:
+#
+# "Applicant SHALL ensure that applicable fixes or other mitigations are
+# applied to any […] security vulnerabilities detected with a CRITICAL or
+# HIGH severity ratings in the NIST Common Vulnerability Scoring System
+# (CVSS) version 3 or greater within 90 days of detection."
+#
+# and the static evidence the applicant has to produce for it:
+#
+# "The process by which the build and deployment pipeline prevents the
+# release, more than 90 days after detection, of the Claim Generator with
+# known CRITICAL or HIGH severity vulnerabilities."
+#
+# This script *is* that process. It is what CI runs on every pull request and
+# what the release workflow runs before publishing, and it fails the build when
+# a CRITICAL or HIGH finding has been open longer than the window.
+#
+# # Why a grace period at all, and why it is bounded
+#
+# Refusing to build the moment an advisory lands would mean a dependency's
+# maintainer could stop this project shipping a security fix of its own. The
+# requirement's 90 days is the compromise, and the honest way to implement it is
+# to track *when each finding was first seen* rather than to allow a blanket
+# exemption. `conformance/vulnerability-ledger.json` is that record: a finding
+# appears the first time it is detected, and from then on it has a deadline.
+#
+# Anything past its deadline fails. Anything inside it prints a countdown, so a
+# finding cannot quietly approach the line unnoticed.
+#
+# Usage:
+# ./conformance/scripts/vulnerability-scan.sh scan and gate
+# ./conformance/scripts/vulnerability-scan.sh --update record new findings
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+ledger="$root/conformance/vulnerability-ledger.json"
+evidence="$root/conformance/evidence/vulnerabilities"
+update=false
+[ "${1:-}" = "--update" ] && update=true
+
+mkdir -p "$evidence"
+
+# The scorer decides whether a finding blocks, so a scorer that has quietly
+# stopped working would make this gate pass unconditionally - a failure that
+# looks exactly like a clean scan. Checked against published scores first.
+python3 "$root/conformance/scripts/gate.py" --self-test
+
+echo "==> Rust advisories (cargo-audit, RustSec + NVD-mapped)"
+if ! command -v cargo-audit >/dev/null 2>&1; then
+ cargo install cargo-audit --locked --quiet
+fi
+# `|| true`: a finding is a result to be judged against the ledger, not a
+# reason to abort before the judging happens.
+(cd "$root" && cargo audit --json > "$evidence/cargo-audit.json") || true
+
+echo "==> npm advisories"
+if [ -f "$root/package-lock.json" ]; then
+ (cd "$root" && npm audit --json > "$evidence/npm-audit.json") || true
+fi
+
+echo "==> evaluating against the 90-day window"
+python3 "$root/conformance/scripts/gate.py" \
+ --ledger "$ledger" \
+ --cargo-audit "$evidence/cargo-audit.json" \
+ --npm-audit "$evidence/npm-audit.json" \
+ --report "$evidence/gate-report.json" \
+ $($update && echo --update)
diff --git a/conformance/test-credentials/README.md b/conformance/test-credentials/README.md
new file mode 100644
index 0000000..16db152
--- /dev/null
+++ b/conformance/test-credentials/README.md
@@ -0,0 +1,111 @@
+# Test credentials
+
+A test PKI, shaped exactly like the one a Certification Authority will issue,
+so that every code path a real certificate exercises is exercised in CI first.
+
+**Nothing here is trusted by anything outside this repository, and nothing here
+is used by a deployment.** The keys are committed on purpose — they are test
+fixtures, like a sample JPEG — and they are behind a Cargo feature (`test-pki`)
+that no shipping build enables.
+
+## What changed, and why the old README is gone
+
+This directory used to be called `signing/` and held the key the browser signed
+with. That key was public because it had to be: anything served to a browser is
+downloadable by whoever receives it. The old README argued, correctly, that this
+was an honest arrangement rather than a mistake.
+
+It was also disqualifying. 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 in a WebAssembly bundle is none of those, so Assurance
+Level 1 — and therefore any certificate a validator would recognise — was out of
+reach. See `conformance/README.md` for what replaced it.
+
+So these files no longer sign anything a user will see. They exist to test.
+
+## What is generated
+
+```text
+c2pa-test-root-ca.pem self-signed root
+c2pa-test-issuing-ca.pem claim signing issuing CA, pathlen:0
+c2pa-test-claim-signer.pem the leaf, Assurance Level 1 profile
+c2pa-test-claim-signer.key its PKCS#8 key
+c2pa-test-claim-signer-chain.pem leaf + issuing CA, the x5chain
+c2pa-test-trust-list.pem the root, as a trust list
+
+tsa-test-root-ca.pem a time-stamping authority root
+tsa-test-signer.pem its signer, timeStamping EKU, critical
+tsa-test-signer.key
+c2pa-test-tsa-trust-list.pem the TSA root, as a TSA trust list
+```
+
+Regenerate with:
+
+```sh
+./conformance/test-credentials/generate.sh
+```
+
+Requires OpenSSL 3. It verifies both chains and prints the leaf's extensions
+before finishing.
+
+## The profile, and why it is followed exactly
+
+`generate.sh` implements the *C2PA Claim Signing Leaf — Assurance Level 1*
+profile from the C2PA Certificate Policy v0.2, including the parts it would have
+been easier to skip:
+
+| Property | Value | Why it is not simplified |
+|---|---|---|
+| Validity | **366 days** | The real ceiling. A twenty-year test certificate would hide the expiry handling and make the time-stamp path untestable — and expiry is exactly what time-stamping exists to survive |
+| Extended Key Usage | `1.3.6.1.4.1.62558.2.1` + `emailProtection` | `c2pa-kp-claimSigning` is a C2PA-private OID that no generic tooling knows; the parser has to read it, so the fixture has to carry it |
+| `c2pa-al` | `1.3.6.1.4.1.62558.3.10` | The Assurance Level is shown in the interface and in crJSON. Without it in the fixture, that display is untested |
+| `c2pa-cpl-record` | the nil UUID | The right shape, and obviously not a real record. Override with `C2PA_CPL_RECORD_ID` once the product is listed |
+| Key Usage | critical, `digitalSignature` + `nonRepudiation` | Path validation checks it |
+| Basic Constraints | critical, `cA=FALSE` | §14.5 forbids a CA certificate signing a claim, and the validator enforces it |
+| AIA | an OCSP URI | Parsed and reported; `signingCredential.ocsp.skipped` depends on knowing one exists |
+| Certificate Policies | `1.3.6.1.4.1.62558.1.1` | Parsed |
+
+The chain is three deep — root, issuing CA, leaf — rather than two, because a
+real CA hierarchy has an intermediate and chain building has to walk one.
+`x5chain` carries the leaf and the intermediate but never the root (§13.2.2).
+
+### The 366-day expiry, and tests that do not rot
+
+Certificates that last a year would normally make a test suite a time bomb. The
+tests avoid it by deriving their validation time from the certificate rather
+than hard-coding a date:
+
+```rust
+testpki::validation_time() // notBefore + one day
+testpki::after_expiry() // notAfter + one day
+```
+
+So regenerating the PKI moves the tests with it, and the expiry paths stay
+genuinely tested instead of being postponed.
+
+## The stand-in time-stamping authority
+
+`imagecore::c2pa::testpki::issue_timestamp` issues a real RFC 3161
+`TimeStampToken` with the TSA key above: CMS `SignedData` wrapping a `TSTInfo`,
+signed over DER-encoded signed attributes rather than over the payload directly.
+
+Mocking it would have tested the mock. What the validator has to cope with is
+the real structure — including the fact that the signature covers a *digest* of
+the payload carried in an attribute, which is the classic place to get CMS
+verification wrong. One of the tests moves a digit inside `genTime` and asserts
+the token stops validating.
+
+## Never used in production
+
+Three things keep these files out of a deployment, in increasing order of how
+much they would catch:
+
+1. They are reachable only behind the `test-pki` Cargo feature, which is not in
+ `imagecore`'s default features.
+2. `conformance/scripts/check-no-key-material.sh` fails the build if the normal
+ dependency graph links a private-key parser at all.
+3. The same script searches the built `.wasm` for PEM private-key headers and
+ for the literal bytes of the key above, in DER and in Base64.
+
+All three run in CI and again before deployment.
diff --git a/conformance/test-credentials/c2pa-test-claim-signer-chain.pem b/conformance/test-credentials/c2pa-test-claim-signer-chain.pem
new file mode 100644
index 0000000..29da548
--- /dev/null
+++ b/conformance/test-credentials/c2pa-test-claim-signer-chain.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIC+DCCAp6gAwIBAgIUeqhNn1NmjU3XxuBWzA2aJHwfkUkwCgYIKoZIzj0EAwIw
+UTELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczErMCkGA1UEAwwi
+QTEwY2l0eSBDMlBBIFRlc3QgQ2xhaW0gU2lnbmluZyBDQTAeFw0yNjA4MjYwODMw
+MTJaFw0yNzA4MjcwODMwMTJaMEMxCzAJBgNVBAYTAklOMRUwEwYDVQQKDAxBMTBj
+aXR5IExhYnMxHTAbBgNVBAMMFEExMGNpdHkgSW1hZ2UgRWRpdG9yMFkwEwYHKoZI
+zj0CAQYIKoZIzj0DAQcDQgAEy/IwCxCkupdLyc/XBDPNkbZWdHGbOR1V05fsWELp
+ccVx/6MLovMu35pG3rJ+TayGSHKG7kyMTxSz+xsXtC6+T6OCAWAwggFcMAwGA1Ud
+EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgbAMB8GA1UdJQQYMBYGCisGAQQBg+heAgEG
+CCsGAQUFBwMEMB0GA1UdDgQWBBR5jkcLlIqP4oqaP1GOMToVj4eIyjAfBgNVHSME
+GDAWgBR+cUbBZt9THA8zTwFrJqSOr9IEeDAXBgNVHSAEEDAOMAwGCisGAQQBg+he
+AQEwcgYIKwYBBQUHAQEEZjBkMCkGCCsGAQUFBzABhh1odHRwOi8vb2NzcC50ZXN0
+LmludmFsaWQvYzJwYTA3BggrBgEFBQcwAoYraHR0cDovL3BraS50ZXN0LmludmFs
+aWQvYzJwYS1pc3N1aW5nLWNhLmRlcjAZBgkrBgEEAYPoXgMEDAYKKwYBBAGD6F4D
+CjAzBgkrBgEEAYPoXgQEJgwkMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAw
+MDAwMDAwMAoGCCqGSM49BAMCA0gAMEUCIGftLOJ6IYYN6/OKdz7hZ24uBjS+hFiQ
+6m759lh8aU1sAiEAy3dhqnLAZ85O+Y+llWdOBcoSNjz9e/he/LHyJk4S2XQ=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIICPTCCAeOgAwIBAgIUEhe0WBBNDlCHzIFGfjj41IG9SsgwCgYIKoZIzj0EAwIw
+SDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEiMCAGA1UEAwwZ
+QTEwY2l0eSBDMlBBIFRlc3QgUm9vdCBDQTAeFw0yNjA4MjYwODMwMTJaFw0zNjA4
+MjMwODMwMTJaMFExCzAJBgNVBAYTAklOMRUwEwYDVQQKDAxBMTBjaXR5IExhYnMx
+KzApBgNVBAMMIkExMGNpdHkgQzJQQSBUZXN0IENsYWltIFNpZ25pbmcgQ0EwWTAT
+BgcqhkjOPQIBBggqhkjOPQMBBwNCAATxSCV/UMAWYYjNMc9CR+RHQNRUanVGd5hp
+ekvg0MdlwKScHk1Sw07LPxhciKtnr1katpQQrnqD0vGzlaGqvwg7o4GhMIGeMBIG
+A1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBR+cUbB
+Zt9THA8zTwFrJqSOr9IEeDAfBgNVHSMEGDAWgBQCWMiC6DiYVpUglVVGCkukFF8b
+5TAXBgNVHSAEEDAOMAwGCisGAQQBg+heAQEwHwYDVR0lBBgwFgYKKwYBBAGD6F4C
+AQYIKwYBBQUHAwQwCgYIKoZIzj0EAwIDSAAwRQIhAPrjnpdotM3HfWb8H8bMnT2R
+I4QQuQXhmDU4Y/w62F7yAiBhzrrmFMdBngW3MFH0YSLsel//Ucut/cIi116JzL0Y
+hQ==
+-----END CERTIFICATE-----
diff --git a/conformance/test-credentials/c2pa-test-claim-signer.key b/conformance/test-credentials/c2pa-test-claim-signer.key
new file mode 100644
index 0000000..47996eb
--- /dev/null
+++ b/conformance/test-credentials/c2pa-test-claim-signer.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9nTfpakK3lqt+F6d
+fSfomn0dWgdlfOFiTMF0whSMox+hRANCAATL8jALEKS6l0vJz9cEM82RtlZ0cZs5
+HVXTl+xYQulxxXH/owui8y7fmkbesn5NrIZIcobuTIxPFLP7Gxe0Lr5P
+-----END PRIVATE KEY-----
diff --git a/conformance/test-credentials/c2pa-test-claim-signer.pem b/conformance/test-credentials/c2pa-test-claim-signer.pem
new file mode 100644
index 0000000..6332d99
--- /dev/null
+++ b/conformance/test-credentials/c2pa-test-claim-signer.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC+DCCAp6gAwIBAgIUeqhNn1NmjU3XxuBWzA2aJHwfkUkwCgYIKoZIzj0EAwIw
+UTELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczErMCkGA1UEAwwi
+QTEwY2l0eSBDMlBBIFRlc3QgQ2xhaW0gU2lnbmluZyBDQTAeFw0yNjA4MjYwODMw
+MTJaFw0yNzA4MjcwODMwMTJaMEMxCzAJBgNVBAYTAklOMRUwEwYDVQQKDAxBMTBj
+aXR5IExhYnMxHTAbBgNVBAMMFEExMGNpdHkgSW1hZ2UgRWRpdG9yMFkwEwYHKoZI
+zj0CAQYIKoZIzj0DAQcDQgAEy/IwCxCkupdLyc/XBDPNkbZWdHGbOR1V05fsWELp
+ccVx/6MLovMu35pG3rJ+TayGSHKG7kyMTxSz+xsXtC6+T6OCAWAwggFcMAwGA1Ud
+EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgbAMB8GA1UdJQQYMBYGCisGAQQBg+heAgEG
+CCsGAQUFBwMEMB0GA1UdDgQWBBR5jkcLlIqP4oqaP1GOMToVj4eIyjAfBgNVHSME
+GDAWgBR+cUbBZt9THA8zTwFrJqSOr9IEeDAXBgNVHSAEEDAOMAwGCisGAQQBg+he
+AQEwcgYIKwYBBQUHAQEEZjBkMCkGCCsGAQUFBzABhh1odHRwOi8vb2NzcC50ZXN0
+LmludmFsaWQvYzJwYTA3BggrBgEFBQcwAoYraHR0cDovL3BraS50ZXN0LmludmFs
+aWQvYzJwYS1pc3N1aW5nLWNhLmRlcjAZBgkrBgEEAYPoXgMEDAYKKwYBBAGD6F4D
+CjAzBgkrBgEEAYPoXgQEJgwkMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAw
+MDAwMDAwMAoGCCqGSM49BAMCA0gAMEUCIGftLOJ6IYYN6/OKdz7hZ24uBjS+hFiQ
+6m759lh8aU1sAiEAy3dhqnLAZ85O+Y+llWdOBcoSNjz9e/he/LHyJk4S2XQ=
+-----END CERTIFICATE-----
diff --git a/conformance/test-credentials/c2pa-test-issuing-ca.pem b/conformance/test-credentials/c2pa-test-issuing-ca.pem
new file mode 100644
index 0000000..f55dea2
--- /dev/null
+++ b/conformance/test-credentials/c2pa-test-issuing-ca.pem
@@ -0,0 +1,15 @@
+-----BEGIN CERTIFICATE-----
+MIICPTCCAeOgAwIBAgIUEhe0WBBNDlCHzIFGfjj41IG9SsgwCgYIKoZIzj0EAwIw
+SDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEiMCAGA1UEAwwZ
+QTEwY2l0eSBDMlBBIFRlc3QgUm9vdCBDQTAeFw0yNjA4MjYwODMwMTJaFw0zNjA4
+MjMwODMwMTJaMFExCzAJBgNVBAYTAklOMRUwEwYDVQQKDAxBMTBjaXR5IExhYnMx
+KzApBgNVBAMMIkExMGNpdHkgQzJQQSBUZXN0IENsYWltIFNpZ25pbmcgQ0EwWTAT
+BgcqhkjOPQIBBggqhkjOPQMBBwNCAATxSCV/UMAWYYjNMc9CR+RHQNRUanVGd5hp
+ekvg0MdlwKScHk1Sw07LPxhciKtnr1katpQQrnqD0vGzlaGqvwg7o4GhMIGeMBIG
+A1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBR+cUbB
+Zt9THA8zTwFrJqSOr9IEeDAfBgNVHSMEGDAWgBQCWMiC6DiYVpUglVVGCkukFF8b
+5TAXBgNVHSAEEDAOMAwGCisGAQQBg+heAQEwHwYDVR0lBBgwFgYKKwYBBAGD6F4C
+AQYIKwYBBQUHAwQwCgYIKoZIzj0EAwIDSAAwRQIhAPrjnpdotM3HfWb8H8bMnT2R
+I4QQuQXhmDU4Y/w62F7yAiBhzrrmFMdBngW3MFH0YSLsel//Ucut/cIi116JzL0Y
+hQ==
+-----END CERTIFICATE-----
diff --git a/conformance/test-credentials/c2pa-test-root-ca.pem b/conformance/test-credentials/c2pa-test-root-ca.pem
new file mode 100644
index 0000000..446af08
--- /dev/null
+++ b/conformance/test-credentials/c2pa-test-root-ca.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB7DCCAZOgAwIBAgIUJdPldv1Ji9ohLFKIOY4GpdIx74UwCgYIKoZIzj0EAwIw
+SDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEiMCAGA1UEAwwZ
+QTEwY2l0eSBDMlBBIFRlc3QgUm9vdCBDQTAeFw0yNjA4MjYwODMwMTJaFw0zNjA4
+MjMwODMwMTJaMEgxCzAJBgNVBAYTAklOMRUwEwYDVQQKDAxBMTBjaXR5IExhYnMx
+IjAgBgNVBAMMGUExMGNpdHkgQzJQQSBUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIB
+BggqhkjOPQMBBwNCAAR8JZKqivcZSRRISbOYiLxU3q5ZZWlX7Zga+DO1269vCVwj
+XbQ0kegFNGNOS9p7JMkyTsprcDE93VYAcxqJlUuSo1swWTAPBgNVHRMBAf8EBTAD
+AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUAljIgug4mFaVIJVVRgpLpBRf
+G+UwFwYDVR0gBBAwDjAMBgorBgEEAYPoXgEBMAoGCCqGSM49BAMCA0cAMEQCIHdQ
+mIC1dflt6XynRBTdTmDdx9Z9g2XpJJ4hO72VMciMAiBMKOcMVBoOmyQwoZXBVBhd
+YAj/FxhDz2YWqunbPCU76w==
+-----END CERTIFICATE-----
diff --git a/conformance/test-credentials/c2pa-test-trust-list.pem b/conformance/test-credentials/c2pa-test-trust-list.pem
new file mode 100644
index 0000000..446af08
--- /dev/null
+++ b/conformance/test-credentials/c2pa-test-trust-list.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB7DCCAZOgAwIBAgIUJdPldv1Ji9ohLFKIOY4GpdIx74UwCgYIKoZIzj0EAwIw
+SDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEiMCAGA1UEAwwZ
+QTEwY2l0eSBDMlBBIFRlc3QgUm9vdCBDQTAeFw0yNjA4MjYwODMwMTJaFw0zNjA4
+MjMwODMwMTJaMEgxCzAJBgNVBAYTAklOMRUwEwYDVQQKDAxBMTBjaXR5IExhYnMx
+IjAgBgNVBAMMGUExMGNpdHkgQzJQQSBUZXN0IFJvb3QgQ0EwWTATBgcqhkjOPQIB
+BggqhkjOPQMBBwNCAAR8JZKqivcZSRRISbOYiLxU3q5ZZWlX7Zga+DO1269vCVwj
+XbQ0kegFNGNOS9p7JMkyTsprcDE93VYAcxqJlUuSo1swWTAPBgNVHRMBAf8EBTAD
+AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUAljIgug4mFaVIJVVRgpLpBRf
+G+UwFwYDVR0gBBAwDjAMBgorBgEEAYPoXgEBMAoGCCqGSM49BAMCA0cAMEQCIHdQ
+mIC1dflt6XynRBTdTmDdx9Z9g2XpJJ4hO72VMciMAiBMKOcMVBoOmyQwoZXBVBhd
+YAj/FxhDz2YWqunbPCU76w==
+-----END CERTIFICATE-----
diff --git a/conformance/test-credentials/c2pa-test-tsa-trust-list.pem b/conformance/test-credentials/c2pa-test-tsa-trust-list.pem
new file mode 100644
index 0000000..50f453c
--- /dev/null
+++ b/conformance/test-credentials/c2pa-test-tsa-trust-list.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB3zCCAYWgAwIBAgIUV8+tPnMJrmoueRoYrQZ6zAa+rxgwCgYIKoZIzj0EAwIw
+TDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEmMCQGA1UEAwwd
+QTEwY2l0eSBDMlBBIFRlc3QgVFNBIFJvb3QgQ0EwHhcNMjYwODI2MDgzMDEyWhcN
+MzYwODIzMDgzMDEyWjBMMQswCQYDVQQGEwJJTjEVMBMGA1UECgwMQTEwY2l0eSBM
+YWJzMSYwJAYDVQQDDB1BMTBjaXR5IEMyUEEgVGVzdCBUU0EgUm9vdCBDQTBZMBMG
+ByqGSM49AgEGCCqGSM49AwEHA0IABGILQzx+f/dQ3jPRSopcz7r8cl/ZFR97Bkam
++unjAZMNLT54ny/Httt4D5E+hwoRUL3sbf6onb4Bt7LYHDg6ZnWjRTBDMBIGA1Ud
+EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRvj7p7OsHG
+1a/B+pSjZ+NmGCrUSzAKBggqhkjOPQQDAgNIADBFAiBc5GBKKOM4KWL9kgfMEZUW
+JvqJdLzAMFrfN4n1XDAtZQIhAJFx8WR27GxFzJ6A70fmtjTngHaXswfI03nnlSol
++j9S
+-----END CERTIFICATE-----
diff --git a/conformance/test-credentials/generate.sh b/conformance/test-credentials/generate.sh
new file mode 100755
index 0000000..9da09cb
--- /dev/null
+++ b/conformance/test-credentials/generate.sh
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+#
+# Generate the *test* PKI the conformance harness and the local claim-signer
+# run against.
+#
+# Nothing here is a production credential and nothing here is trusted by any
+# validator outside this repository. Production claim signing certificates come
+# from a Certification Authority on the C2PA Trust List — see
+# `conformance/enrolment-runbook.md`. What this script exists for is to produce
+# certificates that are shaped *exactly* like the ones a CA will issue, so that
+# every code path the real certificate will exercise is exercised in CI too:
+# the c2pa-kp-claimSigning EKU, the assurance-level extension, the CPL record
+# id, the 366-day ceiling, and a time-stamping authority to test against.
+#
+# Profiles implemented, from the C2PA Certificate Policy v0.2, "Certificate
+# Profiles":
+#
+# * C2PA Claim Signing Root CA
+# * C2PA Claim Signing Issuing CA
+# * C2PA Claim Signing Leaf — Assurance Level 1
+# * A time-stamping authority chain, for the TSA Trust List
+#
+# Usage: ./generate.sh [output-dir]
+set -euo pipefail
+
+out="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
+mkdir -p "$out"
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+
+# --- OIDs from the C2PA private arc (1.3.6.1.4.1.62558) ----------------------
+OID_CP="1.3.6.1.4.1.62558.1.1" # c2pa-certificate-policy
+OID_EKU_CLAIM="1.3.6.1.4.1.62558.2.1" # c2pa-kp-claimSigning
+OID_AL="1.3.6.1.4.1.62558.3" # id-c2pa-al
+OID_AL1="1.3.6.1.4.1.62558.3.10" # c2pa-assuranceLevel-1
+OID_CPL="1.3.6.1.4.1.62558.4" # c2pa-cpl-record
+
+# The Conforming Products List record id is a UUID the Conformance Program
+# assigns when the product is listed. Until this product is listed there is no
+# real one, so the test chain carries the nil UUID: it is the same shape and
+# obviously not a real record.
+CPL_RECORD_ID="${C2PA_CPL_RECORD_ID:-00000000-0000-0000-0000-000000000000}"
+
+# Assurance Level 1 caps leaf validity at 366 days. Using the real ceiling in
+# the test PKI is deliberate: it means the expiry handling, and the time-stamp
+# that has to outlive the certificate, are exercised rather than postponed by a
+# twenty-year certificate that hides both.
+LEAF_DAYS=366
+CA_DAYS=3650
+
+echo "==> C2PA claim signing root CA"
+cat > "$work/root.cnf" < C2PA claim signing issuing CA"
+cat > "$work/issuing.cnf" < C2PA claim signing leaf, assurance level 1"
+# Subject must match the Conforming Products List entry for the product. The
+# claim generator reads O and CN back out of this certificate and shows them,
+# so what goes in here is what a viewer sees.
+cat > "$work/leaf.cnf" < "$out/c2pa-test-claim-signer-chain.pem"
+
+echo "==> time-stamping authority"
+cat > "$work/tsa-root.cnf" < "$work/tsa.cnf" < "$out/c2pa-test-trust-list.pem"
+cat "$out/tsa-test-root-ca.pem" > "$out/c2pa-test-tsa-trust-list.pem"
+
+echo "==> verify"
+openssl verify -CAfile "$out/c2pa-test-root-ca.pem" \
+ -untrusted "$out/c2pa-test-issuing-ca.pem" "$out/c2pa-test-claim-signer.pem"
+openssl verify -CAfile "$out/tsa-test-root-ca.pem" "$out/tsa-test-signer.pem"
+
+echo "==> claim signing leaf extensions"
+openssl x509 -in "$out/c2pa-test-claim-signer.pem" -noout -text \
+ | sed -n '/X509v3 extensions/,/Signature Algorithm/p'
+
+rm -f "$out"/*.srl
+echo "==> wrote the test PKI into $out"
diff --git a/conformance/test-credentials/tsa-test-root-ca.pem b/conformance/test-credentials/tsa-test-root-ca.pem
new file mode 100644
index 0000000..50f453c
--- /dev/null
+++ b/conformance/test-credentials/tsa-test-root-ca.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB3zCCAYWgAwIBAgIUV8+tPnMJrmoueRoYrQZ6zAa+rxgwCgYIKoZIzj0EAwIw
+TDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEmMCQGA1UEAwwd
+QTEwY2l0eSBDMlBBIFRlc3QgVFNBIFJvb3QgQ0EwHhcNMjYwODI2MDgzMDEyWhcN
+MzYwODIzMDgzMDEyWjBMMQswCQYDVQQGEwJJTjEVMBMGA1UECgwMQTEwY2l0eSBM
+YWJzMSYwJAYDVQQDDB1BMTBjaXR5IEMyUEEgVGVzdCBUU0EgUm9vdCBDQTBZMBMG
+ByqGSM49AgEGCCqGSM49AwEHA0IABGILQzx+f/dQ3jPRSopcz7r8cl/ZFR97Bkam
++unjAZMNLT54ny/Httt4D5E+hwoRUL3sbf6onb4Bt7LYHDg6ZnWjRTBDMBIGA1Ud
+EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRvj7p7OsHG
+1a/B+pSjZ+NmGCrUSzAKBggqhkjOPQQDAgNIADBFAiBc5GBKKOM4KWL9kgfMEZUW
+JvqJdLzAMFrfN4n1XDAtZQIhAJFx8WR27GxFzJ6A70fmtjTngHaXswfI03nnlSol
++j9S
+-----END CERTIFICATE-----
diff --git a/conformance/test-credentials/tsa-test-signer.key b/conformance/test-credentials/tsa-test-signer.key
new file mode 100644
index 0000000..ba1d10d
--- /dev/null
+++ b/conformance/test-credentials/tsa-test-signer.key
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgmooJPGOcFCua3p7t
+2eL3tHtKotKL73olWDxuuNhkdlihRANCAARuPC0L9VkpGX2FlmIP6pmHZsRdqi5l
+V38pVOGDIAgzYzecSvwbbdFjOjmXLGL37RiKUAhQI+7aTkzgtuHHvmdH
+-----END PRIVATE KEY-----
diff --git a/conformance/test-credentials/tsa-test-signer.pem b/conformance/test-credentials/tsa-test-signer.pem
new file mode 100644
index 0000000..93dcdb1
--- /dev/null
+++ b/conformance/test-credentials/tsa-test-signer.pem
@@ -0,0 +1,14 @@
+-----BEGIN CERTIFICATE-----
+MIICGjCCAcCgAwIBAgIUcD53Nebiz+r7qSDZbVTUenHZKtowCgYIKoZIzj0EAwIw
+TDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEmMCQGA1UEAwwd
+QTEwY2l0eSBDMlBBIFRlc3QgVFNBIFJvb3QgQ0EwHhcNMjYwODI2MDgzMDEyWhcN
+MzYwODIzMDgzMDEyWjBUMQswCQYDVQQGEwJJTjEVMBMGA1UECgwMQTEwY2l0eSBM
+YWJzMS4wLAYDVQQDDCVBMTBjaXR5IEMyUEEgVGVzdCBUaW1lc3RhbXAgQXV0aG9y
+aXR5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbjwtC/VZKRl9hZZiD+qZh2bE
+XaouZVd/KVThgyAIM2M3nEr8G23RYzo5lyxi9+0YilAIUCPu2k5M4Lbhx75nR6N4
+MHYwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBsAwFgYDVR0lAQH/BAwwCgYI
+KwYBBQUHAwgwHQYDVR0OBBYEFLtz5srOnMZUNTB0585Rbn4UB2udMB8GA1UdIwQY
+MBaAFG+Puns6wcbVr8H6lKNn42YYKtRLMAoGCCqGSM49BAMCA0gAMEUCIA19+fGp
+W546GQdBzbXVNTO5WDfpAr5KsWTp8Ikbjx3fAiEAzyigpyOwzatU8gxXPPD4JwlO
+xUMvL4b3/uw7RNKYV7s=
+-----END CERTIFICATE-----
diff --git a/conformance/vulnerability-ledger.json b/conformance/vulnerability-ledger.json
new file mode 100644
index 0000000..59c0e31
--- /dev/null
+++ b/conformance/vulnerability-ledger.json
@@ -0,0 +1,4 @@
+{
+ "_comment": "Findings the supply-chain gate has seen, and when. See conformance/scripts/gate.py. A CRITICAL or HIGH entry whose firstSeen is more than 90 days old fails the build; that window comes from C2PA Generator Product Security Requirements O.3 and O.4 at Assurance Level 1. Entries are never deleted - a resolved one is evidence of how long the fix took.",
+ "findings": {}
+}
diff --git a/crates/c2pa-harness/Cargo.toml b/crates/c2pa-harness/Cargo.toml
new file mode 100644
index 0000000..ee4d3bd
--- /dev/null
+++ b/crates/c2pa-harness/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "c2pa-harness"
+version = "0.1.0"
+edition = "2021"
+description = "Conformance test harness: validates C2PA assets and emits crJSON"
+license = "MIT"
+publish = false
+
+[[bin]]
+name = "c2pa-harness"
+path = "src/main.rs"
+
+[dependencies]
+# The harness is a front end over the product's own validator, not a second
+# implementation: whatever it reports, the browser reports. `test-pki` brings in
+# the test certificates so the end-to-end tests can produce assets to validate;
+# nothing in `main.rs` touches it.
+imagecore = { path = "../imagecore", default-features = false, features = ["test-pki"] }
+serde_json = "1"
+
+[dev-dependencies]
+image = { version = "0.25", default-features = false, features = ["jpeg"] }
diff --git a/crates/c2pa-harness/src/main.rs b/crates/c2pa-harness/src/main.rs
new file mode 100644
index 0000000..82bd316
--- /dev/null
+++ b/crates/c2pa-harness/src/main.rs
@@ -0,0 +1,281 @@
+//! The conformance test harness.
+//!
+//! The C2PA Conformance Program requires any applicant whose product validates
+//! manifests to run a harness over assets the Program supplies and hand back
+//! the results in crJSON. From *Additional Conformance Requirements* v0.2, the
+//! harness "SHALL accept the following inputs":
+//!
+//! 1. an asset to validate
+//! 2. a (test) C2PA Trust List
+//! 3. a (test) C2PA TSA Trust List
+//! 4. a validation time (RFC 3339)
+//!
+//! Those are exactly the four flags below, and they map one-to-one onto
+//! `imagecore::c2pa::ValidationOptions`. That matters more than the command
+//! line does: this binary is a front end over the same validator the browser
+//! runs, not a second implementation that could quietly disagree with it. If
+//! the harness says a manifest is trusted, the editor says so too, because it
+//! is the same function.
+//!
+//! ```text
+//! c2pa-harness validate \
+//! --asset signed.jpg \
+//! --trust-list c2pa-trust-list.pem \
+//! --tsa-trust-list c2pa-tsa-trust-list.pem \
+//! --validation-time 2026-03-10T12:34:56Z \
+//! --output signed.crjson
+//! ```
+//!
+//! Exit status is 0 when the asset validated, 1 when it did not, and 2 when the
+//! harness could not run at all. A validation failure is a result, not an
+//! error: the Program's asset library is full of assets that are *meant* to
+//! fail, and the crJSON says which check caught them.
+
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+
+use imagecore::c2pa::{self, clock, TrustStore, ValidationOptions};
+
+const USAGE: &str = "\
+c2pa-harness — validate a C2PA asset and report the result in crJSON
+
+USAGE:
+ c2pa-harness validate --asset [options]
+ c2pa-harness batch --asset-dir --output-dir [options]
+
+OPTIONS:
+ --asset the asset to validate
+ --asset-dir (batch) every *.jpg / *.jpeg in this directory
+ --trust-list PEM bundle of C2PA trust anchors
+ --tsa-trust-list PEM bundle of TSA trust anchors
+ --validation-time the instant to judge certificate validity at
+ --output write crJSON here instead of standard output
+ --output-dir (batch) write one .crjson per asset here
+ --summary also print a one-line human summary to stderr
+ -h, --help show this text
+
+EXIT STATUS:
+ 0 the asset validated
+ 1 the asset did not validate
+ 2 the harness could not run
+";
+
+fn main() -> ExitCode {
+ match run() {
+ Ok(true) => ExitCode::from(0),
+ Ok(false) => ExitCode::from(1),
+ Err(message) => {
+ eprintln!("c2pa-harness: {message}");
+ ExitCode::from(2)
+ }
+ }
+}
+
+#[derive(Default)]
+struct Args {
+ command: String,
+ asset: Option,
+ asset_dir: Option,
+ trust_list: Option,
+ tsa_trust_list: Option,
+ validation_time: Option,
+ output: Option,
+ output_dir: Option,
+ summary: bool,
+}
+
+fn run() -> Result {
+ let args = parse_args()?;
+
+ match args.command.as_str() {
+ "validate" => validate_one(&args),
+ "batch" => validate_batch(&args),
+ other => Err(format!("unknown command '{other}'\n\n{USAGE}")),
+ }
+}
+
+fn parse_args() -> Result {
+ let mut raw = std::env::args().skip(1);
+ let mut args = Args::default();
+
+ let Some(command) = raw.next() else {
+ return Err(format!("no command given\n\n{USAGE}"));
+ };
+ if command == "-h" || command == "--help" {
+ println!("{USAGE}");
+ std::process::exit(0);
+ }
+ args.command = command;
+
+ while let Some(flag) = raw.next() {
+ let mut value = || raw.next().ok_or_else(|| format!("{flag} needs a value"));
+ match flag.as_str() {
+ "--asset" => args.asset = Some(PathBuf::from(value()?)),
+ "--asset-dir" => args.asset_dir = Some(PathBuf::from(value()?)),
+ "--trust-list" => args.trust_list = Some(PathBuf::from(value()?)),
+ "--tsa-trust-list" => args.tsa_trust_list = Some(PathBuf::from(value()?)),
+ "--validation-time" => args.validation_time = Some(value()?),
+ "--output" => args.output = Some(PathBuf::from(value()?)),
+ "--output-dir" => args.output_dir = Some(PathBuf::from(value()?)),
+ "--summary" => args.summary = true,
+ "-h" | "--help" => {
+ println!("{USAGE}");
+ std::process::exit(0);
+ }
+ other => return Err(format!("unknown option '{other}'\n\n{USAGE}")),
+ }
+ }
+
+ Ok(args)
+}
+
+/// Turn the four required inputs into the options the validator takes.
+fn options_from(args: &Args) -> Result {
+ let load = |path: &Option, what: &str| -> Result {
+ let Some(path) = path else {
+ return Ok(TrustStore::empty());
+ };
+ let pem = std::fs::read_to_string(path)
+ .map_err(|e| format!("reading the {what} at {}: {e}", path.display()))?;
+ let (store, skipped) = TrustStore::from_pem(&pem)
+ .map_err(|e| format!("parsing the {what} at {}: {e}", path.display()))?;
+ if skipped > 0 {
+ // Worth saying out loud: a trust list that half-loaded would
+ // otherwise produce a mysteriously untrusted result.
+ eprintln!(
+ "c2pa-harness: {skipped} entr{} in the {what} could not be parsed and {} skipped",
+ if skipped == 1 { "y" } else { "ies" },
+ if skipped == 1 { "was" } else { "were" },
+ );
+ }
+ Ok(store)
+ };
+
+ // The validation time is required in substance even though the flag is
+ // optional: without one there is no defensible answer to "was this
+ // certificate valid?". Defaulting to the epoch would make everything read
+ // as not-yet-valid, so say so instead.
+ let validation_time =
+ match &args.validation_time {
+ Some(text) => clock::parse_rfc3339(text)
+ .ok_or_else(|| format!("'{text}' is not an RFC 3339 date-time"))?,
+ None => return Err(
+ "--validation-time is required; the Conformance Program supplies one with each \
+ test asset"
+ .into(),
+ ),
+ };
+
+ Ok(ValidationOptions {
+ trust: load(&args.trust_list, "trust list")?,
+ tsa_trust: load(&args.tsa_trust_list, "TSA trust list")?,
+ validation_time,
+ })
+}
+
+fn validate_one(args: &Args) -> Result {
+ let Some(asset) = &args.asset else {
+ return Err(format!("validate needs --asset\n\n{USAGE}"));
+ };
+ let options = options_from(args)?;
+ let (document, valid, summary) = validate_file(asset, &options)?;
+
+ let rendered =
+ serde_json::to_string_pretty(&document).map_err(|e| format!("serialising crJSON: {e}"))?;
+ match &args.output {
+ Some(path) => std::fs::write(path, format!("{rendered}\n"))
+ .map_err(|e| format!("writing {}: {e}", path.display()))?,
+ None => println!("{rendered}"),
+ }
+ if args.summary {
+ eprintln!("{summary}");
+ }
+
+ Ok(valid)
+}
+
+fn validate_batch(args: &Args) -> Result {
+ let Some(dir) = &args.asset_dir else {
+ return Err(format!("batch needs --asset-dir\n\n{USAGE}"));
+ };
+ let Some(out_dir) = &args.output_dir else {
+ return Err(format!("batch needs --output-dir\n\n{USAGE}"));
+ };
+ let options = options_from(args)?;
+ std::fs::create_dir_all(out_dir).map_err(|e| format!("creating {}: {e}", out_dir.display()))?;
+
+ let mut assets: Vec = std::fs::read_dir(dir)
+ .map_err(|e| format!("reading {}: {e}", dir.display()))?
+ .filter_map(Result::ok)
+ .map(|entry| entry.path())
+ .filter(|path| {
+ path.extension()
+ .and_then(|e| e.to_str())
+ .is_some_and(|e| e.eq_ignore_ascii_case("jpg") || e.eq_ignore_ascii_case("jpeg"))
+ })
+ .collect();
+ assets.sort();
+
+ if assets.is_empty() {
+ return Err(format!("no JPEG assets found in {}", dir.display()));
+ }
+
+ let mut all_valid = true;
+ for asset in &assets {
+ let stem = asset.file_stem().unwrap_or_default().to_string_lossy();
+ match validate_file(asset, &options) {
+ Ok((document, valid, summary)) => {
+ all_valid &= valid;
+ let path = out_dir.join(format!("{stem}.crjson"));
+ let rendered = serde_json::to_string_pretty(&document)
+ .map_err(|e| format!("serialising crJSON for {stem}: {e}"))?;
+ std::fs::write(&path, format!("{rendered}\n"))
+ .map_err(|e| format!("writing {}: {e}", path.display()))?;
+ eprintln!("{summary}");
+ }
+ Err(why) => {
+ // One unreadable asset should not abandon the batch: the
+ // Program's library deliberately includes broken files.
+ all_valid = false;
+ eprintln!("{stem}: could not be validated — {why}");
+ }
+ }
+ }
+
+ Ok(all_valid)
+}
+
+fn validate_file(
+ path: &Path,
+ options: &ValidationOptions,
+) -> Result<(serde_json::Value, bool, String), String> {
+ let bytes = std::fs::read(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
+ let name = path.file_name().unwrap_or_default().to_string_lossy();
+
+ let Some(report) = c2pa::validate_jpeg(&bytes, options)? else {
+ return Err("the asset carries no Content Credentials".into());
+ };
+
+ let failures = report.active.status.failure.len();
+ let summary = format!(
+ "{name}: {} — {} success, {} informational, {failures} failure{}{}",
+ if report.is_valid() {
+ "valid"
+ } else {
+ "INVALID"
+ },
+ report.active.status.success.len(),
+ report.active.status.informational.len(),
+ if failures == 1 { "" } else { "s" },
+ if report.active.signature.trusted {
+ format!(
+ ", signer trusted via {}",
+ report.active.signature.trust_anchor
+ )
+ } else {
+ String::new()
+ },
+ );
+
+ Ok((c2pa::to_crjson(&report), report.is_valid(), summary))
+}
diff --git a/crates/c2pa-harness/tests/harness.rs b/crates/c2pa-harness/tests/harness.rs
new file mode 100644
index 0000000..2620328
--- /dev/null
+++ b/crates/c2pa-harness/tests/harness.rs
@@ -0,0 +1,408 @@
+//! End-to-end tests for the conformance harness.
+//!
+//! These run the actual binary the way the Conformance Program will: four
+//! inputs in, crJSON out, exit status saying whether the asset validated. What
+//! is being checked is not the argument parsing so much as the shape of the
+//! document — a crJSON that is subtly wrong is worse than no crJSON, because it
+//! looks like evidence.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use imagecore::c2pa::{self, manifest, testpki, SignRequest};
+use serde_json::Value as Json;
+
+const HARNESS: &str = env!("CARGO_BIN_EXE_c2pa-harness");
+
+fn jpeg(width: u32, height: u32) -> Vec {
+ use image::{ImageFormat, Rgb, RgbImage};
+ let image = RgbImage::from_fn(width, height, |x, y| {
+ Rgb([(x * 7 % 256) as u8, (y * 11 % 256) as u8, 128])
+ });
+ let mut bytes = Vec::new();
+ image
+ .write_to(&mut std::io::Cursor::new(&mut bytes), ImageFormat::Jpeg)
+ .expect("encoding a JPEG");
+ bytes
+}
+
+/// A signed asset, produced the way the editor produces one.
+fn signed_asset(title: &str, timestamped: bool) -> Vec {
+ let identity = if timestamped {
+ testpki::identity()
+ } else {
+ testpki::identity_without_timestamps()
+ };
+ let request = SignRequest {
+ title: title.to_string(),
+ generator: c2pa::generator(),
+ now: "2026-08-25T12:00:00Z".to_string(),
+ instance_id: "xmp:iid:11111111-2222-3333-4444-555555555555".to_string(),
+ manifest_id: "urn:c2pa:AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE".to_string(),
+ actions: c2pa::actions_for(&Default::default(), true, (200, 150)),
+ parent: Some(manifest::Parent {
+ title: "original.jpg".into(),
+ format: "image/jpeg".into(),
+ instance_id: "xmp:iid:original".into(),
+ store: None,
+ }),
+ thumbnail: None,
+ };
+
+ let prepared = manifest::prepare(&jpeg(200, 150), request, identity).unwrap();
+ let signature = testpki::sign_es256(&prepared.to_be_signed);
+ let token =
+ timestamped.then(|| testpki::issue_timestamp(&signature, testpki::validation_time()));
+ manifest::complete(&prepared, &signature, token.as_deref())
+ .unwrap()
+ .jpeg
+}
+
+struct Fixture {
+ dir: PathBuf,
+}
+
+impl Fixture {
+ fn new(name: &str) -> Self {
+ let dir = std::env::temp_dir().join(format!("c2pa-harness-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ Fixture { dir }
+ }
+
+ fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
+ let path = self.dir.join(name);
+ std::fs::write(&path, bytes).unwrap();
+ path
+ }
+
+ fn credentials(&self, name: &str) -> PathBuf {
+ testpki::directory().join(name)
+ }
+}
+
+impl Drop for Fixture {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.dir);
+ }
+}
+
+struct Run {
+ status: i32,
+ stdout: String,
+ stderr: String,
+}
+
+impl Run {
+ fn json(&self) -> Json {
+ serde_json::from_str(&self.stdout)
+ .unwrap_or_else(|e| panic!("the harness should print crJSON: {e}\n{}", self.stdout))
+ }
+
+ /// The status codes in one of the three result groups of the active
+ /// manifest, which is `manifests[0]` in crJSON's reversed order.
+ fn status_codes(&self, group: &str) -> Vec {
+ self.json()["manifests"][0]["validationResults"][group]
+ .as_array()
+ .unwrap_or_else(|| panic!("expected a {group} array"))
+ .iter()
+ .filter_map(|entry| entry["code"].as_str().map(str::to_string))
+ .collect()
+ }
+}
+
+fn harness(args: &[&std::ffi::OsStr]) -> Run {
+ let output = Command::new(HARNESS)
+ .args(args)
+ .output()
+ .expect("running the harness");
+ Run {
+ status: output.status.code().unwrap_or(-1),
+ stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
+ stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
+ }
+}
+
+/// The four inputs the Conformance Program specifies, as command-line flags.
+fn conformance_args(asset: &Path, fixture: &Fixture) -> Vec {
+ vec![
+ "validate".into(),
+ "--asset".into(),
+ asset.to_path_buf().into(),
+ "--trust-list".into(),
+ fixture.credentials("c2pa-test-trust-list.pem").into(),
+ "--tsa-trust-list".into(),
+ fixture.credentials("c2pa-test-tsa-trust-list.pem").into(),
+ "--validation-time".into(),
+ c2pa::clock::to_rfc3339(testpki::validation_time()).into(),
+ ]
+}
+
+fn run_conformance(asset: &Path, fixture: &Fixture) -> Run {
+ let owned = conformance_args(asset, fixture);
+ let refs: Vec<&std::ffi::OsStr> = owned.iter().map(|s| s.as_os_str()).collect();
+ harness(&refs)
+}
+
+#[test]
+fn a_valid_asset_produces_crjson_and_exits_zero() {
+ let fixture = Fixture::new("valid");
+ let asset = fixture.write("signed.jpg", &signed_asset("holiday.jpg", true));
+ let run = run_conformance(&asset, &fixture);
+
+ assert_eq!(run.status, 0, "stderr: {}", run.stderr);
+ let document = run.json();
+
+ // Section 3.1: the three required top-level properties.
+ assert!(document.get("@context").is_some());
+ assert!(document.get("jsonGenerator").is_some());
+ let manifests = document["manifests"].as_array().expect("manifests array");
+ assert_eq!(manifests.len(), 1);
+
+ // Section 3.3: name and a SemVer version.
+ let generator = &document["jsonGenerator"];
+ assert!(generator["name"].as_str().is_some_and(|n| !n.is_empty()));
+ assert!(generator["version"]
+ .as_str()
+ .is_some_and(|v| v.split('.').count() == 3));
+
+ let manifest = &manifests[0];
+ assert!(manifest["label"].as_str().unwrap().starts_with("urn:c2pa:"));
+ assert!(manifest.get("claim.v2").is_some(), "a v2 claim is written");
+ assert!(manifest.get("assertions").is_some());
+ assert!(manifest.get("signature").is_some());
+ assert!(manifest.get("validationResults").is_some());
+}
+
+#[test]
+fn the_claim_carries_both_assertion_lists_even_when_empty() {
+ // Section 3.5.1 requires them present, and a validator comparing two
+ // implementations will notice their absence before anything else.
+ let fixture = Fixture::new("lists");
+ let asset = fixture.write("signed.jpg", &signed_asset("photo.jpg", false));
+ let claim = run_conformance(&asset, &fixture).json()["manifests"][0]["claim.v2"].clone();
+
+ assert_eq!(claim["gathered_assertions"], serde_json::json!([]));
+ assert_eq!(claim["redacted_assertions"], serde_json::json!([]));
+ assert!(claim["created_assertions"].as_array().unwrap().len() >= 2);
+}
+
+#[test]
+fn byte_strings_are_base64_with_the_b64_prefix() {
+ let fixture = Fixture::new("b64");
+ let asset = fixture.write("signed.jpg", &signed_asset("photo.jpg", false));
+ let document = run_conformance(&asset, &fixture).json();
+
+ let hash = document["manifests"][0]["assertions"]["c2pa.hash.data"]["hash"]
+ .as_str()
+ .expect("the data hash should be present");
+ assert!(hash.starts_with("b64'"), "got {hash}");
+ // 32 bytes of SHA-256 is 44 Base64 characters, plus the four-character
+ // prefix.
+ assert_eq!(hash.len(), 4 + 44, "got {hash}");
+}
+
+#[test]
+fn the_validation_results_carry_the_time_they_were_produced_at() {
+ let fixture = Fixture::new("time");
+ let asset = fixture.write("signed.jpg", &signed_asset("photo.jpg", false));
+ let document = run_conformance(&asset, &fixture).json();
+
+ let results = &document["manifests"][0]["validationResults"];
+ let at = results["validationTime"].as_str().expect("validationTime");
+ assert_eq!(at, c2pa::clock::to_rfc3339(testpki::validation_time()));
+ assert!(results["success"].is_array());
+ assert!(results["informational"].is_array());
+ assert!(results["failure"].is_array());
+}
+
+#[test]
+fn a_trusted_signer_is_reported_with_its_certificate_details() {
+ let fixture = Fixture::new("signer");
+ let asset = fixture.write("signed.jpg", &signed_asset("photo.jpg", true));
+ let run = run_conformance(&asset, &fixture);
+ let document = run.json();
+
+ let signature = &document["manifests"][0]["signature"];
+ assert_eq!(signature["algorithm"], "ES256");
+
+ // Section 3.7's required certificateInfo fields.
+ let info = &signature["certificateInfo"];
+ assert!(info["serialNumber"].as_str().is_some());
+ assert_eq!(info["subject"]["CN"], "A10city Image Editor");
+ assert_eq!(info["subject"]["O"], "A10city Labs");
+ assert!(info["issuer"]["CN"]
+ .as_str()
+ .unwrap()
+ .contains("Claim Signing CA"));
+ assert!(info["validity"]["notBefore"]
+ .as_str()
+ .unwrap()
+ .ends_with('Z'));
+ assert!(info["validity"]["notAfter"]
+ .as_str()
+ .unwrap()
+ .ends_with('Z'));
+
+ // The conformance facts, from the C2PA Certificate Policy extensions.
+ assert_eq!(info["extras:c2pa"]["assuranceLevel"], 1);
+ assert_eq!(
+ info["extras:c2pa"]["cplRecordId"],
+ "00000000-0000-0000-0000-000000000000"
+ );
+
+ // And the time-stamp, with the authority's own certificate.
+ let timestamp = &signature["timestampInfo"];
+ assert!(timestamp["timestamp"].as_str().unwrap().ends_with('Z'));
+ assert!(timestamp["certificateInfo"]["subject"]["CN"]
+ .as_str()
+ .unwrap()
+ .contains("Timestamp Authority"));
+
+ let codes = run.status_codes("success");
+ assert!(
+ codes.iter().any(|c| c == "signingCredential.trusted"),
+ "{codes:?}"
+ );
+ assert!(
+ codes.iter().any(|c| c == "timeStamp.validated"),
+ "{codes:?}"
+ );
+}
+
+#[test]
+fn a_tampered_asset_exits_one_and_says_which_check_caught_it() {
+ let fixture = Fixture::new("tampered");
+ let mut bytes = signed_asset("photo.jpg", false);
+ let at = bytes.len() - 40;
+ bytes[at] ^= 0xFF;
+ let asset = fixture.write("tampered.jpg", &bytes);
+
+ let run = run_conformance(&asset, &fixture);
+ assert_eq!(run.status, 1, "a tampered asset must not report as valid");
+
+ let failures = run.status_codes("failure");
+ assert!(
+ failures
+ .iter()
+ .any(|code| code == "assertion.dataHash.mismatch"),
+ "{failures:?}"
+ );
+}
+
+#[test]
+fn an_untrusted_signer_is_a_failure_when_a_trust_list_was_supplied() {
+ let fixture = Fixture::new("untrusted");
+ let asset = fixture.write("signed.jpg", &signed_asset("photo.jpg", false));
+
+ // The TSA list is a valid trust list that simply lacks this signer's root.
+ let run = harness(&[
+ "validate".as_ref(),
+ "--asset".as_ref(),
+ asset.as_os_str(),
+ "--trust-list".as_ref(),
+ fixture
+ .credentials("c2pa-test-tsa-trust-list.pem")
+ .as_os_str(),
+ "--validation-time".as_ref(),
+ c2pa::clock::to_rfc3339(testpki::validation_time()).as_ref(),
+ ]);
+
+ assert_eq!(run.status, 1);
+ let failures = run.status_codes("failure");
+ assert!(
+ failures
+ .iter()
+ .any(|code| code == "signingCredential.untrusted"),
+ "{failures:?}"
+ );
+}
+
+#[test]
+fn the_validation_time_changes_the_answer() {
+ // The Program supplies a validation time with each asset precisely because
+ // it decides expiry. A harness that ignored it would pass today and fail
+ // in a year.
+ let fixture = Fixture::new("expiry");
+ let asset = fixture.write("signed.jpg", &signed_asset("photo.jpg", false));
+
+ let expired = harness(&[
+ "validate".as_ref(),
+ "--asset".as_ref(),
+ asset.as_os_str(),
+ "--trust-list".as_ref(),
+ fixture.credentials("c2pa-test-trust-list.pem").as_os_str(),
+ "--validation-time".as_ref(),
+ c2pa::clock::to_rfc3339(testpki::after_expiry()).as_ref(),
+ ]);
+ assert_eq!(expired.status, 1);
+
+ let failures = expired.status_codes("failure");
+ assert!(
+ failures
+ .iter()
+ .any(|code| code == "claimSignature.outsideValidity"),
+ "{failures:?}"
+ );
+}
+
+#[test]
+fn a_missing_validation_time_is_refused_rather_than_guessed() {
+ let fixture = Fixture::new("no-time");
+ let asset = fixture.write("signed.jpg", &signed_asset("photo.jpg", false));
+ let run = harness(&["validate".as_ref(), "--asset".as_ref(), asset.as_os_str()]);
+
+ assert_eq!(run.status, 2, "a harness that guesses the time is useless");
+ assert!(run.stderr.contains("--validation-time"), "{}", run.stderr);
+}
+
+#[test]
+fn batch_mode_writes_one_document_per_asset() {
+ let fixture = Fixture::new("batch");
+ let assets = fixture.dir.join("assets");
+ let out = fixture.dir.join("out");
+ std::fs::create_dir_all(&assets).unwrap();
+ std::fs::write(assets.join("one.jpg"), signed_asset("one.jpg", false)).unwrap();
+ std::fs::write(assets.join("two.jpg"), signed_asset("two.jpg", true)).unwrap();
+ // A file the batch should ignore rather than choke on.
+ std::fs::write(assets.join("notes.txt"), b"not an asset").unwrap();
+
+ let run = harness(&[
+ "batch".as_ref(),
+ "--asset-dir".as_ref(),
+ assets.as_os_str(),
+ "--output-dir".as_ref(),
+ out.as_os_str(),
+ "--trust-list".as_ref(),
+ fixture.credentials("c2pa-test-trust-list.pem").as_os_str(),
+ "--tsa-trust-list".as_ref(),
+ fixture
+ .credentials("c2pa-test-tsa-trust-list.pem")
+ .as_os_str(),
+ "--validation-time".as_ref(),
+ c2pa::clock::to_rfc3339(testpki::validation_time()).as_ref(),
+ ]);
+
+ assert_eq!(run.status, 0, "stderr: {}", run.stderr);
+ for name in ["one.crjson", "two.crjson"] {
+ let path = out.join(name);
+ assert!(path.exists(), "{name} should have been written");
+ let document: Json =
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
+ assert!(document["manifests"].as_array().unwrap().len() == 1);
+ }
+}
+
+#[test]
+fn an_asset_with_no_credentials_is_an_error_not_an_empty_document() {
+ let fixture = Fixture::new("bare");
+ let asset = fixture.write("bare.jpg", &jpeg(64, 64));
+ let run = run_conformance(&asset, &fixture);
+
+ assert_eq!(run.status, 2);
+ assert!(
+ run.stderr.contains("no Content Credentials"),
+ "{}",
+ run.stderr
+ );
+}
diff --git a/crates/imagecore/Cargo.toml b/crates/imagecore/Cargo.toml
index 4476a78..4c0600a 100644
--- a/crates/imagecore/Cargo.toml
+++ b/crates/imagecore/Cargo.toml
@@ -2,7 +2,7 @@
name = "imagecore"
version = "0.1.0"
edition = "2021"
-description = "WebAssembly image processing core for the A10city Image Editor"
+description = "WebAssembly image processing core and C2PA claim generator for the A10city Image Editor"
license = "MIT"
repository = "https://github.com/a10citylabs/editor"
@@ -40,34 +40,55 @@ imageproc = { version = "0.27", default-features = false }
# --- Content Credentials (C2PA) ---------------------------------------------
# The claim generator is written against the C2PA 2.2 specification directly
-# rather than pulling in `c2pa-rs`: that crate carries a full trust-list and
-# OCSP stack this proof of concept has no use for, and its wasm story goes
-# through a different toolchain than `wasm-bindgen`. What is left is small
-# enough to read - deterministic CBOR, JUMBF boxes and COSE_Sign1.
+# rather than pulling in `c2pa-rs`: that crate's wasm story goes through a
+# different toolchain than `wasm-bindgen`, and the Conformance Program holds
+# the applicant accountable for its own implementation either way. What is left
+# is small enough to read - deterministic CBOR, JUMBF boxes and COSE_Sign1.
#
-# Note what is *not* here: no `getrandom` and no clock. Neither exists on
-# wasm32-unknown-unknown without a shim, so the host passes in the time and the
-# random bytes it already has from `crypto.getRandomValues`. That also makes
-# every test in this crate reproducible.
+# Note what is *not* here: no signing key and no private-key type at all. The
+# Edge subsystem never holds one; see `services/claim-signer`. Also no clock and
+# no `getrandom` - neither exists on wasm32-unknown-unknown without a shim, so
+# the host passes in the time and the random bytes it already has from
+# `crypto.getRandomValues`. That also makes every test in this crate
+# reproducible.
sha2 = { version = "0.10", default-features = false }
-# `std` is deliberately off. It would switch on `rand_core/getrandom`, and
-# `getrandom` 0.2 does not build for wasm32-unknown-unknown without a JavaScript
-# shim. Nothing here needs an RNG - ECDSA signing is deterministic per RFC 6979,
-# and the UUIDs come from the host - so the honest fix is to drop the dependency
-# rather than to shim a random number generator this crate never calls.
-p256 = { version = "0.13", default-features = false, features = [
- "ecdsa",
- "pem",
-] }
+sha1 = { version = "0.10", default-features = false }
+
+# Signature verification only. `std` is deliberately off on the elliptic-curve
+# crates: it would switch on `rand_core/getrandom`, which does not build for
+# wasm32-unknown-unknown without a JavaScript shim, and nothing here needs an
+# RNG.
+p256 = { version = "0.13", default-features = false, features = ["ecdsa"] }
+p384 = { version = "0.13", default-features = false, features = ["ecdsa"] }
+# Certification authorities on the C2PA Trust List issue from RSA roots as well
+# as elliptic-curve ones, so a validator that only understands ECDSA would
+# report perfectly good manifests as unverifiable.
+rsa = { version = "0.9", default-features = false, features = ["sha2"] }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+base64 = { version = "0.22", default-features = false, features = ["alloc"] }
console_error_panic_hook = { version = "0.1", optional = true }
[features]
default = ["panic-hook"]
panic-hook = ["dep:console_error_panic_hook"]
+# Compiles in the test PKI under `conformance/test-credentials`, including its
+# private keys. Enabled by the test suite and by the conformance harness, and
+# by nothing that ships: `npm run build:wasm` does not set it, which is what
+# keeps a private key out of the browser bundle by construction rather than by
+# convention.
+test-pki = ["p256/pkcs8", "p256/pem"]
[dev-dependencies]
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
+# Test fixtures sign with the test PKI under `conformance/test-credentials`.
+# This is the only place in the workspace outside the claim-signer service
+# where a private key type is linked at all, and it is a dev-dependency so it
+# cannot reach the shipped WebAssembly module.
+p256 = { version = "0.13", default-features = false, features = ["ecdsa", "pem", "pkcs8"] }
+# A self-dependency so the integration tests can reach `c2pa::testpki`, which is
+# gated behind the `test-pki` feature. Unit tests get it from `cfg(test)`;
+# integration tests link the ordinary library, so they need the feature on.
+imagecore = { path = ".", features = ["test-pki"] }
diff --git a/crates/imagecore/build.rs b/crates/imagecore/build.rs
deleted file mode 100644
index 458d9de..0000000
--- a/crates/imagecore/build.rs
+++ /dev/null
@@ -1,101 +0,0 @@
-//! Bakes the C2PA signing credentials into the engine.
-//!
-//! The certificate chain and private key come from `C2PA_SIGNING_CERT` and
-//! `C2PA_SIGNING_KEY` when both are set, and from `signing/` in the repository
-//! root otherwise. The deploy workflow populates the environment from GitHub
-//! secrets; CI and local builds fall through to the committed demo key so that
-//! a fresh clone builds and signs without any setup.
-//!
-//! To be clear about what that buys: a secret keeps a key out of public git
-//! history, and nothing more. This engine is compiled to WebAssembly and served
-//! to browsers, so whichever key it holds is published the moment the site is.
-//! `signing/README.md` covers why that is a property of client-side signing
-//! rather than a shortcut taken here, and what a trustworthy setup looks like.
-
-use std::env;
-use std::fs;
-use std::path::PathBuf;
-
-const CERT_VAR: &str = "C2PA_SIGNING_CERT";
-const KEY_VAR: &str = "C2PA_SIGNING_KEY";
-
-fn main() {
- println!("cargo:rerun-if-env-changed={CERT_VAR}");
- println!("cargo:rerun-if-env-changed={KEY_VAR}");
-
- let crate_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
- let repo_signing = crate_dir.join("../../signing");
- let cert_file = repo_signing.join("demo-signer.pem");
- let key_file = repo_signing.join("demo-signer.key");
- let root_file = repo_signing.join("demo-root-ca.pem");
-
- for path in [&cert_file, &key_file, &root_file] {
- println!("cargo:rerun-if-changed={}", path.display());
- }
-
- // Both or neither. Taking a certificate from the environment and a key from
- // disk would produce a signature that cannot be verified by the very
- // certificate shipped alongside it, and the failure would only surface in a
- // validator somewhere downstream.
- let from_env = match (env::var(CERT_VAR), env::var(KEY_VAR)) {
- (Ok(cert), Ok(key)) if !cert.trim().is_empty() && !key.trim().is_empty() => {
- Some((cert, key))
- }
- (Ok(cert), Err(_)) if !cert.trim().is_empty() => {
- panic!("{CERT_VAR} is set but {KEY_VAR} is not; set both or neither");
- }
- (Err(_), Ok(key)) if !key.trim().is_empty() => {
- panic!("{KEY_VAR} is set but {CERT_VAR} is not; set both or neither");
- }
- _ => None,
- };
-
- let (cert_chain, private_key, provenance) = match from_env {
- Some((cert, key)) => (cert, key, "environment"),
- None => (
- read(&cert_file, CERT_VAR),
- read(&key_file, KEY_VAR),
- "repository",
- ),
- };
-
- // The trust anchor is never part of x5chain - it is read only so the app can
- // name the issuer. Missing is not fatal; the UI simply shows less.
- let root_ca = fs::read_to_string(&root_file).unwrap_or_default();
-
- let generated = format!(
- "// @generated by build.rs - do not edit.\n\
- /// PEM certificate chain, end-entity certificate first.\n\
- pub const SIGNING_CERT_CHAIN_PEM: &str = {cert};\n\
- /// PKCS#8 PEM private key matching the end-entity certificate.\n\
- pub const SIGNING_KEY_PEM: &str = {key};\n\
- /// The trust anchor that issued the signer. Not sent in x5chain.\n\
- pub const SIGNING_ROOT_CA_PEM: &str = {root};\n\
- /// Where the credentials came from at build time.\n\
- pub const SIGNING_CREDENTIAL_SOURCE: &str = {provenance:?};\n",
- cert = escape(&cert_chain),
- key = escape(&private_key),
- root = escape(&root_ca),
- );
-
- let out =
- PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")).join("signing_credentials.rs");
- fs::write(&out, generated).unwrap_or_else(|e| panic!("writing {}: {e}", out.display()));
-}
-
-fn read(path: &PathBuf, var: &str) -> String {
- fs::read_to_string(path).unwrap_or_else(|e| {
- panic!(
- "could not read {} ({e}).\n\
- Either run signing/generate.sh to create the demo credentials, or \
- set {var} in the environment.",
- path.display()
- )
- })
-}
-
-/// Rust's own string escaping via `{:?}`. PEM is ASCII with newlines, so this
-/// is exact rather than merely adequate.
-fn escape(value: &str) -> String {
- format!("{value:?}")
-}
diff --git a/crates/imagecore/src/c2pa/clock.rs b/crates/imagecore/src/c2pa/clock.rs
new file mode 100644
index 0000000..a018369
--- /dev/null
+++ b/crates/imagecore/src/c2pa/clock.rs
@@ -0,0 +1,261 @@
+//! Time, reduced to the one representation everything else can compare.
+//!
+//! Certificate validity, time-stamp `genTime` and the validation time the
+//! Conformance Program supplies all arrive as text in three different formats.
+//! Path validation has to answer "is this instant inside that window", so all
+//! three become seconds since the Unix epoch and stay that way.
+//!
+//! Written by hand rather than taken from `chrono` or `time` for two reasons
+//! that both matter here: `wasm32-unknown-unknown` has no clock, so the crates'
+//! main draw is unavailable anyway, and every dependency in a WebAssembly
+//! bundle is one more entry in the Software Bill of Materials that the
+//! conformance programme's O.3 and O.4 requirements make the applicant
+//! responsible for tracking. Sixty lines of civil-calendar arithmetic is a
+//! better trade than either.
+
+/// Seconds since 1970-01-01T00:00:00Z. Negative for earlier instants.
+pub type Instant = i64;
+
+/// Days from the Unix epoch to a proleptic-Gregorian civil date.
+///
+/// Howard Hinnant's `days_from_civil`, which is exact for every year this will
+/// ever see and has no branches worth worrying about.
+fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
+ let year = if month <= 2 { year - 1 } else { year };
+ let era = if year >= 0 { year } else { year - 399 } / 400;
+ let year_of_era = year - era * 400; // [0, 399]
+ let month = i64::from(month);
+ let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(day) - 1;
+ let doe = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + doy; // [0, 146096]
+ era * 146_097 + doe - 719_468
+}
+
+/// The inverse of [`days_from_civil`].
+fn civil_from_days(days: i64) -> (i64, u32, u32) {
+ let z = days + 719_468;
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+ let doe = z - era * 146_097;
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
+ let year = yoe + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
+ let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
+ (if month <= 2 { year + 1 } else { year }, month, day)
+}
+
+fn compose(
+ year: i64,
+ month: u32,
+ day: u32,
+ hour: u32,
+ minute: u32,
+ second: u32,
+) -> Option {
+ if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
+ return None;
+ }
+ // A leap second lands on :60 and is folded onto the following instant,
+ // which is what every other consumer of these timestamps does too.
+ if hour > 23 || minute > 59 || second > 60 {
+ return None;
+ }
+ Some(
+ days_from_civil(year, month, day) * 86_400
+ + i64::from(hour) * 3600
+ + i64::from(minute) * 60
+ + i64::from(second.min(59)),
+ )
+}
+
+/// Parse an RFC 3339 date-time.
+///
+/// Accepts the offsets RFC 3339 allows, not just `Z`, because the validation
+/// time supplied to the conformance harness is whatever the Program chooses to
+/// write. Fractional seconds are read and discarded: nothing here is decided at
+/// sub-second resolution.
+pub fn parse_rfc3339(text: &str) -> Option {
+ let bytes = text.as_bytes();
+ if bytes.len() < 20 {
+ return None;
+ }
+ let num =
+ |range: std::ops::Range| -> Option { text.get(range)?.parse::().ok() };
+
+ let year = num(0..4)?;
+ if bytes[4] != b'-' || bytes[7] != b'-' {
+ return None;
+ }
+ let month = num(5..7)? as u32;
+ let day = num(8..10)? as u32;
+ if !matches!(bytes[10], b'T' | b't' | b' ') {
+ return None;
+ }
+ if bytes[13] != b':' || bytes[16] != b':' {
+ return None;
+ }
+ let hour = num(11..13)? as u32;
+ let minute = num(14..16)? as u32;
+ let second = num(17..19)? as u32;
+
+ let mut at = 19;
+ if bytes.get(at) == Some(&b'.') {
+ at += 1;
+ while bytes.get(at).is_some_and(u8::is_ascii_digit) {
+ at += 1;
+ }
+ }
+
+ let offset = match bytes.get(at) {
+ Some(b'Z') | Some(b'z') => 0,
+ Some(sign @ (b'+' | b'-')) => {
+ let hours = num(at + 1..at + 3)?;
+ if bytes.get(at + 3) != Some(&b':') {
+ return None;
+ }
+ let minutes = num(at + 4..at + 6)?;
+ let magnitude = hours * 3600 + minutes * 60;
+ if *sign == b'-' {
+ -magnitude
+ } else {
+ magnitude
+ }
+ }
+ _ => return None,
+ };
+
+ Some(compose(year, month, day, hour, minute, second)? - offset)
+}
+
+/// Render an instant as an RFC 3339 date-time in UTC.
+pub fn to_rfc3339(at: Instant) -> String {
+ let days = at.div_euclid(86_400);
+ let rest = at.rem_euclid(86_400);
+ let (year, month, day) = civil_from_days(days);
+ format!(
+ "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
+ rest / 3600,
+ (rest % 3600) / 60,
+ rest % 60
+ )
+}
+
+/// Parse a DER `UTCTime` (`YYMMDDHHMMSSZ`) or `GeneralizedTime`
+/// (`YYYYMMDDHHMMSS[.fff]Z`).
+///
+/// `two_digit_year` selects between them: RFC 5280 pins UTCTime's window so
+/// that 00-49 means 20xx and 50-99 means 19xx.
+pub fn parse_asn1_time(text: &str, two_digit_year: bool) -> Option {
+ let digits: Vec = text.bytes().filter(u8::is_ascii_digit).collect();
+ let digits = String::from_utf8(digits).ok()?;
+ let field =
+ |range: std::ops::Range| -> Option { digits.get(range)?.parse::().ok() };
+
+ let (year, rest) = if two_digit_year {
+ if digits.len() < 10 {
+ return None;
+ }
+ let two = field(0..2)?;
+ (
+ if two < 50 {
+ 2000 + i64::from(two)
+ } else {
+ 1900 + i64::from(two)
+ },
+ &digits[2..],
+ )
+ } else {
+ if digits.len() < 12 {
+ return None;
+ }
+ (i64::from(field(0..4)?), &digits[4..])
+ };
+
+ let at = |range: std::ops::Range| -> Option { rest.get(range)?.parse().ok() };
+ compose(
+ year,
+ at(0..2)?,
+ at(2..4)?,
+ at(4..6)?,
+ at(6..8)?,
+ at(8..10).unwrap_or(0),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_epoch_is_zero() {
+ assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0));
+ assert_eq!(to_rfc3339(0), "1970-01-01T00:00:00Z");
+ }
+
+ #[test]
+ fn round_trips_a_range_of_instants() {
+ for instant in [
+ 0,
+ 1_000_000_000,
+ 1_770_000_000,
+ -1_000_000,
+ 951_782_400, // 2000-02-29, a leap day in a century leap year
+ 4_102_444_800, // 2100-01-01, a century that is not a leap year
+ ] {
+ assert_eq!(
+ parse_rfc3339(&to_rfc3339(instant)),
+ Some(instant),
+ "{instant}"
+ );
+ }
+ }
+
+ #[test]
+ fn honours_the_offset_rather_than_ignoring_it() {
+ let utc = parse_rfc3339("2026-08-26T12:00:00Z").unwrap();
+ assert_eq!(parse_rfc3339("2026-08-26T14:00:00+02:00"), Some(utc));
+ assert_eq!(parse_rfc3339("2026-08-26T07:00:00-05:00"), Some(utc));
+ }
+
+ #[test]
+ fn accepts_fractional_seconds_and_drops_them() {
+ assert_eq!(
+ parse_rfc3339("2026-03-16T18:35:24.012Z"),
+ parse_rfc3339("2026-03-16T18:35:24Z")
+ );
+ }
+
+ #[test]
+ fn reads_both_asn1_time_forms() {
+ let expected = parse_rfc3339("2026-08-26T12:34:56Z");
+ assert_eq!(parse_asn1_time("260826123456Z", true), expected);
+ assert_eq!(parse_asn1_time("20260826123456Z", false), expected);
+ }
+
+ #[test]
+ fn applies_rfc_5280_two_digit_year_windowing() {
+ // 49 is 2049; 50 is 1950. Getting this backwards would make every
+ // certificate issued before 2050 look expired.
+ assert_eq!(
+ parse_asn1_time("490101000000Z", true),
+ parse_rfc3339("2049-01-01T00:00:00Z")
+ );
+ assert_eq!(
+ parse_asn1_time("500101000000Z", true),
+ parse_rfc3339("1950-01-01T00:00:00Z")
+ );
+ }
+
+ #[test]
+ fn rejects_text_that_is_not_a_timestamp() {
+ for junk in [
+ "",
+ "yesterday",
+ "2026-13-01T00:00:00Z",
+ "2026-08-26",
+ "2026-08-26T25:00:00Z",
+ ] {
+ assert_eq!(parse_rfc3339(junk), None, "{junk} should not parse");
+ }
+ }
+}
diff --git a/crates/imagecore/src/c2pa/cose.rs b/crates/imagecore/src/c2pa/cose.rs
index 0c2ed93..0fb60b6 100644
--- a/crates/imagecore/src/c2pa/cose.rs
+++ b/crates/imagecore/src/c2pa/cose.rs
@@ -23,29 +23,56 @@
//! simple value `null`; section 13.2.3 is explicit that a zero-length byte
//! string will not do.
//!
-//! Not implemented: RFC 3161 time-stamps (`sigTst2`) and stapled OCSP responses
-//! (`rVals`). Both need a network round-trip to a third party at signing time,
-//! which an offline browser claim generator cannot do. Their absence is
-//! reported honestly to the user rather than papered over — see
-//! `signing/README.md` for what it costs.
-
-use p256::ecdsa::signature::{Signer, Verifier};
-use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
+//! # Nothing here signs
+//!
+//! This module builds the bytes to be signed and assembles the result around a
+//! signature someone else produced. It holds no key and links no key type. The
+//! signature comes back from `services/claim-signer`, which is the only
+//! component in the Target of Evaluation that ever sees one — see
+//! [`super::identity`] for why that is a conformance requirement rather than a
+//! preference.
+//!
+//! # Padding, and why the unprotected bucket is never empty
+//!
+//! The hard binding commits to the byte range the manifest occupies, so the
+//! signature box's size has to be fixed *before* the signature exists — and
+//! before the RFC 3161 time-stamp, whose size nobody can predict, comes back
+//! from the TSA. Section 10.4.2 solves this with a zero-filled `pad` in the
+//! COSE unprotected header: reserve generously, then shrink `pad` by exactly as
+//! much as the real values grew. The unprotected bucket is not covered by the
+//! signature, so rewriting it afterwards costs nothing.
+//!
+//! Section 10.4.4 notes the one wrinkle: deterministic CBOR encodes a byte
+//! string's length in a variable number of bytes, so growing `pad` by one byte
+//! sometimes grows its encoding by two. The sizes that fall in those cracks are
+//! made up with a second field, `pad2`, exactly as the specification prescribes.
use super::cbor::Value;
+use super::identity::{alg, SigningIdentity};
/// COSE header label 1: the signature algorithm.
const HEADER_ALG: i64 = 1;
/// COSE header label 33: `x5chain` (RFC 9360). C2PA 2.2 section 13.2.2 says to
/// write the integer label, and that the string form is deprecated.
const HEADER_X5CHAIN: i64 = 33;
-/// COSE algorithm -7: ECDSA with SHA-256.
-const ALG_ES256: i64 = -7;
/// CBOR tag 18 marks a `COSE_Sign1`.
const TAG_COSE_SIGN1: u64 = 18;
-/// A P-256 signature is r and s, 32 bytes each. Fixed width is what lets the
-/// manifest builder reserve space for a signature before producing one.
-pub const ES256_SIGNATURE_LEN: usize = 64;
+
+/// Unprotected header labels, all of them string-labelled in C2PA.
+const HEADER_SIG_TST2: &str = "sigTst2";
+const HEADER_SIG_TST: &str = "sigTst";
+const HEADER_PAD: &str = "pad";
+const HEADER_PAD2: &str = "pad2";
+
+/// Bytes reserved for an RFC 3161 time-stamp token by default.
+///
+/// A token signed by an elliptic-curve TSA runs to about 2 KB; one from an RSA
+/// authority with a three-deep chain can reach 8. Twelve kilobytes clears both
+/// with room to spare, and the Backend overrides it with a figure measured
+/// against the TSA actually in use. If a token still will not fit,
+/// [`assemble`] says so rather than truncating, and the caller re-prepares with
+/// a larger reservation — the retry section 10.4.4 describes.
+pub const TIMESTAMP_BUDGET: usize = 12 * 1024;
#[derive(Debug)]
pub struct CoseError(String);
@@ -58,18 +85,24 @@ impl std::fmt::Display for CoseError {
impl std::error::Error for CoseError {}
-type Result = std::result::Result;
+pub type Result = std::result::Result;
fn err(message: impl Into) -> Result {
Err(CoseError(message.into()))
}
+/// Raised when a time-stamp token is larger than the space reserved for it.
+///
+/// Named because the caller has a specific remedy — reserve more and prepare
+/// again — rather than a generic failure to report.
+pub const ERR_RESERVATION_TOO_SMALL: &str = "the signature does not fit the reserved space";
+
/// The protected header: algorithm plus the certificate chain.
///
/// RFC 9360 says a single certificate is a bare `bstr` and a chain is an array
/// of them. Writing an array of one instead is a common enough mistake that it
/// is worth being explicit about.
-fn protected_header(chain: &[Vec]) -> Value {
+fn protected_header(chain: &[Vec], algorithm: i64) -> Value {
let x5chain = if chain.len() == 1 {
Value::bytes(chain[0].clone())
} else {
@@ -77,13 +110,22 @@ fn protected_header(chain: &[Vec]) -> Value {
};
Value::Map(vec![
- (Value::Uint(HEADER_ALG as u64), Value::NegInt(ALG_ES256)),
+ (Value::Uint(HEADER_ALG as u64), Value::NegInt(algorithm)),
(Value::Uint(HEADER_X5CHAIN as u64), x5chain),
])
}
+/// The encoded protected header for an identity: the exact bytes the signature
+/// will cover.
+pub fn protected_bytes(identity: &SigningIdentity) -> Vec {
+ protected_header(&identity.chain, identity.algorithm).encode()
+}
+
/// Build the `Sig_structure` whose encoding is what actually gets signed.
-fn to_be_signed(protected: &[u8], payload: &[u8]) -> Vec {
+///
+/// This is the only thing the Edge subsystem sends to the Backend. It carries
+/// the claim, not the image — the picture never leaves the tab.
+pub fn sig_structure(protected: &[u8], payload: &[u8]) -> Vec {
Value::Array(vec![
Value::text("Signature1"),
Value::bytes(protected.to_vec()),
@@ -95,79 +137,172 @@ fn to_be_signed(protected: &[u8], payload: &[u8]) -> Vec {
.encode()
}
-/// A `COSE_Sign1_Tagged` structure with a detached payload.
-fn assemble(protected: &[u8], signature: &[u8]) -> Vec {
- Value::Tag(
- TAG_COSE_SIGN1,
- Box::new(Value::Array(vec![
- Value::bytes(protected.to_vec()),
- // Unprotected bucket. A time-stamped signature would carry
- // `sigTst2` here; this one has nothing to put in it.
- Value::Map(Vec::new()),
- Value::Null,
- Value::bytes(signature.to_vec()),
- ])),
- )
- .encode()
+/// The `sigTst2` value: a `tstContainer` holding one DER `TimeStampToken`.
+fn tst_container(token: &[u8]) -> Value {
+ Value::Map(vec![(
+ Value::text("tstTokens"),
+ Value::Array(vec![Value::Map(vec![(
+ Value::text("val"),
+ Value::bytes(token.to_vec()),
+ )])]),
+ )])
+}
+
+/// How many bytes the CBOR head of a byte string of `n` bytes occupies.
+fn bstr_head(n: usize) -> usize {
+ match n {
+ 0..=23 => 1,
+ 24..=255 => 2,
+ 256..=65_535 => 3,
+ _ => 5,
+ }
}
-/// Sign `claim_bytes`, returning the serialised `COSE_Sign1_Tagged`.
+/// The length `pad` must be for its encoded size to grow by `delta` bytes over
+/// an empty `pad`, or `None` when no length lands exactly on that figure.
+fn pad_length_for(delta: usize) -> Option {
+ if delta == 0 {
+ return Some(0);
+ }
+ // An empty pad encodes as one head byte, so growing to n bytes costs
+ // bstr_head(n) + n - 1.
+ // head(n) + n == delta + 1, and the head is 1, 2, 3 or 5 bytes, so only
+ // four candidate lengths can possibly land on the figure.
+ for shrink in [0usize, 1, 2, 4] {
+ let n = delta.saturating_sub(shrink);
+ if bstr_head(n) + n == delta + 1 {
+ return Some(n);
+ }
+ }
+ None
+}
+
+/// Choose `pad` and `pad2` lengths that add exactly `need` bytes.
///
-/// The signature is deterministic (RFC 6979), so signing the same claim with
-/// the same key twice gives identical bytes. That is not a security property
-/// here so much as a practical one: it needs no random number generator, which
-/// `wasm32-unknown-unknown` does not have, and it makes tests reproducible.
-pub fn sign(claim_bytes: &[u8], key: &SigningKey, chain: &[Vec]) -> Result> {
- if chain.is_empty() {
- return err("cannot sign without a certificate chain");
+/// `need` is measured against a header that already carries an empty `pad`, so
+/// the answer for `need == 0` is "leave it empty".
+fn padding_for(need: usize) -> Result<(usize, Option)> {
+ if let Some(pad) = pad_length_for(need) {
+ return Ok((pad, None));
+ }
+ // The gaps section 10.4.4 warns about. An empty `pad2` costs six bytes -
+ // one map-key head, four for the text, one for the empty byte string - so
+ // put six aside for it and land the rest in `pad`.
+ const EMPTY_PAD2_COST: usize = 6;
+ if need >= EMPTY_PAD2_COST {
+ if let Some(pad) = pad_length_for(need - EMPTY_PAD2_COST) {
+ return Ok((pad, Some(0)));
+ }
}
- let protected = protected_header(chain).encode();
- let signature: Signature = key.sign(&to_be_signed(&protected, claim_bytes));
- let raw = signature.to_bytes();
- debug_assert_eq!(raw.len(), ES256_SIGNATURE_LEN);
- Ok(assemble(&protected, &raw))
+ err(format!("no padding combination adds exactly {need} bytes"))
}
-/// A `COSE_Sign1` produced with a placeholder signature, for sizing.
+/// Assemble a `COSE_Sign1_Tagged` structure with a detached payload.
///
-/// The manifest builder has to know how large the signature box will be before
-/// it can compute the byte offsets the claim commits to. Because ES256
-/// signatures are always 64 bytes and the protected header depends only on the
-/// certificate chain, a placeholder is exactly the size of the real thing —
-/// which [`crate::c2pa::manifest`] asserts rather than assumes.
-pub fn placeholder(chain: &[Vec]) -> Result> {
- if chain.is_empty() {
- return err("cannot size a signature without a certificate chain");
+/// `target` is the size the result must be, because the hard binding already
+/// committed to it. Pass `None` while measuring, to learn what that size should
+/// be.
+pub fn assemble(
+ protected: &[u8],
+ signature: &[u8],
+ timestamp: Option<&[u8]>,
+ target: Option,
+) -> Result> {
+ let build = |pad: usize, pad2: Option| -> Vec {
+ let mut unprotected = Vec::new();
+ if let Some(token) = timestamp {
+ unprotected.push((Value::text(HEADER_SIG_TST2), tst_container(token)));
+ }
+ // Always present, even at zero length: section 10.4.2 asks for it, and
+ // a fixed shape means the measuring pass and the final pass differ only
+ // in the numbers.
+ unprotected.push((Value::text(HEADER_PAD), Value::bytes(vec![0u8; pad])));
+ if let Some(pad2) = pad2 {
+ unprotected.push((Value::text(HEADER_PAD2), Value::bytes(vec![0u8; pad2])));
+ }
+
+ Value::Tag(
+ TAG_COSE_SIGN1,
+ Box::new(Value::Array(vec![
+ Value::bytes(protected.to_vec()),
+ Value::Map(unprotected),
+ Value::Null,
+ Value::bytes(signature.to_vec()),
+ ])),
+ )
+ .encode()
+ };
+
+ let minimum = build(0, None);
+ let Some(target) = target else {
+ return Ok(minimum);
+ };
+
+ if minimum.len() > target {
+ return err(format!(
+ "{ERR_RESERVATION_TOO_SMALL}: {} bytes needed, {target} reserved",
+ minimum.len()
+ ));
+ }
+
+ let (pad, pad2) = padding_for(target - minimum.len())?;
+ let padded = build(pad, pad2);
+ if padded.len() != target {
+ return err(format!(
+ "padding produced {} bytes, expected {target}",
+ padded.len()
+ ));
}
- let protected = protected_header(chain).encode();
- Ok(assemble(&protected, &[0u8; ES256_SIGNATURE_LEN]))
+ Ok(padded)
+}
+
+/// The size a signature box must reserve for this identity.
+///
+/// Everything that varies — the certificate chain, the signature length, the
+/// time-stamp budget — is fixed by the identity, so this is exact rather than
+/// an estimate, and [`super::manifest`] asserts it rather than trusting it.
+pub fn reserved_len(identity: &SigningIdentity) -> Result {
+ let protected = protected_bytes(identity);
+ let signature = vec![0u8; identity.signature_len()];
+ let token = vec![0u8; identity.timestamp_budget];
+ let timestamp = (identity.timestamp_budget > 0).then_some(token.as_slice());
+ Ok(assemble(&protected, &signature, timestamp, None)?.len())
+}
+
+/// A `COSE_Sign1` of exactly [`reserved_len`] bytes, for sizing the manifest
+/// before any of its real values exist.
+pub fn placeholder(identity: &SigningIdentity) -> Result> {
+ let protected = protected_bytes(identity);
+ let signature = vec![0u8; identity.signature_len()];
+ let token = vec![0u8; identity.timestamp_budget];
+ let timestamp = (identity.timestamp_budget > 0).then_some(token.as_slice());
+ assemble(&protected, &signature, timestamp, None)
}
/// What a `COSE_Sign1` claims about itself, before any of it is believed.
-#[derive(Debug)]
+#[derive(Clone, Debug, Default)]
pub struct ParsedSignature {
/// DER certificates from `x5chain`, end-entity first.
pub chain: Vec>,
/// COSE algorithm identifier.
pub algorithm: i64,
pub signature: Vec,
+ /// The DER `TimeStampToken` from `sigTst2`, when one is present.
+ pub timestamp_token: Option>,
+ /// True when the deprecated `sigTst` header was used instead, whose value
+ /// is a whole `TimeStampResp` rather than a bare token.
+ pub timestamp_is_v1: bool,
+ /// Set when a time-stamp header carried more than one token, which section
+ /// 15.8.1.1 says to report and ignore.
+ pub timestamp_ambiguous: bool,
/// The protected header exactly as encoded — the signature covers these
/// bytes, so they must be verified as read rather than re-encoded.
- protected: Vec,
+ pub protected: Vec,
}
impl ParsedSignature {
pub fn algorithm_name(&self) -> &'static str {
- match self.algorithm {
- -7 => "ES256",
- -35 => "ES384",
- -36 => "ES512",
- -37 => "PS256",
- -38 => "PS384",
- -39 => "PS512",
- -8 => "EdDSA",
- _ => "unknown",
- }
+ alg::name(self.algorithm)
}
}
@@ -232,28 +367,48 @@ pub fn parse(bytes: &[u8]) -> Result {
},
};
- Ok(ParsedSignature {
+ let mut parsed = ParsedSignature {
chain,
algorithm,
signature,
protected,
- })
+ ..ParsedSignature::default()
+ };
+
+ // The unprotected bucket. It is not covered by the signature, so nothing
+ // read here is trusted; the time-stamp inside carries its own proof.
+ if let Some(container) = array[1].get(HEADER_SIG_TST2) {
+ read_timestamp(container, &mut parsed, false);
+ } else if let Some(container) = array[1].get(HEADER_SIG_TST) {
+ read_timestamp(container, &mut parsed, true);
+ }
+
+ Ok(parsed)
+}
+
+fn read_timestamp(container: &Value, into: &mut ParsedSignature, v1: bool) {
+ let Some(tokens) = container.get("tstTokens").and_then(Value::as_array) else {
+ return;
+ };
+ // Section 15.8.1.1: more than one token is reported and the time-stamps
+ // ignored, rather than one being picked arbitrarily.
+ if tokens.len() != 1 {
+ into.timestamp_ambiguous = true;
+ return;
+ }
+ if let Some(value) = tokens[0].get("val").and_then(Value::as_bytes) {
+ into.timestamp_token = Some(value.to_vec());
+ into.timestamp_is_v1 = v1;
+ }
}
/// Check a parsed signature against the claim it should cover.
///
/// This answers one question only — "was this claim signed by the key in that
/// certificate?" — and deliberately not "should anyone trust that certificate?".
-/// The second needs a trust anchor store the app does not have.
+/// The second is [`super::trust`]'s job, because it needs a trust list and a
+/// validation time that this function has no business inventing.
pub fn verify(parsed: &ParsedSignature, claim_bytes: &[u8]) -> Result<()> {
- if parsed.algorithm != ALG_ES256 {
- return err(format!(
- "unsupported signature algorithm {} ({})",
- parsed.algorithm,
- parsed.algorithm_name()
- ));
- }
-
let certificate = parsed
.chain
.first()
@@ -261,143 +416,207 @@ pub fn verify(parsed: &ParsedSignature, claim_bytes: &[u8]) -> Result<()> {
let parsed_certificate =
super::x509::parse_certificate(certificate).map_err(|e| CoseError(e.to_string()))?;
- let key = VerifyingKey::from_sec1_bytes(&parsed_certificate.public_key)
- .map_err(|e| CoseError(format!("signing certificate has no usable P-256 key: {e}")))?;
- let signature = Signature::from_slice(&parsed.signature)
- .map_err(|e| CoseError(format!("malformed ES256 signature: {e}")))?;
-
- key.verify(&to_be_signed(&parsed.protected, claim_bytes), &signature)
- .map_err(|_| CoseError("the claim does not match its signature".into()))
+ let message = sig_structure(&parsed.protected, claim_bytes);
+ super::verify::by_cose_algorithm(
+ parsed.algorithm,
+ &parsed_certificate,
+ &message,
+ &parsed.signature,
+ )
+ .map_err(|e| CoseError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
- use crate::c2pa::signer;
+ use crate::c2pa::testpki;
- fn credentials() -> (SigningKey, Vec>) {
- let signer = signer::load().expect("shipped credentials should load");
- (signer.key, signer.chain)
+ fn sign_with_test_key(identity: &SigningIdentity, claim: &[u8]) -> Vec {
+ let protected = protected_bytes(identity);
+ testpki::sign_es256(&sig_structure(&protected, claim))
}
#[test]
- fn signs_and_verifies_a_claim() {
- let (key, chain) = credentials();
- let claim = b"a claim, pretending to be CBOR";
+ fn a_signature_verifies_against_the_claim_it_covers() {
+ let identity = testpki::identity_without_timestamps();
+ let claim = b"a claim, more or less";
+ let signature = sign_with_test_key(&identity, claim);
+ let cose = assemble(
+ &protected_bytes(&identity),
+ &signature,
+ None,
+ Some(reserved_len(&identity).unwrap()),
+ )
+ .unwrap();
- let cose = sign(claim, &key, &chain).unwrap();
let parsed = parse(&cose).unwrap();
-
- assert_eq!(parsed.algorithm, ALG_ES256);
assert_eq!(parsed.algorithm_name(), "ES256");
- assert_eq!(parsed.chain.len(), chain.len());
- assert_eq!(parsed.signature.len(), ES256_SIGNATURE_LEN);
- verify(&parsed, claim).expect("a freshly signed claim should verify");
+ assert_eq!(parsed.chain.len(), 2);
+ verify(&parsed, claim).expect("the signature should verify");
}
#[test]
- fn a_changed_claim_fails_verification() {
- let (key, chain) = credentials();
- let cose = sign(b"the original claim", &key, &chain).unwrap();
+ fn a_changed_claim_stops_verifying() {
+ let identity = testpki::identity_without_timestamps();
+ let signature = sign_with_test_key(&identity, b"the original claim");
+ let cose = assemble(&protected_bytes(&identity), &signature, None, None).unwrap();
let parsed = parse(&cose).unwrap();
- assert!(verify(&parsed, b"the original cIaim").is_err());
+ assert!(verify(&parsed, b"a different claim").is_err());
}
#[test]
- fn a_changed_certificate_fails_verification() {
- // The point of putting x5chain in the *protected* bucket: swapping the
- // certificate has to break the signature, not just the identity.
- let (key, chain) = credentials();
+ fn the_certificate_chain_is_covered_by_the_signature() {
+ // Swapping the chain has to break verification, or x5chain would be a
+ // suggestion rather than a binding.
+ let identity = testpki::identity_without_timestamps();
let claim = b"a claim";
- let cose = sign(claim, &key, &chain).unwrap();
- let mut parsed = parse(&cose).unwrap();
+ let signature = sign_with_test_key(&identity, claim);
- let root = crate::c2pa::x509::pem_to_der(signer::SIGNING_ROOT_CA_PEM).unwrap();
- parsed.chain = vec![root[0].clone()];
- assert!(
- verify(&parsed, claim).is_err(),
- "verification must not silently use a substituted certificate"
- );
+ let mut tampered =
+ parse(&assemble(&protected_bytes(&identity), &signature, None, None).unwrap()).unwrap();
+ tampered.protected = protected_header(&[testpki::root_ca_der()], alg::ES256).encode();
+ assert!(verify(&tampered, claim).is_err());
}
#[test]
- fn a_flipped_signature_bit_fails_verification() {
- let (key, chain) = credentials();
- let claim = b"a claim";
- let mut parsed = parse(&sign(claim, &key, &chain).unwrap()).unwrap();
- parsed.signature[0] ^= 0x01;
- assert!(verify(&parsed, claim).is_err());
+ fn the_payload_is_detached_rather_than_embedded() {
+ // Section 13.2.3: a null payload, never a zero-length byte string.
+ let identity = testpki::identity_without_timestamps();
+ let cose = assemble(&protected_bytes(&identity), &[0u8; 64], None, None).unwrap();
+ let decoded = crate::c2pa::cbor::decode(&cose).unwrap();
+ let Value::Tag(18, inner) = &decoded else {
+ panic!("expected a tagged COSE_Sign1, got {decoded:?}");
+ };
+ assert!(matches!(inner.as_array().unwrap()[2], Value::Null));
}
#[test]
- fn the_payload_is_detached() {
- let (key, chain) = credentials();
- let cose = sign(b"a claim", &key, &chain).unwrap();
- let array = match super::super::cbor::decode(&cose).unwrap() {
- Value::Tag(TAG_COSE_SIGN1, inner) => inner.as_array().unwrap().to_vec(),
- _ => panic!("expected tag 18"),
- };
- assert_eq!(array[2], Value::Null, "detached content must be null");
- assert_ne!(
- array[2],
- Value::bytes(Vec::new()),
- "an empty bstr does not mean detached (section 13.2.3)"
- );
+ fn padding_hits_the_reserved_size_exactly_for_every_shortfall() {
+ // The size a time-stamp token comes back at is not predictable, so
+ // every possible gap between the real size and the reservation has to
+ // be fillable. The two the specification warns about are 24 and 257.
+ let identity = testpki::identity_without_timestamps();
+ let protected = protected_bytes(&identity);
+ let minimum = assemble(&protected, &[0u8; 64], None, None).unwrap().len();
+
+ for extra in 0..600usize {
+ let target = minimum + extra;
+ let built = assemble(&protected, &[0u8; 64], None, Some(target))
+ .unwrap_or_else(|e| panic!("shortfall of {extra} bytes: {e}"));
+ assert_eq!(built.len(), target, "shortfall of {extra} bytes");
+ // Whatever the padding, the result must still parse.
+ parse(&built).unwrap();
+ }
}
#[test]
- fn a_single_certificate_is_a_bare_bstr() {
- // RFC 9360: one certificate is a bstr, several are an array of bstr.
- let chain = vec![vec![0xAAu8; 4]];
- let header = super::super::cbor::decode(&protected_header(&chain).encode()).unwrap();
- assert!(matches!(
- header.get_int(HEADER_X5CHAIN),
- Some(Value::Bytes(_))
- ));
+ fn a_time_stamp_fits_in_the_space_reserved_for_it() {
+ let identity = testpki::identity();
+ let reserved = reserved_len(&identity).unwrap();
+ assert!(
+ reserved > TIMESTAMP_BUDGET,
+ "the reservation must cover the token"
+ );
- let chain = vec![vec![0xAAu8; 4], vec![0xBBu8; 4]];
- let header = super::super::cbor::decode(&protected_header(&chain).encode()).unwrap();
- assert!(matches!(
- header.get_int(HEADER_X5CHAIN),
- Some(Value::Array(_))
- ));
+ // A token smaller than the budget, which is the normal case.
+ let token = vec![0xABu8; 2048];
+ let cose = assemble(
+ &protected_bytes(&identity),
+ &[0u8; 64],
+ Some(&token),
+ Some(reserved),
+ )
+ .unwrap();
+ assert_eq!(cose.len(), reserved);
+
+ let parsed = parse(&cose).unwrap();
+ assert_eq!(parsed.timestamp_token.as_deref(), Some(token.as_slice()));
+ assert!(!parsed.timestamp_is_v1);
}
#[test]
- fn a_placeholder_is_exactly_the_size_of_a_real_signature() {
- // The manifest builder reserves space using `placeholder` and then
- // writes the real signature into it. If these ever differed, every
- // byte offset in the hard binding would be wrong.
- let (key, chain) = credentials();
- let real = sign(b"a claim of some length", &key, &chain).unwrap();
- assert_eq!(placeholder(&chain).unwrap().len(), real.len());
+ fn a_missing_time_stamp_still_fills_the_reservation() {
+ // The TSA can be unreachable. The manifest must come out the same size
+ // regardless, because the hard binding already committed to it.
+ let identity = testpki::identity();
+ let reserved = reserved_len(&identity).unwrap();
+ let cose = assemble(
+ &protected_bytes(&identity),
+ &[0u8; 64],
+ None,
+ Some(reserved),
+ )
+ .unwrap();
+ assert_eq!(cose.len(), reserved);
+ assert!(parse(&cose).unwrap().timestamp_token.is_none());
}
#[test]
- fn signing_is_deterministic() {
- // RFC 6979. No RNG needed, which matters on wasm32-unknown-unknown.
- let (key, chain) = credentials();
- assert_eq!(
- sign(b"a claim", &key, &chain).unwrap(),
- sign(b"a claim", &key, &chain).unwrap()
+ fn an_oversized_time_stamp_is_reported_rather_than_truncated() {
+ let identity = testpki::identity();
+ let reserved = reserved_len(&identity).unwrap();
+ let token = vec![0u8; identity.timestamp_budget + 4096];
+ let error = assemble(
+ &protected_bytes(&identity),
+ &[0u8; 64],
+ Some(&token),
+ Some(reserved),
+ )
+ .unwrap_err();
+ assert!(
+ error.to_string().contains(ERR_RESERVATION_TOO_SMALL),
+ "the caller needs to be told to reserve more, got: {error}"
);
}
#[test]
- fn rejects_malformed_input() {
- assert!(parse(b"not cbor at all").is_err());
- // A four-element array is required.
- assert!(parse(&Value::Array(vec![Value::Null]).encode()).is_err());
- // No algorithm in the protected header.
- let headerless = Value::Tag(
+ fn more_than_one_token_is_flagged_and_ignored() {
+ let identity = testpki::identity_without_timestamps();
+ let container = Value::Map(vec![(
+ Value::text("tstTokens"),
+ Value::Array(vec![
+ Value::Map(vec![(Value::text("val"), Value::bytes(vec![1, 2, 3]))]),
+ Value::Map(vec![(Value::text("val"), Value::bytes(vec![4, 5, 6]))]),
+ ]),
+ )]);
+ let cose = Value::Tag(
TAG_COSE_SIGN1,
Box::new(Value::Array(vec![
- Value::bytes(Value::Map(Vec::new()).encode()),
- Value::Map(Vec::new()),
+ Value::bytes(protected_bytes(&identity)),
+ Value::Map(vec![(Value::text(HEADER_SIG_TST2), container)]),
Value::Null,
Value::bytes(vec![0u8; 64]),
])),
- );
- assert!(parse(&headerless.encode()).is_err());
+ )
+ .encode();
+
+ let parsed = parse(&cose).unwrap();
+ assert!(parsed.timestamp_ambiguous);
+ assert!(parsed.timestamp_token.is_none());
+ }
+
+ #[test]
+ fn a_single_certificate_chain_is_a_bare_byte_string() {
+ // RFC 9360: one certificate is a bstr, several are an array. An array
+ // of one is the classic mistake.
+ let single = SigningIdentity::new(vec![testpki::leaf_der()], alg::ES256, "k", 0).unwrap();
+ let header = crate::c2pa::cbor::decode(&protected_bytes(&single)).unwrap();
+ assert!(matches!(
+ header.get_int(HEADER_X5CHAIN),
+ Some(Value::Bytes(_))
+ ));
+
+ let pair = testpki::identity_without_timestamps();
+ let header = crate::c2pa::cbor::decode(&protected_bytes(&pair)).unwrap();
+ assert!(matches!(
+ header.get_int(HEADER_X5CHAIN),
+ Some(Value::Array(_))
+ ));
+ }
+
+ #[test]
+ fn rejects_a_structure_that_is_not_a_cose_sign1() {
+ assert!(parse(b"").is_err());
+ assert!(parse(&Value::Array(vec![Value::Null]).encode()).is_err());
}
}
diff --git a/crates/imagecore/src/c2pa/crjson.rs b/crates/imagecore/src/c2pa/crjson.rs
new file mode 100644
index 0000000..5359c1f
--- /dev/null
+++ b/crates/imagecore/src/c2pa/crjson.rs
@@ -0,0 +1,309 @@
+//! The crJSON serialisation of a manifest store and its validation results.
+//!
+//! crJSON is a JSON-LD view over a C2PA manifest store, defined by the *Content
+//! Credentials JSON (crJSON) File Format Specification*. It is not a
+//! cryptographic artefact and cannot be validated on its own; it exists so that
+//! two implementations can be compared field by field.
+//!
+//! That comparison is the reason this module exists. The Conformance Program
+//! requires any applicant whose product validates manifests to run a test
+//! harness over assets the Program supplies — with a test C2PA Trust List, a
+//! test TSA Trust List and a fixed validation time — and hand back crJSON. So
+//! this is the product's validator speaking the Program's language, not a
+//! separate reporting path that could disagree with what the browser shows.
+//! `crates/c2pa-harness` is the command-line front end; the browser can export
+//! the same document.
+//!
+//! # The bits that are easy to get wrong
+//!
+//! - **Manifest order is reversed.** The store holds the active manifest last;
+//! crJSON puts it first.
+//! - **Byte strings are Base64 with a `b64'` prefix**, not bare Base64 and not
+//! hex.
+//! - **`gathered_assertions` and `redacted_assertions` are always present**,
+//! as empty arrays when the claim omits them.
+//! - **CBOR tag 0 unwraps** to the plain date-time string inside it.
+//! - **Non-text map keys become strings**, because JSON has no other kind.
+
+use base64::engine::general_purpose::STANDARD as BASE64;
+use base64::Engine as _;
+use serde_json::{json, Map, Value as Json};
+
+use super::cbor::Value;
+use super::clock;
+use super::manifest::{ManifestReport, StatusCodes, ValidationReport};
+use super::x509::{self, Certificate};
+
+/// The tool identification crJSON requires, in SemVer 2.0 form.
+fn json_generator() -> Json {
+ json!({
+ "name": "A10city Image Editor conformance harness",
+ "version": env!("CARGO_PKG_VERSION"),
+ })
+}
+
+/// Render a validation report as a crJSON document.
+pub fn to_crjson(report: &ValidationReport) -> Json {
+ // Section 3.4: reverse store order, so the active manifest is first.
+ let manifests: Vec = report
+ .chain
+ .iter()
+ .rev()
+ .map(|manifest| to_manifest(manifest, &report.validation_time))
+ .collect();
+
+ json!({
+ "@context": {
+ "@vocab": "https://c2pa.org/crjson/",
+ "extras": "https://c2pa.org/crjson/extras/",
+ },
+ "jsonGenerator": json_generator(),
+ "manifests": manifests,
+ })
+}
+
+fn to_manifest(report: &ManifestReport, validation_time: &str) -> Json {
+ let mut manifest = Map::new();
+ manifest.insert("label".into(), json!(report.label));
+
+ // The claim keeps the label of its own box, so a v1 claim reads as `claim`
+ // and a v2 claim as `claim.v2`.
+ let claim_key = if report.raw.claim_label == "c2pa.claim" {
+ "claim"
+ } else {
+ "claim.v2"
+ };
+ let claim = report
+ .raw
+ .claim
+ .as_ref()
+ .map(to_json)
+ .unwrap_or_else(|| json!({}));
+ manifest.insert(claim_key.into(), with_empty_assertion_arrays(claim));
+
+ let mut assertions = Map::new();
+ for (label, value) in &report.raw.assertions {
+ // Section 3.6.1: an assertion that could not be decoded is still
+ // listed, with an empty object as its value.
+ assertions.insert(
+ label.clone(),
+ value.as_ref().map(to_json).unwrap_or_else(|| json!({})),
+ );
+ }
+ manifest.insert("assertions".into(), Json::Object(assertions));
+ manifest.insert("signature".into(), to_signature(report));
+ manifest.insert(
+ "validationResults".into(),
+ to_validation_results(&report.status, validation_time),
+ );
+
+ Json::Object(manifest)
+}
+
+/// Section 3.5.1: both assertion lists are always present, empty if absent.
+fn with_empty_assertion_arrays(claim: Json) -> Json {
+ let Json::Object(mut map) = claim else {
+ return claim;
+ };
+ for key in ["gathered_assertions", "redacted_assertions"] {
+ map.entry(key.to_string()).or_insert_with(|| json!([]));
+ }
+ Json::Object(map)
+}
+
+fn to_signature(report: &ManifestReport) -> Json {
+ let Some(leaf) = report
+ .raw
+ .chain
+ .first()
+ .and_then(|der| x509::parse_certificate(der).ok())
+ else {
+ // Section 3.7: an empty object when there is no signature information.
+ return json!({});
+ };
+
+ let mut signature = Map::new();
+ signature.insert("algorithm".into(), json!(report.signature.algorithm));
+ signature.insert("certificateInfo".into(), to_certificate_info(&leaf));
+
+ if report.signature.time_stamped {
+ let mut info = Map::new();
+ info.insert("timestamp".into(), json!(report.signature.time_stamp));
+ if let Some(tsa) = report
+ .raw
+ .timestamp_token
+ .as_ref()
+ .and_then(|token| super::timestamp::parse(token).ok())
+ {
+ info.insert("certificateInfo".into(), to_certificate_info(&tsa.signer));
+ }
+ signature.insert("timestampInfo".into(), Json::Object(info));
+ }
+
+ Json::Object(signature)
+}
+
+fn to_certificate_info(certificate: &Certificate) -> Json {
+ let dn = |attributes: &[(String, String)]| -> Json {
+ let mut map = Map::new();
+ for (label, value) in attributes {
+ map.insert(label.clone(), json!(value));
+ }
+ Json::Object(map)
+ };
+
+ // The serial is a hex string here rather than the decimal one the crJSON
+ // example shows: a twenty-octet serial has no exact decimal form in JSON's
+ // number type, and the specification's own note allows implementations to
+ // add and shape fields sensibly.
+ let mut info = Map::new();
+ info.insert("serialNumber".into(), json!(certificate.serial));
+ info.insert("subject".into(), dn(&certificate.subject_attributes));
+ info.insert("issuer".into(), dn(&certificate.issuer_attributes));
+ info.insert(
+ "validity".into(),
+ json!({
+ "notBefore": certificate.not_before,
+ "notAfter": certificate.not_after,
+ }),
+ );
+
+ // The extensions the C2PA Certificate Policy adds are the whole point of a
+ // conformance-grade report, so they go in even though crJSON does not name
+ // them. Section 3.7 says implementations may include more.
+ let mut extras = Map::new();
+ if let Some(level) = certificate.c2pa_assurance_level {
+ extras.insert("assuranceLevel".into(), json!(level));
+ }
+ if let Some(record) = &certificate.c2pa_cpl_record_id {
+ extras.insert("cplRecordId".into(), json!(record));
+ }
+ if !certificate.extended_key_usage.is_empty() {
+ extras.insert(
+ "extendedKeyUsage".into(),
+ json!(certificate.extended_key_usage),
+ );
+ }
+ if !extras.is_empty() {
+ info.insert("extras:c2pa".into(), Json::Object(extras));
+ }
+
+ Json::Object(info)
+}
+
+fn to_validation_results(status: &StatusCodes, validation_time: &str) -> Json {
+ let list = |entries: &[super::manifest::Status]| -> Json {
+ Json::Array(
+ entries
+ .iter()
+ .map(|entry| {
+ json!({
+ "code": entry.code,
+ "explanation": entry.explanation,
+ })
+ })
+ .collect(),
+ )
+ };
+
+ json!({
+ "success": list(&status.success),
+ "informational": list(&status.informational),
+ "failure": list(&status.failure),
+ "validationTime": validation_time,
+ })
+}
+
+/// Table 1 of the crJSON specification: CBOR to JSON-LD.
+pub fn to_json(value: &Value) -> Json {
+ match value {
+ Value::Uint(n) => json!(n),
+ Value::Uint32(n) => json!(n),
+ Value::NegInt(n) => json!(n),
+ Value::Bytes(bytes) => json!(format!("b64'{}", BASE64.encode(bytes))),
+ Value::Text(text) => json!(text),
+ Value::Array(items) => Json::Array(items.iter().map(to_json).collect()),
+ Value::Map(entries) => {
+ let mut map = Map::new();
+ for (key, value) in entries {
+ map.insert(key_to_string(key), to_json(value));
+ }
+ Json::Object(map)
+ }
+ Value::Bool(b) => json!(b),
+ Value::Null => Json::Null,
+ // Tag 0 is a date-time; the specification says to copy the text out of
+ // it. Any other tag is transparent for these purposes.
+ Value::Tag(_, inner) => to_json(inner),
+ }
+}
+
+/// JSON object keys are strings, so a CBOR key of any other type is rendered.
+fn key_to_string(key: &Value) -> String {
+ match key {
+ Value::Text(text) => text.clone(),
+ Value::Uint(n) => n.to_string(),
+ Value::Uint32(n) => n.to_string(),
+ Value::NegInt(n) => n.to_string(),
+ Value::Bytes(bytes) => format!("b64'{}", BASE64.encode(bytes)),
+ other => format!("{other:?}"),
+ }
+}
+
+/// Parse the validation time back out of a crJSON document, for tests and for
+/// tools that round-trip one.
+pub fn validation_time_of(document: &Json) -> Option {
+ document
+ .get("manifests")?
+ .as_array()?
+ .first()?
+ .get("validationResults")?
+ .get("validationTime")?
+ .as_str()
+ .and_then(clock::parse_rfc3339)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn byte_strings_carry_the_b64_prefix() {
+ let value = Value::Map(vec![(Value::text("hash"), Value::bytes(vec![1, 2, 3]))]);
+ let json = to_json(&value);
+ assert_eq!(json["hash"], json!("b64'AQID"));
+ }
+
+ #[test]
+ fn a_tagged_date_time_unwraps_to_its_text() {
+ let value = Value::datetime("2026-08-26T12:00:00Z");
+ assert_eq!(to_json(&value), json!("2026-08-26T12:00:00Z"));
+ }
+
+ #[test]
+ fn negative_integers_stay_negative() {
+ assert_eq!(to_json(&Value::NegInt(-7)), json!(-7));
+ assert_eq!(to_json(&Value::Uint(7)), json!(7));
+ }
+
+ #[test]
+ fn non_text_keys_become_strings() {
+ let value = Value::Map(vec![(Value::Uint(1), Value::text("alg"))]);
+ assert_eq!(to_json(&value), json!({"1": "alg"}));
+ }
+
+ #[test]
+ fn a_claim_always_carries_both_assertion_lists() {
+ let claim = json!({"instanceID": "xmp:iid:1"});
+ let filled = with_empty_assertion_arrays(claim);
+ assert_eq!(filled["gathered_assertions"], json!([]));
+ assert_eq!(filled["redacted_assertions"], json!([]));
+ }
+
+ #[test]
+ fn an_existing_assertion_list_is_left_alone() {
+ let claim = json!({"gathered_assertions": [{"url": "x"}]});
+ let filled = with_empty_assertion_arrays(claim);
+ assert_eq!(filled["gathered_assertions"].as_array().unwrap().len(), 1);
+ }
+}
diff --git a/crates/imagecore/src/c2pa/der.rs b/crates/imagecore/src/c2pa/der.rs
new file mode 100644
index 0000000..45c9008
--- /dev/null
+++ b/crates/imagecore/src/c2pa/der.rs
@@ -0,0 +1,195 @@
+//! A small DER writer.
+//!
+//! The parser in [`super::x509`] reads certificates; this writes the handful of
+//! ASN.1 structures the product has to *produce*: an RFC 3161 `TimeStampReq`
+//! for the Backend to send to a time-stamping authority, and — in tests — the
+//! `TimeStampToken` a stand-in authority answers with, so the whole time-stamp
+//! path is exercised against real DER rather than a mock.
+//!
+//! Definite-length, minimal-length encodings throughout, which is what DER
+//! means and what every verifier re-deriving a digest over these bytes assumes.
+
+/// Encode a tag, length and value.
+pub fn tlv(tag: u8, value: &[u8]) -> Vec {
+ let mut out = Vec::with_capacity(value.len() + 6);
+ out.push(tag);
+ let len = value.len();
+ if len < 0x80 {
+ out.push(len as u8);
+ } else {
+ // Minimal long form: only the bytes the length actually needs.
+ let bytes = len.to_be_bytes();
+ let first = bytes
+ .iter()
+ .position(|b| *b != 0)
+ .unwrap_or(bytes.len() - 1);
+ let significant = &bytes[first..];
+ out.push(0x80 | significant.len() as u8);
+ out.extend_from_slice(significant);
+ }
+ out.extend_from_slice(value);
+ out
+}
+
+fn concat(items: &[Vec]) -> Vec {
+ items.iter().flat_map(|i| i.iter().copied()).collect()
+}
+
+pub fn sequence(items: &[Vec]) -> Vec {
+ tlv(0x30, &concat(items))
+}
+
+pub fn set_of(items: &[Vec]) -> Vec {
+ tlv(0x31, &concat(items))
+}
+
+pub fn octet_string(value: &[u8]) -> Vec {
+ tlv(0x04, value)
+}
+
+pub fn boolean(value: bool) -> Vec {
+ tlv(0x01, &[if value { 0xFF } else { 0x00 }])
+}
+
+pub fn null() -> Vec {
+ tlv(0x05, &[])
+}
+
+/// A context-specific constructed wrapper, `[n] EXPLICIT`.
+pub fn explicit(number: u8, inner: &[u8]) -> Vec {
+ tlv(0xA0 | number, inner)
+}
+
+/// A context-specific primitive, `[n] IMPLICIT`, keeping the inner content.
+pub fn implicit_primitive(number: u8, value: &[u8]) -> Vec {
+ tlv(0x80 | number, value)
+}
+
+/// A context-specific constructed value with the tag replaced, `[n] IMPLICIT`
+/// over a constructed type.
+pub fn implicit_constructed(number: u8, encoded: &[u8]) -> Vec {
+ let mut out = encoded.to_vec();
+ if let Some(first) = out.first_mut() {
+ *first = 0xA0 | number;
+ }
+ out
+}
+
+/// A non-negative INTEGER.
+pub fn integer(value: u64) -> Vec {
+ let bytes = value.to_be_bytes();
+ let first = bytes
+ .iter()
+ .position(|b| *b != 0)
+ .unwrap_or(bytes.len() - 1);
+ let mut body = bytes[first..].to_vec();
+ // DER integers are signed, so a leading zero keeps a high bit from reading
+ // as a negative number.
+ if body[0] & 0x80 != 0 {
+ body.insert(0, 0);
+ }
+ tlv(0x02, &body)
+}
+
+/// An OBJECT IDENTIFIER from its dotted-decimal form.
+pub fn oid(dotted: &str) -> Vec {
+ let arcs: Vec = dotted.split('.').filter_map(|a| a.parse().ok()).collect();
+ let mut body = Vec::new();
+ if arcs.len() >= 2 {
+ body.push((arcs[0] * 40 + arcs[1]) as u8);
+ for arc in &arcs[2..] {
+ let mut stack = Vec::new();
+ let mut value = *arc;
+ loop {
+ stack.push((value & 0x7F) as u8);
+ value >>= 7;
+ if value == 0 {
+ break;
+ }
+ }
+ for (index, byte) in stack.iter().rev().enumerate() {
+ body.push(if index + 1 == stack.len() {
+ *byte
+ } else {
+ byte | 0x80
+ });
+ }
+ }
+ }
+ tlv(0x06, &body)
+}
+
+/// A `GeneralizedTime` in the `YYYYMMDDHHMMSSZ` form DER requires.
+pub fn generalized_time(at: super::clock::Instant) -> Vec {
+ let text = super::clock::to_rfc3339(at)
+ .replace(['-', ':'], "")
+ .replace('T', "");
+ tlv(0x18, text.as_bytes())
+}
+
+/// An `AlgorithmIdentifier` with absent parameters, as the elliptic-curve
+/// signature algorithms use.
+pub fn algorithm(oid_text: &str) -> Vec {
+ sequence(&[oid(oid_text)])
+}
+
+/// An `AlgorithmIdentifier` with explicit NULL parameters, as the SHA-2 digest
+/// algorithms conventionally carry.
+pub fn algorithm_with_null(oid_text: &str) -> Vec {
+ sequence(&[oid(oid_text), null()])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::c2pa::x509;
+
+ #[test]
+ fn short_and_long_lengths_round_trip_through_the_parser() {
+ for size in [0usize, 1, 127, 128, 255, 256, 4096] {
+ let encoded = octet_string(&vec![0xAB; size]);
+ let parsed = x509::read_tlv(&encoded).unwrap();
+ assert_eq!(parsed.tag, 0x04);
+ assert_eq!(parsed.value.len(), size, "size {size}");
+ assert_eq!(parsed.total, encoded.len(), "size {size}");
+ }
+ }
+
+ #[test]
+ fn oids_round_trip() {
+ for dotted in [
+ "1.2.840.113549.1.7.2",
+ "2.16.840.1.101.3.4.2.1",
+ "1.3.6.1.4.1.62558.3.10",
+ "1.3.101.112",
+ ] {
+ let encoded = oid(dotted);
+ let parsed = x509::read_tlv(&encoded).unwrap();
+ assert_eq!(x509::oid_to_string(parsed.value), dotted);
+ }
+ }
+
+ #[test]
+ fn integers_keep_their_sign_guard() {
+ // 0x80 must not encode as a negative number.
+ assert_eq!(integer(0x80), vec![0x02, 0x02, 0x00, 0x80]);
+ assert_eq!(integer(1), vec![0x02, 0x01, 0x01]);
+ assert_eq!(integer(0), vec![0x02, 0x01, 0x00]);
+ }
+
+ #[test]
+ fn generalized_time_round_trips_through_the_parser() {
+ let at = crate::c2pa::clock::parse_rfc3339("2026-08-26T12:34:56Z").unwrap();
+ let encoded = generalized_time(at);
+ let parsed = x509::read_tlv(&encoded).unwrap();
+ assert_eq!(x509::decode_time_instant(&parsed), Some(at));
+ }
+
+ #[test]
+ fn implicit_tagging_rewrites_the_tag_without_disturbing_the_body() {
+ let inner = sequence(&[integer(1)]);
+ let tagged = implicit_constructed(0, &inner);
+ assert_eq!(tagged[0], 0xA0);
+ assert_eq!(&tagged[1..], &inner[1..]);
+ }
+}
diff --git a/crates/imagecore/src/c2pa/identity.rs b/crates/imagecore/src/c2pa/identity.rs
new file mode 100644
index 0000000..3c58837
--- /dev/null
+++ b/crates/imagecore/src/c2pa/identity.rs
@@ -0,0 +1,263 @@
+//! The signing identity the Edge subsystem is given, and never holds.
+//!
+//! # What changed, and why it had to
+//!
+//! An earlier version of this engine compiled a private key into the
+//! WebAssembly module. That is disqualifying under the C2PA Conformance
+//! Program: objective O.2 of the Generator Product Security Requirements says
+//! the Target of Evaluation shall keep the claim signing key encrypted at rest
+//! *and* in volatile memory except while signing, shall restrict access to it
+//! by least privilege, and shall be able to rotate it. A key served to every
+//! visitor as part of a static bundle fails all three, and no amount of
+//! obfuscation changes that — so a browser-only claim generator cannot reach
+//! even Assurance Level 1.
+//!
+//! The fix is architectural rather than cosmetic. The product is now a
+//! **Distributed** implementation: the browser (the Edge subsystem) builds the
+//! asset, the assertions and the claim, and `services/claim-signer` (the
+//! Backend subsystem) holds the key and returns a signature over the 32-byte
+//! digest structure the Edge sends it. The image still never leaves the tab.
+//!
+//! What this module holds is the *public* half of that arrangement: the
+//! certificate chain, the algorithm, and how much room to reserve for a
+//! time-stamp. All of it is public information that the Backend publishes, and
+//! all of it is needed on the Edge before signing, because the size of the
+//! finished signature box — which the hard binding commits to — depends on it.
+
+use super::x509;
+
+/// COSE algorithm identifiers, as C2PA 2.2 section 13.2.1 allows them.
+pub mod alg {
+ pub const ES256: i64 = -7;
+ pub const ES384: i64 = -35;
+ pub const ES512: i64 = -36;
+ pub const PS256: i64 = -37;
+ pub const PS384: i64 = -38;
+ pub const PS512: i64 = -39;
+ pub const ED25519: i64 = -8;
+
+ /// How many bytes a signature in this algorithm occupies.
+ ///
+ /// The manifest builder reserves space for the signature before it exists,
+ /// so a wrong answer here is a wrong byte offset in the hard binding. For
+ /// ECDSA the answer is fixed by the curve; for RSA it is the modulus size,
+ /// which the caller has to read off the certificate.
+ pub fn signature_len(algorithm: i64, rsa_modulus_bytes: Option) -> Option {
+ match algorithm {
+ ES256 => Some(64),
+ ES384 => Some(96),
+ ES512 => Some(132),
+ ED25519 => Some(64),
+ PS256 | PS384 | PS512 => rsa_modulus_bytes,
+ _ => None,
+ }
+ }
+
+ pub fn name(algorithm: i64) -> &'static str {
+ match algorithm {
+ ES256 => "ES256",
+ ES384 => "ES384",
+ ES512 => "ES512",
+ PS256 => "PS256",
+ PS384 => "PS384",
+ PS512 => "PS512",
+ ED25519 => "Ed25519",
+ _ => "Unknown",
+ }
+ }
+
+ pub fn from_name(name: &str) -> Option {
+ Some(match name {
+ "ES256" => ES256,
+ "ES384" => ES384,
+ "ES512" => ES512,
+ "PS256" => PS256,
+ "PS384" => PS384,
+ "PS512" => PS512,
+ "Ed25519" | "EdDSA" => ED25519,
+ _ => return None,
+ })
+ }
+}
+
+/// What the Backend published about the credential it will sign with.
+///
+/// Everything here is public. The Edge caches it for the session and uses it to
+/// size the signature box; it is re-fetched when the Backend reports a
+/// different `key_id`, which is how key rotation reaches the browser without a
+/// redeploy.
+#[derive(Clone, Debug)]
+pub struct SigningIdentity {
+ /// DER certificates for `x5chain`, end-entity first, trust anchor omitted
+ /// (C2PA 2.2 section 13.2.2).
+ pub chain: Vec>,
+ /// COSE algorithm identifier the Backend signs with.
+ pub algorithm: i64,
+ /// Which key version this chain belongs to, so the Edge can notice a
+ /// rotation mid-session and re-fetch rather than sign against a stale
+ /// certificate.
+ pub key_id: String,
+ /// Bytes to reserve in the COSE unprotected header for an RFC 3161
+ /// time-stamp token. Zero means the Backend has no time-stamping
+ /// authority configured and the signature will carry none.
+ pub timestamp_budget: usize,
+}
+
+impl SigningIdentity {
+ /// Build an identity from a PEM chain, checking the parts the Edge depends
+ /// on rather than trusting the Backend's word for them.
+ ///
+ /// The Backend is inside the Target of Evaluation, so this is not a trust
+ /// boundary in the security sense. It is still worth checking: a chain that
+ /// does not parse here produces a manifest whose offsets are wrong, and the
+ /// failure would otherwise surface as an unverifiable image rather than as
+ /// a setup error.
+ pub fn from_pem(
+ pem: &str,
+ algorithm: i64,
+ key_id: impl Into,
+ timestamp_budget: usize,
+ ) -> Result {
+ let chain = x509::pem_to_der(pem)
+ .map_err(|e| format!("the signing certificate chain could not be read: {e}"))?;
+ Self::new(chain, algorithm, key_id, timestamp_budget)
+ }
+
+ pub fn new(
+ chain: Vec>,
+ algorithm: i64,
+ key_id: impl Into,
+ timestamp_budget: usize,
+ ) -> Result {
+ let leaf = chain
+ .first()
+ .ok_or_else(|| "the signing certificate chain is empty".to_string())?;
+ let certificate =
+ x509::parse_certificate(leaf).map_err(|e| format!("signing certificate: {e}"))?;
+
+ if certificate.is_ca {
+ return Err("the end-entity certificate must not be a CA".into());
+ }
+ if alg::signature_len(algorithm, certificate.rsa_modulus_bytes()).is_none() {
+ return Err(format!(
+ "the signature algorithm {} is not one this build can size",
+ alg::name(algorithm)
+ ));
+ }
+
+ Ok(SigningIdentity {
+ chain,
+ algorithm,
+ key_id: key_id.into(),
+ timestamp_budget,
+ })
+ }
+
+ /// Size of the signature the Backend will return.
+ pub fn signature_len(&self) -> usize {
+ let modulus = x509::parse_certificate(&self.chain[0])
+ .ok()
+ .and_then(|c| c.rsa_modulus_bytes());
+ alg::signature_len(self.algorithm, modulus).unwrap_or(0)
+ }
+
+ /// The leaf certificate, parsed.
+ pub fn leaf(&self) -> Result {
+ x509::parse_certificate(&self.chain[0]).map_err(|e| e.to_string())
+ }
+
+ /// How this signer describes itself, for the interface.
+ pub fn describe(&self) -> Result {
+ let leaf = self.leaf()?;
+ Ok(SignerDescription {
+ common_name: leaf.subject_common_name.clone(),
+ organisation: leaf.subject_organisation.clone(),
+ issuer: if leaf.issuer_common_name.is_empty() {
+ leaf.issuer.clone()
+ } else {
+ leaf.issuer_common_name.clone()
+ },
+ not_before: leaf.not_before.clone(),
+ not_after: leaf.not_after.clone(),
+ algorithm: alg::name(self.algorithm).to_string(),
+ assurance_level: leaf.c2pa_assurance_level,
+ cpl_record_id: leaf.c2pa_cpl_record_id.clone(),
+ claim_signing_eku: leaf.has_claim_signing_eku(),
+ time_stamped: self.timestamp_budget > 0,
+ key_id: self.key_id.clone(),
+ })
+ }
+}
+
+/// The signer, as the interface presents it to a person.
+///
+/// `assurance_level` and `cpl_record_id` come straight out of the certificate.
+/// They are the two facts that separate a conformant Generator Product from
+/// something that merely produces well-formed CBOR, and showing them beats any
+/// wording the app could invent for itself.
+#[derive(Clone, Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SignerDescription {
+ pub common_name: String,
+ pub organisation: String,
+ pub issuer: String,
+ pub not_before: String,
+ pub not_after: String,
+ pub algorithm: String,
+ /// 1 or 2 from the `c2pa-al` extension; `None` when the certificate carries
+ /// none, which means it was not issued under the C2PA Certificate Policy.
+ pub assurance_level: Option,
+ /// The Conforming Products List record this instance signs under.
+ pub cpl_record_id: Option,
+ /// Whether the leaf asserts `c2pa-kp-claimSigning` (1.3.6.1.4.1.62558.2.1).
+ pub claim_signing_eku: bool,
+ pub time_stamped: bool,
+ pub key_id: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::c2pa::testpki;
+
+ #[test]
+ fn the_test_chain_loads_as_an_identity() {
+ let identity = testpki::identity();
+ assert_eq!(identity.algorithm, alg::ES256);
+ assert_eq!(identity.signature_len(), 64);
+ // Leaf plus issuing CA, and never the root.
+ assert_eq!(identity.chain.len(), 2);
+ }
+
+ #[test]
+ fn the_identity_reports_the_conformance_facts_from_the_certificate() {
+ let described = testpki::identity().describe().unwrap();
+ assert_eq!(described.assurance_level, Some(1));
+ assert!(
+ described.claim_signing_eku,
+ "the leaf must assert c2pa-kp-claimSigning"
+ );
+ assert_eq!(
+ described.cpl_record_id.as_deref(),
+ Some("00000000-0000-0000-0000-000000000000")
+ );
+ assert_eq!(described.algorithm, "ES256");
+ }
+
+ #[test]
+ fn a_ca_certificate_is_refused_as_an_end_entity() {
+ let chain = vec![testpki::issuing_ca_der()];
+ let error = SigningIdentity::new(chain, alg::ES256, "k1", 0).unwrap_err();
+ assert!(error.contains("must not be a CA"), "{error}");
+ }
+
+ #[test]
+ fn an_unsizable_algorithm_is_refused_rather_than_guessed() {
+ // Reserving the wrong number of bytes would corrupt the hard binding's
+ // offsets, so an algorithm this build cannot size has to be a setup
+ // error rather than a silent default.
+ let chain = vec![testpki::leaf_der()];
+ let error = SigningIdentity::new(chain, -99, "k1", 0).unwrap_err();
+ assert!(error.contains("not one this build can size"), "{error}");
+ }
+}
diff --git a/crates/imagecore/src/c2pa/manifest.rs b/crates/imagecore/src/c2pa/manifest.rs
index cede403..2c9b7a8 100644
--- a/crates/imagecore/src/c2pa/manifest.rs
+++ b/crates/imagecore/src/c2pa/manifest.rs
@@ -8,44 +8,61 @@
//! built, which cannot happen until the hash exists.
//!
//! Section 10.4 of the specification breaks the loop with exclusion ranges and
-//! fixed-width placeholders. This module does it in two renders:
+//! fixed-width placeholders. Signing happens on a different machine, which adds
+//! a second seam: the claim has to be finished and handed out before the
+//! signature comes back. So the flow is three renders across two calls:
//!
//! ```text
-//! render 1 everything real except: hash = 32 zero bytes
-//! exclusion = (0, 0)
-//! signature = 64 zero bytes
-//! │
-//! ├── gives the store's exact final size, because every
-//! │ placeholder is the same width as the real value
-//! ▼
-//! measure exclusion = (insertion offset, embedded length)
-//! hash = SHA-256 of the file with that range removed,
-//! which is just head ++ tail
-//! │
-//! ▼
-//! render 2 the same manifest with real values substituted in, asserted
-//! to be byte-for-byte the same length as render 1
+//! prepare ─┬─ render 1 everything real except: hash = 32 zero bytes
+//! │ exclusion = (0, 0)
+//! │ signature = a placeholder
+//! │ │
+//! │ ├── gives the store's exact final size,
+//! │ │ because the signature box is reserved at
+//! │ │ a fixed width whatever comes back
+//! │ ▼
+//! ├─ measure exclusion = (insertion offset, embedded length)
+//! │ hash = SHA-256 of the file with that range
+//! │ removed - head ++ tail
+//! │ │
+//! │ ▼
+//! └─ render 2 the same manifest with the real hash, still with
+//! the placeholder signature. Its claim bytes are what
+//! gets signed.
+//! │
+//! ═════════════╪═════════ the Backend signs, and asks a
+//! │ time-stamping authority to stamp
+//! ▼ the signature
+//! complete ── render 3 identical again, with the real COSE_Sign1 padded
+//! back to the reserved width, then embedded
//! ```
//!
-//! Three properties make the placeholders exact, and each is enforced rather
+//! Four properties make the placeholders exact, and each is enforced rather
//! than assumed:
//!
//! - `start` and `length` are written as 32-bit CBOR integers whatever their
//! value ([`cbor::Value::Uint32`]). Section 18.5.2 asks for precisely this.
-//! - SHA-256 is always 32 bytes and an ES256 signature always 64.
+//! - SHA-256 is always 32 bytes, and the signature length is fixed by the
+//! algorithm in the signing identity.
+//! - The time-stamp, whose size nobody can predict, sits in a reserved slot the
+//! COSE `pad` field shrinks to absorb — see [`super::cose`].
//! - Every value that would otherwise vary between renders — timestamps, the
//! instance ID, the manifest URN — is computed once by the caller and passed
-//! in, so the two renders differ only where they are meant to.
+//! in, so the renders differ only where they are meant to.
//!
-//! The final `debug_assert_eq!` on the two lengths is the backstop. If a future
+//! The `debug_assert_eq!` on the render lengths is the backstop. If a future
//! change breaks one of those properties, it fails there rather than producing
//! a manifest whose offsets are quietly wrong.
use sha2::{Digest, Sha256};
use super::cbor::Value;
+use super::clock::{self, Instant};
+use super::identity::SigningIdentity;
use super::jumbf::{self, Child, Superbox};
-use super::{cose, jpegxt, signer, x509};
+use super::timestamp::{self, Verdict};
+use super::trust::{self, Purpose, TrustStore};
+use super::{cose, jpegxt, x509};
/// The hash algorithm identifier used throughout, from the C2PA registry.
const ALG: &str = "sha256";
@@ -94,14 +111,23 @@ fn assertion_uri(assertion: &Superbox) -> Value {
pub struct GeneratorInfo {
pub name: String,
pub version: String,
+ /// The specification version this manifest was produced to.
+ ///
+ /// The Conformance Program requires this to match the version on the
+ /// product's Conforming Products List record — see [`super::SPEC_VERSION`].
+ pub spec_version: Option,
}
impl GeneratorInfo {
fn to_value(&self) -> Value {
- Value::Map(vec![
+ let mut fields = vec![
(Value::text("name"), Value::text(self.name.clone())),
(Value::text("version"), Value::text(self.version.clone())),
- ])
+ ];
+ if let Some(spec) = &self.spec_version {
+ fields.push((Value::text("specVersion"), Value::text(spec.clone())));
+ }
+ Value::Map(fields)
}
}
@@ -112,6 +138,9 @@ pub struct Action {
pub action: String,
/// Free text shown to a person; important for entity-specific actions.
pub description: Option,
+ /// The IPTC digital source type. Mandatory on most predefined actions and
+ /// forbidden on `c2pa.opened` — see [`super::requires_digital_source_type`].
+ pub digital_source_type: Option,
/// Extra `parameters-map-v2` entries, e.g. the new dimensions of a resize.
pub parameters: Vec<(String, Value)>,
}
@@ -121,6 +150,7 @@ impl Action {
Action {
action: action.into(),
description: None,
+ digital_source_type: None,
parameters: Vec::new(),
}
}
@@ -130,6 +160,11 @@ impl Action {
self
}
+ pub fn source_type(mut self, source_type: impl Into) -> Self {
+ self.digital_source_type = Some(source_type.into());
+ self
+ }
+
pub fn param(mut self, key: impl Into, value: Value) -> Self {
self.parameters.push((key.into(), value));
self
@@ -160,8 +195,8 @@ pub struct ParentStore {
/// supplied by the caller.
///
/// The host owning the clock and the randomness is not an accident of the
-/// wasm target having neither: it is also what makes the two renders described
-/// at the top of this module produce identical bytes, and what makes the tests
+/// wasm target having neither: it is also what makes the renders described at
+/// the top of this module produce identical bytes, and what makes the tests
/// reproducible.
#[derive(Clone, Debug)]
pub struct SignRequest {
@@ -194,6 +229,12 @@ struct Blueprint<'a> {
thumbnail: Option,
}
+/// One render: the finished store, plus the claim bytes inside it.
+struct Rendered {
+ store: Vec,
+ claim: Vec,
+}
+
impl<'a> Blueprint<'a> {
fn new(request: &'a SignRequest) -> Result {
let mut inherited = Vec::new();
@@ -227,13 +268,9 @@ impl<'a> Blueprint<'a> {
}
/// Render the whole store. `hash` and `exclusion` fill the hard binding;
- /// `sign` turns claim bytes into a `COSE_Sign1`.
- fn render(
- &self,
- hash: &[u8],
- exclusion: (u32, u32),
- sign: &dyn Fn(&[u8]) -> Result>,
- ) -> Result> {
+ /// `signature_box` is the serialised `COSE_Sign1`, real or placeholder, and
+ /// must be the same length in every render.
+ fn render(&self, hash: &[u8], exclusion: (u32, u32), signature_box: &[u8]) -> Rendered {
let mut assertions: Vec = Vec::new();
if let Some(thumbnail) = &self.thumbnail {
assertions.push(thumbnail.clone());
@@ -249,21 +286,19 @@ impl<'a> Blueprint<'a> {
store_box.push(Child::Super(assertion.clone()));
}
- let claim = self.build_claim(&assertions);
- let claim_bytes = claim.encode();
- let signature = sign(&claim_bytes)?;
+ let claim_bytes = self.build_claim(&assertions).encode();
let manifest = Superbox::new(jumbf::UUID_MANIFEST, self.request.manifest_id.clone())
.with_child(Child::Super(store_box))
.with_child(Child::Super(Superbox::cbor(
jumbf::UUID_CLAIM,
LABEL_CLAIM,
- claim_bytes,
+ claim_bytes.clone(),
)))
.with_child(Child::Super(Superbox::cbor(
jumbf::UUID_SIGNATURE,
LABEL_SIGNATURE,
- signature,
+ signature_box.to_vec(),
)));
let mut store = Superbox::new(jumbf::UUID_MANIFEST_STORE, "c2pa");
@@ -273,7 +308,10 @@ impl<'a> Blueprint<'a> {
// The active manifest is the last one in the store (section 11.1.2).
store.push(Child::Super(manifest));
- Ok(store.to_bytes())
+ Rendered {
+ store: store.to_bytes(),
+ claim: claim_bytes,
+ }
}
fn build_actions(&self) -> Superbox {
@@ -294,6 +332,12 @@ impl<'a> Blueprint<'a> {
if let Some(description) = &action.description {
fields.push((Value::text("description"), Value::text(description.clone())));
}
+ if let Some(source_type) = &action.digital_source_type {
+ fields.push((
+ Value::text("digitalSourceType"),
+ Value::text(source_type.clone()),
+ ));
+ }
let mut parameters: Vec<(Value, Value)> = action
.parameters
@@ -328,7 +372,9 @@ impl<'a> Blueprint<'a> {
Value::Array(vec![self.request.generator.to_value()]),
),
// The editor knows every operation it performed, so it can say so.
- // Section 18.10: this asserts nothing else happened off the record.
+ // Section 18.10 makes this optional; the Conformance Program makes
+ // it mandatory, because an asset rubric cannot classify provenance
+ // that might be incomplete without saying which it is.
(Value::text("allActionsIncluded"), Value::Bool(true)),
]);
@@ -453,20 +499,54 @@ pub struct Signed {
pub manifest_len: usize,
/// Total bytes the credential added to the file.
pub embedded_len: usize,
+ /// Whether the finished signature carries a time-stamp.
+ pub time_stamped: bool,
}
-/// Sign a JPEG: build a manifest for it, and embed the result.
-pub fn sign_jpeg(jpeg: &[u8], request: &SignRequest) -> Result {
- let credentials = signer::load()?;
- let blueprint = Blueprint::new(request)?;
+/// A manifest built up to the point where only a signature is missing.
+///
+/// Holding the stripped JPEG and the request means [`complete`] reproduces
+/// exactly the bytes [`prepare`] measured, which is what keeps the hard
+/// binding's offsets correct across the network round trip in between.
+pub struct Prepared {
+ request: SignRequest,
+ identity: SigningIdentity,
+ plan: jpegxt::InsertionPlan,
+ hash: Vec,
+ exclusion: (u32, u32),
+ reserved: usize,
+ embedded_len: usize,
+ /// The `Sig_structure` the Backend signs. This is the *only* thing that
+ /// leaves the tab: a few hundred bytes of claim, never the image.
+ pub to_be_signed: Vec,
+ /// The claim as it will appear in the file, for callers that want to show
+ /// what is about to be signed.
+ pub claim: Vec,
+}
- // Render 1 measures. Placeholders are the same width as real values, so
+impl Prepared {
+ /// How much room the time-stamp has. A caller that gets
+ /// [`cose::ERR_RESERVATION_TOO_SMALL`] back from [`complete`] should prepare
+ /// again with more than this.
+ pub fn timestamp_budget(&self) -> usize {
+ self.identity.timestamp_budget
+ }
+}
+
+/// Build a manifest for `jpeg` and stop just short of signing it.
+pub fn prepare(jpeg: &[u8], request: SignRequest, identity: SigningIdentity) -> Result {
+ let reserved = cose::reserved_len(&identity).map_err(|e| e.to_string())?;
+ let placeholder = cose::placeholder(&identity).map_err(|e| e.to_string())?;
+ debug_assert_eq!(placeholder.len(), reserved);
+
+ let blueprint = Blueprint::new(&request)?;
+
+ // Render 1 measures. The signature box is reserved at its final width, so
// this size is final.
- let placeholder_signature = cose::placeholder(&credentials.chain).map_err(|e| e.to_string())?;
- let measured = blueprint.render(&[0u8; 32], (0, 0), &|_| Ok(placeholder_signature.clone()))?;
+ let measured = blueprint.render(&[0u8; 32], (0, 0), &placeholder);
let plan = jpegxt::plan_insertion(jpeg).map_err(|e| e.to_string())?;
- let embedded_len = jpegxt::embedded_length(measured.len());
+ let embedded_len = jpegxt::embedded_length(measured.store.len());
let exclusion = (
u32::try_from(plan.offset).map_err(|_| "the JPEG is too large to sign".to_string())?,
@@ -482,27 +562,80 @@ pub fn sign_jpeg(jpeg: &[u8], request: &SignRequest) -> Result {
binding.update(&plan.stripped[plan.offset..]);
let hash = binding.finalize().to_vec();
- // Render 2 substitutes the real values in.
- let store = blueprint.render(&hash, exclusion, &|claim_bytes| {
- cose::sign(claim_bytes, &credentials.key, &credentials.chain).map_err(|e| e.to_string())
- })?;
+ // Render 2 substitutes the real hard binding in. Its claim is what gets
+ // signed.
+ let rendered = blueprint.render(&hash, exclusion, &placeholder);
+ if rendered.store.len() != measured.store.len() {
+ return Err("the manifest changed size while being prepared".into());
+ }
+
+ let protected = cose::protected_bytes(&identity);
+ let to_be_signed = cose::sig_structure(&protected, &rendered.claim);
+
+ Ok(Prepared {
+ request,
+ identity,
+ plan,
+ hash,
+ exclusion,
+ reserved,
+ embedded_len,
+ to_be_signed,
+ claim: rendered.claim,
+ })
+}
+
+/// Finish a prepared manifest with the signature the Backend returned, and
+/// embed it.
+///
+/// `timestamp_token` is the DER `TimeStampToken` from the time-stamping
+/// authority, or `None` when the Backend could not obtain one — in which case
+/// the manifest is written anyway and the reserved space becomes padding. A
+/// missing time-stamp costs long-term validity, not validity today, and
+/// refusing to save the image over it would be the wrong trade.
+pub fn complete(
+ prepared: &Prepared,
+ signature: &[u8],
+ timestamp_token: Option<&[u8]>,
+) -> Result {
+ let expected = prepared.identity.signature_len();
+ if signature.len() != expected {
+ return Err(format!(
+ "the signer returned {} bytes, but a {} signature is {expected}",
+ signature.len(),
+ super::identity::alg::name(prepared.identity.algorithm)
+ ));
+ }
+
+ let protected = cose::protected_bytes(&prepared.identity);
+ let signature_box = cose::assemble(
+ &protected,
+ signature,
+ timestamp_token,
+ Some(prepared.reserved),
+ )
+ .map_err(|e| e.to_string())?;
+
+ let blueprint = Blueprint::new(&prepared.request)?;
+ let rendered = blueprint.render(&prepared.hash, prepared.exclusion, &signature_box);
// If this ever fires, a placeholder stopped matching the width of the value
// it stands in for, and every offset in the hard binding is wrong.
debug_assert_eq!(
- store.len(),
- measured.len(),
+ jpegxt::embedded_length(rendered.store.len()),
+ prepared.embedded_len,
"manifest size changed between renders"
);
- if store.len() != measured.len() {
+ if jpegxt::embedded_length(rendered.store.len()) != prepared.embedded_len {
return Err("the manifest changed size while being signed".into());
}
- let signed = jpegxt::embed(&plan, &store).map_err(|e| e.to_string())?;
+ let signed = jpegxt::embed(&prepared.plan, &rendered.store).map_err(|e| e.to_string())?;
Ok(Signed {
jpeg: signed,
- manifest_len: store.len(),
- embedded_len,
+ manifest_len: rendered.store.len(),
+ embedded_len: prepared.embedded_len,
+ time_stamped: timestamp_token.is_some(),
})
}
@@ -556,6 +689,39 @@ impl StatusCodes {
pub fn is_valid(&self) -> bool {
self.failure.is_empty()
}
+
+ fn extend(&mut self, other: StatusCodes) {
+ self.success.extend(other.success);
+ self.informational.extend(other.informational);
+ self.failure.extend(other.failure);
+ }
+}
+
+/// What a validator was given to work with.
+///
+/// These are exactly the inputs the Conformance Program's test harness has to
+/// accept: an asset, a C2PA Trust List, a C2PA TSA Trust List, and a validation
+/// time. Nothing here is discovered at run time, which is what makes a
+/// validation run reproducible and its crJSON output comparable.
+#[derive(Clone, Debug)]
+pub struct ValidationOptions {
+ pub trust: TrustStore,
+ pub tsa_trust: TrustStore,
+ /// RFC 3339 instant to judge certificate validity at, when no trusted
+ /// time-stamp overrides it.
+ pub validation_time: Instant,
+}
+
+impl ValidationOptions {
+ /// Validation with no trust lists: the signature and the hashes are
+ /// checked, and every signer is reported untrusted.
+ pub fn untrusted(validation_time: Instant) -> Self {
+ ValidationOptions {
+ trust: TrustStore::empty(),
+ tsa_trust: TrustStore::empty(),
+ validation_time,
+ }
+ }
}
/// One action, as read back out of a manifest.
@@ -566,6 +732,7 @@ pub struct ReadAction {
pub when: String,
pub description: String,
pub software_agent: String,
+ pub digital_source_type: String,
}
/// What a manifest says, and what checking it produced.
@@ -576,6 +743,8 @@ pub struct ManifestReport {
pub title: String,
pub instance_id: String,
pub generator: String,
+ /// The `specVersion` the generator declared, when it declared one.
+ pub spec_version: String,
pub claim_version: u8,
pub actions: Vec,
pub ingredients: Vec,
@@ -587,6 +756,21 @@ pub struct ManifestReport {
#[serde(skip)]
pub thumbnail: Option>,
pub status: StatusCodes,
+ /// The decoded claim and assertions, kept for the crJSON serialiser. Not
+ /// part of the JSON the interface receives — it would double its size for
+ /// no reader's benefit.
+ #[serde(skip)]
+ pub raw: RawManifest,
+}
+
+/// The manifest as CBOR, for serialisations that need the whole thing.
+#[derive(Clone, Debug, Default)]
+pub struct RawManifest {
+ pub claim: Option,
+ pub claim_label: String,
+ pub assertions: Vec<(String, Option)>,
+ pub chain: Vec>,
+ pub timestamp_token: Option>,
}
#[derive(Clone, Debug, Default, serde::Serialize)]
@@ -596,9 +780,21 @@ pub struct SignatureReport {
pub issuer: String,
pub subject: String,
pub subject_organisation: String,
+ pub serial_number: String,
pub not_before: String,
pub not_after: String,
+ /// The attested time, when a trusted time-stamp was found.
pub time_stamped: bool,
+ pub time_stamp: String,
+ pub time_stamp_authority: String,
+ /// Whether the chain reached an anchor on the supplied C2PA Trust List.
+ pub trusted: bool,
+ pub trust_anchor: String,
+ /// From the `c2pa-al` extension: the Assurance Level the Conformance
+ /// Program granted the Generator Product that signed this.
+ pub assurance_level: Option,
+ /// From the `c2pa-cpl-record` extension.
+ pub cpl_record_id: String,
}
#[derive(Clone, Debug, Default, serde::Serialize)]
@@ -623,6 +819,9 @@ pub struct ValidationReport {
#[serde(skip)]
pub store: Vec,
pub store_len: usize,
+ /// The instant validation was performed at, echoed so a report can be
+ /// reproduced.
+ pub validation_time: String,
}
impl ValidationReport {
@@ -638,18 +837,26 @@ impl ValidationReport {
/// Whether the file passed every check that was applied.
///
- /// Trust is deliberately not part of this. A validator with no trust anchor
- /// store cannot say whether a signer should be believed, only whether the
- /// bytes are intact and the signature is internally consistent.
+ /// Trust is part of this only when a trust list was supplied: with no list,
+ /// the signer is reported as unverified in the informational codes and the
+ /// integrity result stands on its own.
pub fn is_valid(&self) -> bool {
self.active.status.is_valid()
}
}
+/// Read and check the Content Credentials in a JPEG, with no trust list.
+///
+/// `now` is what certificate validity is judged against. WebAssembly has no
+/// clock, so the host supplies it — which also makes every test reproducible.
+pub fn read_jpeg(jpeg: &[u8], now: Instant) -> Result