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> { + validate_jpeg(jpeg, &ValidationOptions::untrusted(now)) +} + /// Read and check the Content Credentials in a JPEG. /// /// `Ok(None)` means the file simply has none. -pub fn read_jpeg(jpeg: &[u8]) -> Result> { +pub fn validate_jpeg(jpeg: &[u8], options: &ValidationOptions) -> Result> { let Some(embedded) = jpegxt::extract(jpeg).map_err(|e| e.to_string())? else { return Ok(None); }; @@ -668,7 +875,7 @@ pub fn read_jpeg(jpeg: &[u8]) -> Result> { let mut chain = Vec::new(); for manifest in &manifests { - chain.push(inspect(manifest)); + chain.push(inspect(manifest, options)); } // The active manifest is the last one, and it is the only one whose hard @@ -676,10 +883,7 @@ pub fn read_jpeg(jpeg: &[u8]) -> Result> { // they came from, so re-checking their bindings here would be meaningless. let active_index = chain.len() - 1; let binding = check_hard_binding(jpeg, manifests[active_index], &embedded); - let active_status = &mut chain[active_index].status; - active_status.success.extend(binding.success); - active_status.informational.extend(binding.informational); - active_status.failure.extend(binding.failure); + chain[active_index].status.extend(binding); let active = chain[active_index].clone(); Ok(Some(ValidationReport { @@ -687,18 +891,21 @@ pub fn read_jpeg(jpeg: &[u8]) -> Result> { chain, store: embedded.store, store_len: embedded.length, + validation_time: clock::to_rfc3339(options.validation_time), })) } /// Read one manifest and check everything internal to it: that each assertion -/// hashes to what the claim says, and that the claim matches its signature. -fn inspect(manifest: &Superbox) -> ManifestReport { +/// hashes to what the claim says, that the claim matches its signature, and +/// that the signer chains to a trust anchor. +fn inspect(manifest: &Superbox, options: &ValidationOptions) -> ManifestReport { let mut status = StatusCodes::default(); let mut report = ManifestReport { label: manifest.label.clone(), title: String::new(), instance_id: String::new(), generator: String::new(), + spec_version: String::new(), claim_version: 2, actions: Vec::new(), ingredients: Vec::new(), @@ -706,14 +913,19 @@ fn inspect(manifest: &Superbox) -> ManifestReport { signature: SignatureReport::default(), thumbnail: None, status: StatusCodes::default(), + raw: RawManifest::default(), }; let claim_box = manifest .child(LABEL_CLAIM) - .inspect(|_| report.claim_version = 2) + .inspect(|_| { + report.claim_version = 2; + report.raw.claim_label = LABEL_CLAIM.to_string(); + }) .or_else(|| { manifest.child(LABEL_CLAIM_V1).inspect(|_| { report.claim_version = 1; + report.raw.claim_label = LABEL_CLAIM_V1.to_string(); }) }); @@ -756,6 +968,11 @@ fn inspect(manifest: &Superbox) -> ManifestReport { .unwrap_or_default() .to_string(); report.generator = describe_generator(&claim); + report.spec_version = generator_info(&claim) + .and_then(|info| info.get("specVersion").and_then(Value::as_text)) + .unwrap_or_default() + .to_string(); + report.raw.claim = Some(claim.clone()); let assertion_store = manifest.child(LABEL_ASSERTIONS); if let Some(assertions) = assertion_store { @@ -763,6 +980,15 @@ fn inspect(manifest: &Superbox) -> ManifestReport { report.thumbnail = assertions .child(LABEL_THUMBNAIL) .and_then(|b| b.embedded_file().map(|(_, data)| data.to_vec())); + report.raw.assertions = assertions + .child_boxes() + .map(|box_| { + let decoded = box_ + .cbor_payload() + .and_then(|bytes| super::cbor::decode(bytes).ok()); + (box_.label.clone(), decoded) + }) + .collect(); read_actions(assertions, &mut report); read_ingredients(assertions, &mut report); } else { @@ -773,7 +999,7 @@ fn inspect(manifest: &Superbox) -> ManifestReport { } check_assertion_hashes(&claim, assertion_store, &mut status); - check_signature(manifest, claim_bytes, &mut report, &mut status); + check_signature(manifest, claim_bytes, options, &mut report, &mut status); // A standard manifest must carry exactly one hard binding (section 11.2.1). if manifest.uuid == jumbf::UUID_MANIFEST @@ -792,16 +1018,17 @@ fn inspect(manifest: &Superbox) -> ManifestReport { report } -fn describe_generator(claim: &Value) -> String { +fn generator_info(claim: &Value) -> Option<&Value> { // Claim v2 has a single generator-info-map; v1 had an array plus a // free-text `claim_generator` string. - let info = claim.get("claim_generator_info"); - let map = match info { + match claim.get("claim_generator_info") { Some(Value::Array(items)) => items.first(), other => other, - }; + } +} - if let Some(map) = map { +fn describe_generator(claim: &Value) -> String { + if let Some(map) = generator_info(claim) { let name = map.get("name").and_then(Value::as_text).unwrap_or_default(); let version = map.get("version").and_then(Value::as_text); if !name.is_empty() { @@ -885,6 +1112,11 @@ fn read_actions(assertions: &Superbox, report: &mut ManifestReport) { .unwrap_or_default() .to_string(), software_agent, + digital_source_type: item + .get("digitalSourceType") + .and_then(Value::as_text) + .unwrap_or_default() + .to_string(), }); } } @@ -978,9 +1210,13 @@ fn check_assertion_hashes(claim: &Value, assertions: Option<&Superbox>, status: } } +/// Check the claim signature: the algorithm, the signature itself, the +/// time-stamp, the trust path and the validity window, in the order section +/// 15.7 and 15.8 prescribe. fn check_signature( manifest: &Superbox, claim_bytes: &[u8], + options: &ValidationOptions, report: &mut ManifestReport, status: &mut StatusCodes, ) { @@ -1006,12 +1242,25 @@ fn check_signature( }; report.signature.algorithm = parsed.algorithm_name().to_string(); - report.signature.time_stamped = false; + report.raw.chain = parsed.chain.clone(); + report.raw.timestamp_token = parsed.timestamp_token.clone(); + + if !super::verify::is_allowed_cose_algorithm(parsed.algorithm) { + status.failure.push(Status::new( + "algorithm.unsupported", + format!( + "{} is not on the allowed signature algorithm list", + parsed.algorithm_name() + ), + )); + return; + } if let Some(certificate) = parsed.chain.first() { if let Ok(certificate) = x509::parse_certificate(certificate) { report.signature.subject = certificate.subject.clone(); report.signature.subject_organisation = certificate.subject_organisation.clone(); + report.signature.serial_number = certificate.serial.clone(); report.signature.issuer = if certificate.issuer_common_name.is_empty() { certificate.issuer.clone() } else { @@ -1019,9 +1268,114 @@ fn check_signature( }; report.signature.not_before = certificate.not_before.clone(); report.signature.not_after = certificate.not_after.clone(); + report.signature.assurance_level = certificate.c2pa_assurance_level; + report.signature.cpl_record_id = + certificate.c2pa_cpl_record_id.clone().unwrap_or_default(); + } + } + + // Section 15.8: the time-stamp is checked first, because a trusted one + // moves the instant the signing certificate's validity is judged at. + if parsed.timestamp_ambiguous { + status.informational.push(Status::new( + "timestamp.malformed", + "the signature carries more than one time-stamp token", + )); + } + let mut judged_at = options.validation_time; + if let Some(token) = &parsed.timestamp_token { + // sigTst carries a whole TimeStampResp; sigTst2 the bare token. + let token = if parsed.timestamp_is_v1 { + match timestamp::token_from_response(token) { + Ok(inner) => inner, + Err(why) => { + status + .informational + .push(Status::new("timestamp.malformed", why)); + Vec::new() + } + } + } else { + token.clone() + }; + + if !token.is_empty() { + let verdict = timestamp::check(&token, &parsed.signature, &options.tsa_trust); + match &verdict { + Verdict::Trusted { at, authority } => { + judged_at = *at; + report.signature.time_stamped = true; + report.signature.time_stamp = clock::to_rfc3339(*at); + report.signature.time_stamp_authority = authority.clone(); + status + .success + .push(Status::new("timeStamp.trusted", verdict.explanation())); + status + .success + .push(Status::new("timeStamp.validated", verdict.explanation())); + } + other => status + .informational + .push(Status::new(other.code(), other.explanation())), + } + } + } + + // Section 15.7: the trust path, then the signature itself. + let outcome = trust::evaluate( + &options.trust, + &parsed.chain, + judged_at, + Purpose::ClaimSigning, + ); + report.signature.trusted = outcome.trusted; + report.signature.trust_anchor = outcome.anchor.clone().unwrap_or_default(); + + if outcome.trusted { + status.success.push(Status::new( + "signingCredential.trusted", + outcome.reason.clone(), + )); + if outcome.inside_validity { + status.success.push(Status::new( + "claimSignature.insideValidity", + format!( + "the signing certificate was valid at {}", + clock::to_rfc3339(judged_at) + ), + )); + } else { + status.failure.push(Status::new( + "claimSignature.outsideValidity", + format!( + "the signing certificate was not valid at {}", + clock::to_rfc3339(judged_at) + ), + )); } + } else if options.trust.is_empty() { + // No trust list is a different situation from a signer that failed + // against one, and reporting them the same way would either + // over-warn or under-warn depending on which was true. + status.informational.push(Status::new( + "signingCredential.untrusted", + "no trust list was supplied, so the signer's identity was not checked", + )); + } else { + status.failure.push(Status::new( + "signingCredential.untrusted", + outcome.reason.clone(), + )); } + // Revocation. C2PA carries it as a stapled OCSP response captured at + // signing time; there is none here, and no live fetch is possible from a + // browser tab, so this is reported rather than assumed either way. + status.informational.push(Status::new( + "signingCredential.ocsp.skipped", + "no stapled revocation response was present", + )); + match cose::verify(&parsed, claim_bytes) { Ok(()) => status.success.push(Status::new( "claimSignature.validated", @@ -1031,14 +1385,6 @@ fn check_signature( .failure .push(Status::new("claimSignature.mismatch", e.to_string())), } - - // Say plainly what has not been established. This validator has no trust - // anchor store, so it cannot tell a real signer from an impostor, and - // reporting the signature as simply "valid" would overstate the result. - status.informational.push(Status::new( - "signingCredential.untrusted", - "the signer was not checked against any trust list", - )); } /// Recompute the hard binding: hash the file with the manifest's own bytes diff --git a/crates/imagecore/src/c2pa/mod.rs b/crates/imagecore/src/c2pa/mod.rs index f973363..2b20c50 100644 --- a/crates/imagecore/src/c2pa/mod.rs +++ b/crates/imagecore/src/c2pa/mod.rs @@ -1,19 +1,36 @@ -//! Content Credentials: a browser-side C2PA claim generator. +//! Content Credentials: the Edge half of a C2PA claim generator. //! -//! This writes and reads C2PA 2.2 manifests for JPEG files, entirely inside the -//! tab. Nothing is uploaded, and no reference implementation is linked in — the -//! JUMBF containers, deterministic CBOR, COSE signatures and JPEG embedding are -//! all built from the specification. The submodules are, in dependency order: +//! This writes and reads C2PA 2.2 manifests for JPEG files. The image never +//! leaves the tab; the *claim* is signed by `services/claim-signer`, which +//! receives a 90-byte digest structure and returns a signature. No reference +//! implementation is linked in — the JUMBF containers, deterministic CBOR, COSE +//! signatures, RFC 3161 time-stamp handling, certificate path validation and +//! JPEG embedding are all built from the specification. The submodules are, in +//! dependency order: //! //! | Module | Specification | //! |---|---| //! | [`cbor`] | RFC 8949, incl. the deterministic encoding of clause 4.2.1 | +//! | [`clock`] | RFC 3339 and ASN.1 times, reduced to comparable instants | +//! | [`der`] | Enough DER to write an RFC 3161 request | //! | [`jumbf`] | ISO/IEC 19566-5 boxes; C2PA §11.1 labels and UUIDs | //! | [`jpegxt`] | C2PA §A.3.1 `APP11` embedding; §18.5.3 exclusion rules | -//! | [`x509`] | Enough DER to read a certificate (RFC 5280) | +//! | [`x509`] | RFC 5280 certificates, plus the C2PA Certificate Policy extensions | +//! | [`verify`] | Signature checking for every algorithm §13.2.1 allows | +//! | [`trust`] | Path validation against a C2PA Trust List | +//! | [`timestamp`] | RFC 3161 tokens; C2PA §15.8 | +//! | [`identity`] | The public half of the signing credential | //! | [`cose`] | RFC 8152 `COSE_Sign1`, RFC 9360 `x5chain`; C2PA §13.2 | -//! | [`signer`] | The build's key material | //! | [`manifest`] | C2PA §10 claims, §18 assertions, §15 validation | +//! | [`crjson`] | The crJSON validation-result serialisation | +//! +//! # Where the signing key is +//! +//! Not here. Not anywhere in this crate, and not in the WebAssembly module the +//! browser downloads. See [`identity`] for why that is a hard requirement of +//! the C2PA Conformance Program rather than a preference, and +//! `conformance/generator-product-security-architecture.md` for the whole +//! Target of Evaluation. //! //! # Why JPEG only //! @@ -23,53 +40,103 @@ //! 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 the //! most detail, and they are what the overwhelming majority of C2PA tooling -//! reads today. +//! reads today. The Conforming Products List records exactly which media types +//! a Generator Product asserts, so claiming one and doing it properly is also +//! the shape the programme expects. //! -//! Doing this properly for one format teaches more than doing it loosely for -//! six. Every other output format the editor supports keeps working exactly as -//! it did — it just does not get a credential, and the interface says so rather +//! Every other output format the editor supports keeps working exactly as it +//! did — it just does not get a credential, and the interface says so rather //! than leaving the option greyed out with no explanation. -//! -//! # What this proves and what it does not -//! -//! A credential written here genuinely establishes that the pixels have not -//! changed since signing, and genuinely records what the editor did to them. It -//! does not establish *who* signed: the key ships inside the page, so anyone can -//! produce a manifest bearing this signer's name. See `signing/README.md`. -//! -//! Two things a production claim generator would add are missing for reasons -//! that are about the environment rather than effort: an RFC 3161 time-stamp -//! (§10.3.2.5) and a stapled OCSP response (§10.3.2.6). Both need a network -//! round-trip to a third party while signing, which an app whose whole premise -//! is that nothing leaves the tab cannot make. Their absence is reported to the -//! user rather than hidden. pub mod cbor; +pub mod clock; pub mod cose; +pub mod crjson; +pub mod der; +pub mod identity; pub mod jpegxt; pub mod jumbf; pub mod manifest; -pub mod signer; +pub mod timestamp; +pub mod trust; +pub mod verify; pub mod x509; +#[cfg(any(test, feature = "test-pki"))] +pub mod testpki; + use cbor::Value; use manifest::{Action, GeneratorInfo}; +pub use crjson::to_crjson; +pub use identity::{SignerDescription, SigningIdentity}; pub use manifest::{ - read_jpeg, sign_jpeg, IngredientReport, ManifestReport, Parent, ParentStore, SignRequest, - Signed, ValidationReport, + read_jpeg, validate_jpeg, IngredientReport, ManifestReport, Parent, ParentStore, Prepared, + SignRequest, Signed, ValidationOptions, ValidationReport, }; +pub use trust::TrustStore; use crate::pipeline::Pipeline; -/// The IPTC digital source type for an image a human captured or edited, as -/// opposed to one a model generated. Recording it is how a viewer can tell the -/// difference at a glance. +/// The version of the C2PA Content Credentials specification this generator +/// writes to. +/// +/// The Conformance Program's *Additional Conformance Requirements* make this a +/// contract rather than a note: the value recorded in `claim_generator_info` +/// has to match the version asserted on the Program Intake Form and shown on +/// the Conforming Products List record. Changing it here without changing the +/// listing would put the product out of conformance, which is why it is one +/// constant rather than a string repeated at each use. +pub const SPEC_VERSION: &str = "2.2"; + +/// The IPTC digital source type for an image a human captured, as opposed to +/// one a model generated. /// /// const SOURCE_TYPE_DIGITAL_CAPTURE: &str = "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"; +/// "Augmentation, correction or enhancement by one or more humans using +/// non-generative tools" — which is precisely what this editor does. Every +/// operation it offers is a classical image-processing kernel driven by a +/// person; nothing here is generative, and recording that explicitly is the +/// point of the field. +const SOURCE_TYPE_HUMAN_EDITS: &str = "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"; + +/// Actions the Conformance Program excepts from the `digitalSourceType` +/// requirement. +/// +/// From *Additional Conformance Requirements Against the Content Credentials +/// Specification* v0.2: the field is required in every pre-defined action +/// carried in a created assertion except these. `c2pa.opened` goes further — +/// for spec 2.4 a separate requirement *prohibits* the field there, because +/// opening a byte stream has no source type to speak of. +const NO_DIGITAL_SOURCE_TYPE: &[&str] = &[ + "c2pa.converted", + "c2pa.edited.metadata", + "c2pa.enhanced", + "c2pa.opened", + "c2pa.placed", + "c2pa.published", + "c2pa.redacted", + "c2pa.repackaged", + "c2pa.resized.proportional", + "c2pa.transcoded", + "c2pa.watermarked", + "c2pa.watermarked.bound", + "c2pa.watermarked.unbound", +]; + +/// Whether a `digitalSourceType` is required on this action. +pub fn requires_digital_source_type(action: &str) -> bool { + action.starts_with("c2pa.") && !NO_DIGITAL_SOURCE_TYPE.contains(&action) +} + +/// Whether a `digitalSourceType` is forbidden on this action. +pub fn forbids_digital_source_type(action: &str) -> bool { + action == "c2pa.opened" +} + /// Whether a manifest can be written for this output format. /// /// See the module docs for why this is JPEG alone. @@ -82,6 +149,7 @@ pub fn generator() -> GeneratorInfo { GeneratorInfo { name: "A10city Image Editor".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), + spec_version: Some(SPEC_VERSION.to_string()), } } @@ -104,13 +172,11 @@ pub fn actions_for(pipeline: &Pipeline, opened: bool, output: (u32, u32)) -> Vec if opened { // Must be the first element, and must point at a parentOf ingredient. - // `manifest` wires up the ingredient reference itself. + // `manifest` wires up the ingredient reference itself. No + // digitalSourceType: the Conformance Program prohibits one here. actions.push(Action::new("c2pa.opened")); } else { - actions.push(Action::new("c2pa.created").param( - "digitalSourceType", - Value::text(SOURCE_TYPE_DIGITAL_CAPTURE), - )); + actions.push(Action::new("c2pa.created").source_type(SOURCE_TYPE_DIGITAL_CAPTURE)); } if pipeline.flip_h || pipeline.flip_v || !pipeline.quarter_turns.is_multiple_of(4) { @@ -211,6 +277,19 @@ pub fn actions_for(pipeline: &Pipeline, opened: bool, output: (u32, u32)) -> Vec ); } + // Every editing action this product performs is a human driving a classical + // filter, so they all carry the same source type. Applying it here rather + // than at each construction site means a new action added above cannot + // silently omit a field the Conformance Program requires. + for action in &mut actions { + if requires_digital_source_type(&action.action) && action.digital_source_type.is_none() { + action.digital_source_type = Some(SOURCE_TYPE_HUMAN_EDITS.to_string()); + } + if forbids_digital_source_type(&action.action) { + action.digital_source_type = None; + } + } + actions } @@ -254,6 +333,16 @@ mod tests { } } + #[test] + fn the_generator_declares_the_specification_version() { + // Required by the Conformance Program's additional requirements, and + // it has to match the Conforming Products List record. + let generator = generator(); + assert_eq!(generator.spec_version.as_deref(), Some("2.2")); + assert!(!generator.name.is_empty()); + assert!(!generator.version.is_empty()); + } + #[test] fn an_opened_file_starts_with_c2pa_opened() { // Section 18.10.2 requires this as the first element, and section @@ -263,14 +352,103 @@ mod tests { assert_eq!(actions[0].action, "c2pa.opened"); } + #[test] + fn c2pa_opened_never_carries_a_digital_source_type() { + // Prohibited outright by the Conformance Program: opening a byte + // stream has no source type to declare. + let actions = actions_for(&empty(), true, (100, 100)); + assert_eq!(actions[0].digital_source_type, None); + } + #[test] fn a_new_file_starts_with_c2pa_created() { let actions = actions_for(&empty(), false, (100, 100)); assert_eq!(actions[0].action, "c2pa.created"); - assert!(actions[0] - .parameters - .iter() - .any(|(key, _)| key == "digitalSourceType")); + assert_eq!( + actions[0].digital_source_type.as_deref(), + Some(SOURCE_TYPE_DIGITAL_CAPTURE) + ); + } + + #[test] + fn every_action_that_needs_a_digital_source_type_has_one() { + // This is the check that keeps a newly added action from quietly + // failing conformance: it walks whatever `actions_for` produced rather + // than a list written out by hand. + let pipeline = Pipeline { + crop: Some(Crop { + x: 1, + y: 2, + width: 30, + height: 40, + }), + quarter_turns: 1, + flip_h: true, + angle: -2.5, + resize: Some(Resize { + width: 15, + height: 20, + filter: "lanczos3".into(), + }), + adjust: crate::pipeline::AdjustSpec { + brightness: 0.1, + blur: 1.5, + sharpen: 0.8, + ..Default::default() + }, + ..Default::default() + }; + + for action in actions_for(&pipeline, true, (15, 20)) { + if requires_digital_source_type(&action.action) { + assert!( + action.digital_source_type.is_some(), + "{} must carry a digitalSourceType", + action.action + ); + } + if forbids_digital_source_type(&action.action) { + assert!( + action.digital_source_type.is_none(), + "{} must not carry a digitalSourceType", + action.action + ); + } + } + } + + #[test] + fn the_excepted_actions_are_the_ones_the_programme_lists() { + assert!(!requires_digital_source_type("c2pa.enhanced")); + assert!(!requires_digital_source_type("c2pa.opened")); + assert!(!requires_digital_source_type("c2pa.resized.proportional")); + assert!(requires_digital_source_type("c2pa.resized")); + assert!(requires_digital_source_type("c2pa.cropped")); + assert!(requires_digital_source_type("c2pa.filtered")); + // Entity-specific actions are outside the requirement entirely. + assert!(!requires_digital_source_type("com.a10city.something")); + } + + #[test] + fn nothing_generative_is_ever_claimed() { + // The whole reason the Conformance Program made this field mandatory + // is so a reader can tell generative AI from a person with a crop tool. + let pipeline = Pipeline { + adjust: crate::pipeline::AdjustSpec { + blur: 2.0, + ..Default::default() + }, + ..Default::default() + }; + for action in actions_for(&pipeline, true, (10, 10)) { + if let Some(source) = &action.digital_source_type { + assert!( + source.ends_with("humanEdits") || source.ends_with("digitalCapture"), + "{} claims {source}", + action.action + ); + } + } } #[test] @@ -364,7 +542,7 @@ mod tests { }; for action in actions_for(&pipeline, true, (10, 10)) { for (key, _) in &action.parameters { - let known = ["ingredients", "digitalSourceType", "description"]; + let known = ["ingredients", "description"]; assert!( known.contains(&key.as_str()) || key.starts_with("com.a10city."), "parameter {key} is neither predefined nor namespaced" diff --git a/crates/imagecore/src/c2pa/signer.rs b/crates/imagecore/src/c2pa/signer.rs deleted file mode 100644 index 46bc541..0000000 --- a/crates/imagecore/src/c2pa/signer.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! The signing credentials this build carries. -//! -//! The PEM comes from `build.rs`, which reads the `C2PA_SIGNING_CERT` and -//! `C2PA_SIGNING_KEY` environment variables when both are set and falls back to -//! the demo files in `signing/` otherwise. -//! -//! **This key is public and cannot be otherwise.** The engine is compiled to -//! WebAssembly and served to browsers, so whatever key it holds is downloadable -//! by anyone who loads the page. That is a property of signing on the client, -//! not a corner cut here: there is no way to give a browser the ability to sign -//! without also giving it the means. `signing/README.md` works through what -//! GitHub Actions secrets do and do not change about that, and sketches the two -//! designs (remote signing, per-user certificates) that produce credentials -//! anyone should actually trust. -//! -//! What still holds with a public key is worth being precise about, because it -//! is not nothing: the hard binding proves the pixels have not changed since -//! signing, and the actions describe what the editor did. What does not hold is -//! *identity* — anybody can produce a manifest bearing this signer's name. The -//! UI says so on every credential it writes. - -use p256::ecdsa::SigningKey; -use p256::pkcs8::DecodePrivateKey; - -include!(concat!(env!("OUT_DIR"), "/signing_credentials.rs")); - -/// A loaded key with the certificate chain that goes in `x5chain`. -pub struct Credentials { - pub key: SigningKey, - /// DER certificates, end-entity first. The trust anchor is not included, - /// per C2PA 2.2 section 13.2.2. - pub chain: Vec>, -} - -/// Parse the compiled-in credentials. -pub fn load() -> Result { - let key = SigningKey::from_pkcs8_pem(SIGNING_KEY_PEM) - .map_err(|e| format!("the signing key is not a usable PKCS#8 P-256 key: {e}"))?; - - let chain = super::x509::pem_to_der(SIGNING_CERT_CHAIN_PEM) - .map_err(|e| format!("the signing certificate chain could not be read: {e}"))?; - - // A chain whose leaf does not match the key would produce signatures that - // fail against the certificate shipped beside them. Catching it here turns - // a confusing downstream validation failure into a build-time-obvious one. - let leaf = chain - .first() - .ok_or_else(|| "the certificate chain is empty".to_string())?; - let certificate = - super::x509::parse_certificate(leaf).map_err(|e| format!("signing certificate: {e}"))?; - let expected = key.verifying_key().to_encoded_point(false); - if certificate.public_key != expected.as_bytes() { - return Err("the signing key does not match its certificate".into()); - } - - Ok(Credentials { key, chain }) -} - -/// How this build's signer describes itself, for the UI. -pub fn describe() -> Result { - let chain = super::x509::pem_to_der(SIGNING_CERT_CHAIN_PEM) - .map_err(|e| format!("the signing certificate chain could not be read: {e}"))?; - let leaf = super::x509::parse_certificate(&chain[0]).map_err(|e| e.to_string())?; - - // The anchor is optional: a build supplying its own chain through the - // environment need not include one. - let root = super::x509::pem_to_der(SIGNING_ROOT_CA_PEM) - .ok() - .and_then(|der| { - der.first() - .and_then(|d| super::x509::parse_certificate(d).ok()) - }); - - Ok(SignerDescription { - common_name: leaf.subject_common_name, - organisation: leaf.subject_organisation, - issuer: if leaf.issuer_common_name.is_empty() { - leaf.issuer - } else { - leaf.issuer_common_name - }, - not_after: leaf.not_after, - extended_key_usage: leaf.extended_key_usage, - anchor_is_self_signed: root.map(|r| r.issuer == r.subject).unwrap_or(false), - credential_source: SIGNING_CREDENTIAL_SOURCE, - }) -} - -pub struct SignerDescription { - pub common_name: String, - pub organisation: String, - pub issuer: String, - pub not_after: String, - pub extended_key_usage: Vec, - /// True when the chain terminates in a self-signed root, which is the - /// giveaway that no public CA is involved. - pub anchor_is_self_signed: bool, - /// `"repository"` or `"environment"`, per `build.rs`. - pub credential_source: &'static str, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_shipped_credentials_load() { - let credentials = load().expect("build.rs should have supplied working credentials"); - assert!(!credentials.chain.is_empty()); - } - - #[test] - fn the_key_matches_its_certificate() { - // `load` enforces this; the test is here so the failure names the cause - // if someone regenerates one file without the other. - let credentials = load().unwrap(); - let certificate = super::super::x509::parse_certificate(&credentials.chain[0]).unwrap(); - assert_eq!( - certificate.public_key, - credentials - .key - .verifying_key() - .to_encoded_point(false) - .as_bytes() - ); - } - - #[test] - fn the_chain_omits_the_trust_anchor() { - // Section 13.2.2: x5chain carries the signer and intermediates, never - // the root. With a two-deep chain that means exactly one certificate. - let credentials = load().unwrap(); - assert_eq!(credentials.chain.len(), 1); - - let leaf = super::super::x509::parse_certificate(&credentials.chain[0]).unwrap(); - assert!(!leaf.is_ca, "the end-entity certificate must not be a CA"); - } - - #[test] - fn describe_reports_an_untrusted_self_signed_anchor() { - let described = describe().unwrap(); - assert!(!described.common_name.is_empty()); - assert!( - described.anchor_is_self_signed, - "the demo anchor is self-signed, and the UI depends on knowing that" - ); - assert_eq!(described.credential_source, "repository"); - } -} diff --git a/crates/imagecore/src/c2pa/testpki.rs b/crates/imagecore/src/c2pa/testpki.rs new file mode 100644 index 0000000..285e59d --- /dev/null +++ b/crates/imagecore/src/c2pa/testpki.rs @@ -0,0 +1,242 @@ +//! The test PKI, for tests and for the conformance harness. Never for release. +//! +//! Everything in here is behind the `test-pki` feature, which nothing in the +//! shipping WebAssembly build enables. That gate is the point: it is what makes +//! "the Edge subsystem holds no private key" a property the compiler enforces +//! rather than a claim in a document. A release `wasm-pack build` links none of +//! this, and `cargo tree --no-default-features` will show no key type reachable +//! from the library target. +//! +//! The certificates come from `conformance/test-credentials/generate.sh`, which +//! builds them to the same profile the C2PA Certificate Policy defines for a +//! real Assurance Level 1 claim signing certificate. Testing against a +//! correctly shaped certificate is the only way to know the extension parsing, +//! the 366-day expiry handling and the time-stamp fallback all work before a +//! real certificate arrives. + +use super::identity::{alg, SigningIdentity}; +use super::x509; + +pub const CLAIM_SIGNER_CHAIN_PEM: &str = + include_str!("../../../../conformance/test-credentials/c2pa-test-claim-signer-chain.pem"); +pub const CLAIM_SIGNER_KEY_PEM: &str = + include_str!("../../../../conformance/test-credentials/c2pa-test-claim-signer.key"); +pub const ROOT_CA_PEM: &str = + include_str!("../../../../conformance/test-credentials/c2pa-test-root-ca.pem"); +pub const ISSUING_CA_PEM: &str = + include_str!("../../../../conformance/test-credentials/c2pa-test-issuing-ca.pem"); +pub const TRUST_LIST_PEM: &str = + include_str!("../../../../conformance/test-credentials/c2pa-test-trust-list.pem"); +pub const TSA_TRUST_LIST_PEM: &str = + include_str!("../../../../conformance/test-credentials/c2pa-test-tsa-trust-list.pem"); +pub const TSA_SIGNER_PEM: &str = + include_str!("../../../../conformance/test-credentials/tsa-test-signer.pem"); +pub const TSA_SIGNER_KEY_PEM: &str = + include_str!("../../../../conformance/test-credentials/tsa-test-signer.key"); + +/// Where the PEM files live, for tests that need to hand a path to a harness. +/// +/// `CARGO_MANIFEST_DIR` is `crates/imagecore`, so the repository root is two +/// levels up. Resolved rather than joined blindly, because the path is handed +/// to a subprocess whose working directory is not this one. +pub fn directory() -> std::path::PathBuf { + let relative = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../conformance/test-credentials"); + std::fs::canonicalize(&relative).unwrap_or(relative) +} + +fn first(pem: &str) -> Vec { + x509::pem_to_der(pem) + .expect("test PEM should parse") + .remove(0) +} + +pub fn leaf_der() -> Vec { + first(CLAIM_SIGNER_CHAIN_PEM) +} + +pub fn issuing_ca_der() -> Vec { + first(ISSUING_CA_PEM) +} + +pub fn root_ca_der() -> Vec { + first(ROOT_CA_PEM) +} + +pub fn tsa_signer_der() -> Vec { + first(TSA_SIGNER_PEM) +} + +/// The full `x5chain`: leaf then issuing CA, never the root. +pub fn chain() -> Vec> { + x509::pem_to_der(CLAIM_SIGNER_CHAIN_PEM).expect("test chain should parse") +} + +/// A [`SigningIdentity`] over the test chain, with room reserved for a +/// time-stamp so the padded-signature path is the one under test. +pub fn identity() -> SigningIdentity { + SigningIdentity::new( + chain(), + alg::ES256, + "test-key-1", + super::cose::TIMESTAMP_BUDGET, + ) + .expect("the test chain should load as a signing identity") +} + +/// The same identity with no time-stamp reservation, for exercising the +/// untimestamped path. +pub fn identity_without_timestamps() -> SigningIdentity { + SigningIdentity::new(chain(), alg::ES256, "test-key-1", 0) + .expect("the test chain should load as a signing identity") +} + +/// The claim signing key. Exists only in test builds. +pub fn signing_key() -> p256::ecdsa::SigningKey { + use p256::pkcs8::DecodePrivateKey; + p256::ecdsa::SigningKey::from_pkcs8_pem(CLAIM_SIGNER_KEY_PEM) + .expect("the test signing key should load") +} + +/// The time-stamping authority's key. +pub fn tsa_key() -> p256::ecdsa::SigningKey { + use p256::pkcs8::DecodePrivateKey; + p256::ecdsa::SigningKey::from_pkcs8_pem(TSA_SIGNER_KEY_PEM) + .expect("the test TSA key should load") +} + +/// Sign the way the Backend subsystem would: raw ES256 over the bytes handed +/// in, returning the 64-byte `r || s` a `COSE_Sign1` carries. +pub fn sign_es256(bytes: &[u8]) -> Vec { + use p256::ecdsa::signature::Signer; + let signature: p256::ecdsa::Signature = signing_key().sign(bytes); + signature.to_bytes().to_vec() +} + +/// An instant inside the test leaf's validity window. +/// +/// The test certificates last 366 days, exactly as the Assurance Level 1 +/// profile requires, so a fixed date in a test would start failing a year after +/// someone regenerated them. Deriving the validation time from the certificate +/// keeps the suite honest about expiry without making it a time bomb. +pub fn validation_time() -> i64 { + let leaf = x509::parse_certificate(&leaf_der()).expect("test leaf should parse"); + leaf.not_before_at + 86_400 +} + +/// An instant after the test leaf has expired, for the expiry paths. +pub fn after_expiry() -> i64 { + let leaf = x509::parse_certificate(&leaf_der()).expect("test leaf should parse"); + leaf.not_after_at + 86_400 +} + +/* ------------------------------------------------------------------------- +A stand-in time-stamping authority. + +Mocking the time-stamp would have tested the mock. What the validator has to +cope with is a real RFC 3161 token: CMS SignedData wrapping a TSTInfo, signed +over DER-encoded signed attributes rather than over the payload directly. +Issuing one here is about sixty lines and means the parsing, the attribute +handling and the trust path are all exercised by the suite. +------------------------------------------------------------------------- */ + +use super::der; + +const OID_SIGNED_DATA: &str = "1.2.840.113549.1.7.2"; +const OID_TST_INFO: &str = "1.2.840.113549.1.9.16.1.4"; +const OID_SHA256: &str = "2.16.840.1.101.3.4.2.1"; +const OID_ECDSA_SHA256: &str = "1.2.840.10045.4.3.2"; +const OID_ATTR_CONTENT_TYPE: &str = "1.2.840.113549.1.9.3"; +const OID_ATTR_MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4"; +/// An arbitrary policy identifier under the test arc. +const OID_TEST_TSA_POLICY: &str = "1.3.6.1.4.1.62558.99.1"; + +fn sha256(bytes: &[u8]) -> Vec { + use sha2::Digest; + sha2::Sha256::digest(bytes).to_vec() +} + +/// Issue an RFC 3161 `TimeStampToken` over `stamped`, attesting `at`. +pub fn issue_timestamp(stamped: &[u8], at: i64) -> Vec { + let tst_info = der::sequence(&[ + der::integer(1), // version + der::oid(OID_TEST_TSA_POLICY), // policy + der::sequence(&[ + // messageImprint + der::algorithm_with_null(OID_SHA256), + der::octet_string(&sha256(stamped)), + ]), + der::integer(1), // serialNumber + der::generalized_time(at), // genTime + der::boolean(false), // ordering + ]); + + // RFC 5652 section 5.4: the signature covers the signed attributes encoded + // as a SET OF, even though they travel under an implicit [0] tag. + let signed_attrs = der::set_of(&[ + der::sequence(&[ + der::oid(OID_ATTR_CONTENT_TYPE), + der::set_of(&[der::oid(OID_TST_INFO)]), + ]), + der::sequence(&[ + der::oid(OID_ATTR_MESSAGE_DIGEST), + der::set_of(&[der::octet_string(&sha256(&tst_info))]), + ]), + ]); + let signature = sign_der_ecdsa(&tsa_key(), &signed_attrs); + + let certificate = x509::pem_to_der(TSA_SIGNER_PEM).unwrap().remove(0); + let signer = x509::parse_certificate(&certificate).unwrap(); + + let signer_info = der::sequence(&[ + der::integer(1), + der::sequence(&[ + signer.issuer_der.clone(), + der::tlv(0x02, &signer.serial_bytes), + ]), + der::algorithm_with_null(OID_SHA256), + der::implicit_constructed(0, &signed_attrs), + der::algorithm(OID_ECDSA_SHA256), + der::octet_string(&signature), + ]); + + let signed_data = der::sequence(&[ + der::integer(3), + der::set_of(&[der::algorithm_with_null(OID_SHA256)]), + der::sequence(&[ + der::oid(OID_TST_INFO), + der::explicit(0, &der::octet_string(&tst_info)), + ]), + der::implicit_constructed(0, &der::set_of(&[certificate])), + der::set_of(&[signer_info]), + ]); + + der::sequence(&[der::oid(OID_SIGNED_DATA), der::explicit(0, &signed_data)]) +} + +/// Wrap a token in the `TimeStampResp` the deprecated `sigTst` header carries. +pub fn wrap_timestamp_response(token: &[u8], status: u64) -> Vec { + der::sequence(&[der::sequence(&[der::integer(status)]), token.to_vec()]) +} + +/// ECDSA over SHA-256 with the DER `SEQUENCE { r, s }` encoding X.509 uses. +fn sign_der_ecdsa(key: &p256::ecdsa::SigningKey, message: &[u8]) -> Vec { + use p256::ecdsa::signature::Signer; + let signature: p256::ecdsa::Signature = key.sign(message); + let bytes = signature.to_bytes(); + let (r, s) = bytes.split_at(32); + der::sequence(&[der_integer(r), der_integer(s)]) +} + +/// A DER INTEGER from a fixed-width big-endian value. +fn der_integer(value: &[u8]) -> Vec { + let trimmed = value + .iter() + .position(|b| *b != 0) + .unwrap_or(value.len() - 1); + let mut body = value[trimmed..].to_vec(); + if body[0] & 0x80 != 0 { + body.insert(0, 0); + } + der::tlv(0x02, &body) +} diff --git a/crates/imagecore/src/c2pa/timestamp.rs b/crates/imagecore/src/c2pa/timestamp.rs new file mode 100644 index 0000000..82ac007 --- /dev/null +++ b/crates/imagecore/src/c2pa/timestamp.rs @@ -0,0 +1,584 @@ +//! RFC 3161 time-stamps: reading them, and checking they say what they claim. +//! +//! # Why a browser claim generator needs these now +//! +//! It did not, while the signing certificate was one this repository minted +//! and could date twenty years out. A C2PA claim signing certificate issued +//! under the Certificate Policy at Assurance Level 1 is capped at 366 days, +//! and section 15.8 is unambiguous about what happens next: with no trusted +//! time-stamp, a manifest is judged against the validity window *at the moment +//! someone looks at it*, so every image the editor ever signed would start +//! failing the day the certificate expired. +//! +//! A time-stamp fixes that permanently. Once a validator has a trusted +//! `genTime`, it judges the signing certificate at that instant instead — +//! "this was signed while the certificate was live" stays true forever. +//! +//! Obtaining one is the Backend's job, because it is a network round trip and +//! because RFC 3161 stamps the *signature*, which only the Backend has. What +//! this module does is the reading half: pull the token apart, verify the CMS +//! signature over it, check the imprint really covers this manifest's +//! signature, and hand back the attested time for [`super::trust`] to judge the +//! signing certificate at. +//! +//! # Structure +//! +//! ```text +//! TimeStampToken = ContentInfo { id-signedData, SignedData } +//! │ +//! ┌─────────────────────────────────────────┤ +//! │ encapContentInfo: id-ct-TSTInfo, eContent = DER TSTInfo +//! │ certificates: the TSA's certificate and its issuers +//! │ signerInfos: one SignerInfo over the signed attributes +//! ▼ +//! TSTInfo { version, policy, messageImprint, serialNumber, genTime, ... } +//! ``` +//! +//! The signature does not cover `eContent` directly. It covers the DER of the +//! signed attributes, one of which is a digest of `eContent` — so both have to +//! be checked, and checking only the signature is a classic CMS mistake that +//! leaves the payload swappable. + +use super::clock::Instant; +use super::trust::{self, Purpose, TrustStore}; +use super::verify; +use super::x509::{self, Certificate}; + +const OID_SIGNED_DATA: &str = "1.2.840.113549.1.7.2"; +const OID_TST_INFO: &str = "1.2.840.113549.1.9.16.1.4"; +const OID_ATTR_MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4"; +const OID_ATTR_CONTENT_TYPE: &str = "1.2.840.113549.1.9.3"; + +const TAG_INTEGER: u8 = 0x02; +const TAG_OCTET_STRING: u8 = 0x04; +const TAG_OID: u8 = 0x06; +const TAG_GENERALIZED_TIME: u8 = 0x18; +const TAG_SEQUENCE: u8 = 0x30; +const TAG_SET: u8 = 0x31; + +/// Everything a validator needs out of a time-stamp token, once it has been +/// pulled apart but before any of it is believed. +#[derive(Clone, Debug)] +pub struct TimeStamp { + /// The attested time. + pub gen_time: Instant, + /// Digest algorithm OID from `messageImprint`. + pub imprint_algorithm: String, + /// The digest the authority says it stamped. + pub imprint: Vec, + /// Certificates the token carried, signer first where it could be + /// identified. + pub certificates: Vec>, + /// The TSA's own signing certificate, located by the `SignerInfo`. + pub signer: Certificate, +} + +/// How a time-stamp came out, in the vocabulary section 15.8.2 uses. +/// +/// Every failure here is *informational*: an unusable time-stamp is ignored and +/// the manifest falls back to being judged at the current time, rather than +/// being rejected. Getting that wrong would reject good manifests because a TSA +/// changed a certificate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Verdict { + /// `timeStamp.trusted` and `timeStamp.validated`. + Trusted { at: Instant, authority: String }, + /// `timestamp.malformed` + Malformed(String), + /// `timestamp.mismatch` + Mismatch(String), + /// `timestamp.untrusted` + Untrusted(String), + /// `timestamp.outsideValidity` + OutsideValidity(String), +} + +impl Verdict { + /// The C2PA status code this verdict is reported under. + pub fn code(&self) -> &'static str { + match self { + Verdict::Trusted { .. } => "timeStamp.validated", + Verdict::Malformed(_) => "timestamp.malformed", + Verdict::Mismatch(_) => "timestamp.mismatch", + Verdict::Untrusted(_) => "timestamp.untrusted", + Verdict::OutsideValidity(_) => "timestamp.outsideValidity", + } + } + + pub fn explanation(&self) -> String { + match self { + Verdict::Trusted { at, authority } => format!( + "time-stamped at {} by {authority}", + super::clock::to_rfc3339(*at) + ), + Verdict::Malformed(why) + | Verdict::Mismatch(why) + | Verdict::Untrusted(why) + | Verdict::OutsideValidity(why) => why.clone(), + } + } + + pub fn attested_time(&self) -> Option { + match self { + Verdict::Trusted { at, .. } => Some(*at), + _ => None, + } + } +} + +/// Unwrap an RFC 3161 `TimeStampResp` down to its `timeStampToken`. +/// +/// The deprecated `sigTst` header carries the whole response; `sigTst2` carries +/// the token alone. +pub fn token_from_response(der: &[u8]) -> Result, String> { + let outer = x509::read_tlv(der).map_err(|e| e.to_string())?; + let parts = x509::children(outer.value).map_err(|e| e.to_string())?; + let status = parts + .first() + .ok_or_else(|| "the time-stamp response has no status".to_string())?; + let status_value = x509::children(status.value) + .map_err(|e| e.to_string())? + .into_iter() + .next() + .filter(|t| t.tag == TAG_INTEGER) + .map(|t| { + t.value + .iter() + .fold(0u32, |acc, b| (acc << 8) | u32::from(*b)) + }) + .ok_or_else(|| "the time-stamp response status is unreadable".to_string())?; + + // 0 granted, 1 grantedWithMods. Anything else means the authority declined. + if status_value > 1 { + return Err(format!( + "the time-stamp authority returned PKIStatus {status_value}" + )); + } + + let (start, token) = x509::children_with_offsets(outer.value) + .map_err(|e| e.to_string())? + .into_iter() + .nth(1) + .ok_or_else(|| "the time-stamp response carries no token".to_string())?; + Ok(outer.value[start..start + token.total].to_vec()) +} + +/// Read a `TimeStampToken` and verify its own CMS signature. +/// +/// This establishes that the authority named inside really signed this +/// `TSTInfo`. It says nothing about whether that authority should be trusted, +/// nor whether the imprint matches anything in particular; [`check`] does both. +pub fn parse(token: &[u8]) -> Result { + let content_info = x509::read_tlv(token).map_err(|e| e.to_string())?; + if content_info.tag != TAG_SEQUENCE { + return Err("a time-stamp token must be a ContentInfo SEQUENCE".into()); + } + let ci = x509::children(content_info.value).map_err(|e| e.to_string())?; + let content_type = ci + .first() + .filter(|t| t.tag == TAG_OID) + .map(|t| x509::oid_to_string(t.value)) + .unwrap_or_default(); + if content_type != OID_SIGNED_DATA { + return Err(format!( + "a time-stamp token must hold signedData, not {content_type}" + )); + } + + // content [0] EXPLICIT SignedData + let wrapper = ci + .get(1) + .filter(|t| t.tag == 0xA0) + .ok_or_else(|| "the token has no signedData content".to_string())?; + let signed_data = x509::read_tlv(wrapper.value).map_err(|e| e.to_string())?; + let sd = x509::children(signed_data.value).map_err(|e| e.to_string())?; + + // version, digestAlgorithms, encapContentInfo, [0] certificates, + // [1] crls, signerInfos. + let encap = sd + .get(2) + .filter(|t| t.tag == TAG_SEQUENCE) + .ok_or_else(|| "the token has no encapContentInfo".to_string())?; + let encap_parts = x509::children(encap.value).map_err(|e| e.to_string())?; + let econtent_type = encap_parts + .first() + .filter(|t| t.tag == TAG_OID) + .map(|t| x509::oid_to_string(t.value)) + .unwrap_or_default(); + if econtent_type != OID_TST_INFO { + return Err(format!( + "the encapsulated content is {econtent_type}, not a TSTInfo" + )); + } + let econtent_octets = encap_parts + .get(1) + .filter(|t| t.tag == 0xA0) + .and_then(|t| x509::read_tlv(t.value).ok()) + .filter(|t| t.tag == TAG_OCTET_STRING) + .ok_or_else(|| "the token carries no TSTInfo".to_string())?; + let tst_info = econtent_octets.value.to_vec(); + + let certificates: Vec> = sd + .iter() + .find(|t| t.tag == 0xA0) + .map(|set| { + x509::children_with_offsets(set.value) + .map(|items| { + items + .into_iter() + .filter(|(_, t)| t.tag == TAG_SEQUENCE) + .map(|(start, t)| set.value[start..start + t.total].to_vec()) + .collect() + }) + .unwrap_or_default() + }) + .unwrap_or_default(); + + let signer_infos = sd + .iter() + .rev() + .find(|t| t.tag == TAG_SET) + .ok_or_else(|| "the token has no signerInfos".to_string())?; + let signer_info = x509::children(signer_infos.value) + .map_err(|e| e.to_string())? + .into_iter() + .next() + .ok_or_else(|| "the token has no SignerInfo".to_string())?; + + let signer = verify_signer_info(signer_info.value, &tst_info, &certificates)?; + let (gen_time, imprint_algorithm, imprint) = parse_tst_info(&tst_info)?; + + Ok(TimeStamp { + gen_time, + imprint_algorithm, + imprint, + certificates, + signer, + }) +} + +/// Verify the `SignerInfo`, returning the certificate that made the signature. +fn verify_signer_info( + bytes: &[u8], + econtent: &[u8], + certificates: &[Vec], +) -> Result { + let parts = x509::children_with_offsets(bytes).map_err(|e| e.to_string())?; + // version, sid, digestAlgorithm, [0] signedAttrs, signatureAlgorithm, + // signature, [1] unsignedAttrs. + let sid = parts + .get(1) + .map(|(_, t)| *t) + .ok_or_else(|| "the SignerInfo has no signer identifier".to_string())?; + let digest_algorithm = parts + .get(2) + .and_then(|(_, t)| x509::children(t.value).ok()) + .and_then(|c| c.first().map(|o| x509::oid_to_string(o.value))) + .ok_or_else(|| "the SignerInfo has no digest algorithm".to_string())?; + + let signed_attrs = parts.iter().find(|(_, t)| t.tag == 0xA0); + + // The signature algorithm is the last SEQUENCE before the signature OCTET + // STRING, and the signature is the last OCTET STRING. + let signature_algorithm = parts + .iter() + .rfind(|(_, t)| t.tag == TAG_SEQUENCE) + .and_then(|(_, t)| x509::children(t.value).ok()) + .and_then(|c| c.first().map(|o| x509::oid_to_string(o.value))) + .ok_or_else(|| "the SignerInfo has no signature algorithm".to_string())?; + let signature = parts + .iter() + .rfind(|(_, t)| t.tag == TAG_OCTET_STRING) + .map(|(_, t)| t.value.to_vec()) + .ok_or_else(|| "the SignerInfo has no signature".to_string())?; + + let certificate = locate_signer(sid, certificates)?; + + let Some((attrs_start, attrs)) = signed_attrs.copied() else { + // Without signed attributes the signature covers eContent directly. + // Legal in CMS, and RFC 3161 section 2.4.2 does not forbid it. + return verify::by_x509_algorithm(&signature_algorithm, &certificate, econtent, &signature) + .map(|()| certificate) + .map_err(|e| format!("the time-stamp signature does not verify: {e}")); + }; + + // The messageDigest attribute has to match the content, or the signature + // proves nothing about the TSTInfo that was actually delivered. + let expected = verify::digest_by_oid(&digest_algorithm, econtent) + .ok_or_else(|| format!("unsupported digest algorithm {digest_algorithm}"))?; + let attributes = x509::children(attrs.value).map_err(|e| e.to_string())?; + let mut saw_content_type = false; + let mut matched_digest = false; + for attribute in attributes { + let fields = x509::children(attribute.value).map_err(|e| e.to_string())?; + let Some(oid) = fields.first().filter(|t| t.tag == TAG_OID) else { + continue; + }; + let oid = x509::oid_to_string(oid.value); + let value = fields + .get(1) + .filter(|t| t.tag == TAG_SET) + .and_then(|t| x509::children(t.value).ok()) + .and_then(|mut v| { + if v.is_empty() { + None + } else { + Some(v.remove(0)) + } + }); + match oid.as_str() { + OID_ATTR_MESSAGE_DIGEST => { + if let Some(value) = value { + matched_digest = value.value == expected.as_slice(); + } + } + OID_ATTR_CONTENT_TYPE => saw_content_type = true, + _ => {} + } + } + if !saw_content_type { + return Err("the signed attributes omit the content type".into()); + } + if !matched_digest { + return Err("the signed message digest does not cover this TSTInfo".into()); + } + + // RFC 5652 section 5.4: the signature covers the signed attributes DER + // encoded as an explicit SET OF, not with the [0] IMPLICIT tag they carry + // inside the SignerInfo. + let mut to_verify = bytes[attrs_start..attrs_start + attrs.total].to_vec(); + to_verify[0] = TAG_SET; + + verify::by_x509_algorithm(&signature_algorithm, &certificate, &to_verify, &signature) + .map(|()| certificate) + .map_err(|e| format!("the time-stamp signature does not verify: {e}")) +} + +/// Find the certificate a `SignerIdentifier` points at. +fn locate_signer(sid: x509::Tlv<'_>, certificates: &[Vec]) -> Result { + let parsed: Vec = certificates + .iter() + .filter_map(|der| x509::parse_certificate(der).ok()) + .collect(); + + match sid.tag { + // subjectKeyIdentifier [0] IMPLICIT OCTET STRING + 0x80 => parsed + .into_iter() + .find(|c| c.subject_key_identifier.as_deref() == Some(sid.value)) + .ok_or_else(|| "the token names a signer it does not carry".to_string()), + // issuerAndSerialNumber ::= SEQUENCE { issuer Name, serialNumber INTEGER } + TAG_SEQUENCE => { + let fields = x509::children_with_offsets(sid.value).map_err(|e| e.to_string())?; + let (issuer_start, issuer) = *fields + .first() + .ok_or_else(|| "the signer identifier has no issuer".to_string())?; + let issuer_der = sid.value[issuer_start..issuer_start + issuer.total].to_vec(); + let serial = fields + .get(1) + .filter(|(_, t)| t.tag == TAG_INTEGER) + .map(|(_, t)| { + t.value + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":") + }) + .unwrap_or_default(); + parsed + .into_iter() + .find(|c| c.issuer_der == issuer_der && c.serial == serial) + .ok_or_else(|| "the token names a signer it does not carry".to_string()) + } + other => Err(format!("unsupported signer identifier tag 0x{other:02X}")), + } +} + +/// Pull `genTime` and `messageImprint` out of a `TSTInfo`. +fn parse_tst_info(der: &[u8]) -> Result<(Instant, String, Vec), String> { + let sequence = x509::read_tlv(der).map_err(|e| e.to_string())?; + let fields = x509::children(sequence.value).map_err(|e| e.to_string())?; + + // version, policy, messageImprint, serialNumber, genTime, ... + let imprint = fields + .get(2) + .filter(|t| t.tag == TAG_SEQUENCE) + .ok_or_else(|| "the TSTInfo has no messageImprint".to_string())?; + let imprint_parts = x509::children(imprint.value).map_err(|e| e.to_string())?; + let algorithm = imprint_parts + .first() + .and_then(|t| x509::children(t.value).ok()) + .and_then(|c| c.first().map(|o| x509::oid_to_string(o.value))) + .ok_or_else(|| "the messageImprint has no hash algorithm".to_string())?; + let hashed = imprint_parts + .get(1) + .filter(|t| t.tag == TAG_OCTET_STRING) + .map(|t| t.value.to_vec()) + .ok_or_else(|| "the messageImprint has no hashed message".to_string())?; + + let gen_time = fields + .iter() + .find(|t| t.tag == TAG_GENERALIZED_TIME) + .and_then(|t| x509::decode_time_instant(t)) + .ok_or_else(|| "the TSTInfo has no readable genTime".to_string())?; + + Ok((gen_time, algorithm, hashed)) +} + +/// The full section 15.8.2 procedure for one time-stamp. +/// +/// `stamped` is the value the imprint should cover: for `sigTst2` that is the +/// `COSE_Sign1` signature field. +pub fn check(token: &[u8], stamped: &[u8], tsa_trust: &TrustStore) -> Verdict { + let parsed = match parse(token) { + Ok(parsed) => parsed, + // A signature that does not verify is a mismatch; anything structural + // is malformed. `parse` reports the difference in its message. + Err(why) if why.contains("does not verify") => return Verdict::Mismatch(why), + Err(why) => return Verdict::Malformed(why), + }; + + let Some(expected) = verify::digest_by_oid(&parsed.imprint_algorithm, stamped) else { + return Verdict::Untrusted(format!( + "the imprint uses {}, which is not on the allowed hash list", + parsed.imprint_algorithm + )); + }; + if expected != parsed.imprint { + return Verdict::Mismatch( + "the time-stamp covers something other than this signature".into(), + ); + } + + let outcome = trust::evaluate( + tsa_trust, + &parsed.certificates_signer_first(), + parsed.gen_time, + Purpose::TimeStamping, + ); + if !outcome.trusted { + return Verdict::Untrusted(outcome.reason); + } + // Section 15.8.2: the attested time must fall inside the TSA certificate's + // own window. A time-stamp remains usable after the TSA's certificate + // expires, which is why this is judged at genTime rather than now. + if !outcome.inside_validity { + return Verdict::OutsideValidity( + "the attested time falls outside the authority's certificate validity".into(), + ); + } + + Verdict::Trusted { + at: parsed.gen_time, + authority: if parsed.signer.subject_common_name.is_empty() { + parsed.signer.subject.clone() + } else { + parsed.signer.subject_common_name.clone() + }, + } +} + +impl TimeStamp { + /// The token's certificates with the signer at the front, which is the + /// order path validation expects. + fn certificates_signer_first(&self) -> Vec> { + let mut out = Vec::with_capacity(self.certificates.len()); + let signer_tbs = &self.signer.tbs; + for der in &self.certificates { + match x509::parse_certificate(der) { + Ok(parsed) if &parsed.tbs == signer_tbs => out.insert(0, der.clone()), + _ => out.push(der.clone()), + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::c2pa::testpki; + + #[test] + fn a_token_from_the_test_authority_validates() { + let stamped = b"a COSE signature, as far as the authority is concerned"; + let token = testpki::issue_timestamp(stamped, testpki::validation_time()); + let (tsa_store, _) = TrustStore::from_pem(testpki::TSA_TRUST_LIST_PEM).unwrap(); + + match check(&token, stamped, &tsa_store) { + Verdict::Trusted { at, authority } => { + assert_eq!(at, testpki::validation_time()); + assert!(authority.contains("Timestamp Authority"), "{authority}"); + } + other => panic!("expected a trusted time-stamp, got {other:?}"), + } + } + + #[test] + fn a_token_over_different_bytes_is_a_mismatch() { + let token = testpki::issue_timestamp(b"one thing", testpki::validation_time()); + let (tsa_store, _) = TrustStore::from_pem(testpki::TSA_TRUST_LIST_PEM).unwrap(); + assert!(matches!( + check(&token, b"another thing", &tsa_store), + Verdict::Mismatch(_) + )); + } + + #[test] + fn an_authority_that_is_not_on_the_tsa_list_is_untrusted() { + let stamped = b"bytes"; + let token = testpki::issue_timestamp(stamped, testpki::validation_time()); + // The claim signing trust list is a real list; it just does not contain + // the TSA's root. + let (wrong_store, _) = TrustStore::from_pem(testpki::TRUST_LIST_PEM).unwrap(); + assert!(matches!( + check(&token, stamped, &wrong_store), + Verdict::Untrusted(_) + )); + } + + #[test] + fn a_tampered_tst_info_does_not_verify() { + // The CMS signature covers a digest of the TSTInfo, not the TSTInfo + // itself, so a validator that checks only the signature would accept a + // swapped payload. Moving genTime must be caught. + let stamped = b"bytes"; + let mut token = testpki::issue_timestamp(stamped, testpki::validation_time()); + let needle = b"20"; + // Flip a digit inside the genTime string, wherever it landed. + let position = token + .windows(needle.len()) + .rposition(|w| w == needle) + .expect("the token should contain a GeneralizedTime"); + token[position + 1] ^= 0x01; + + let (tsa_store, _) = TrustStore::from_pem(testpki::TSA_TRUST_LIST_PEM).unwrap(); + assert!( + !matches!(check(&token, stamped, &tsa_store), Verdict::Trusted { .. }), + "a modified TSTInfo must not validate" + ); + } + + #[test] + fn junk_is_malformed_rather_than_a_panic() { + let (tsa_store, _) = TrustStore::from_pem(testpki::TSA_TRUST_LIST_PEM).unwrap(); + for junk in [vec![], vec![0x30, 0x00], b"not a token".to_vec()] { + assert!(matches!( + check(&junk, b"bytes", &tsa_store), + Verdict::Malformed(_) + )); + } + } + + #[test] + fn a_response_wrapper_is_unwrapped_to_its_token() { + let stamped = b"bytes"; + let token = testpki::issue_timestamp(stamped, testpki::validation_time()); + let response = testpki::wrap_timestamp_response(&token, 0); + assert_eq!(token_from_response(&response).unwrap(), token); + + // status 2 is "rejection". + let refused = testpki::wrap_timestamp_response(&token, 2); + assert!(token_from_response(&refused).is_err()); + } +} diff --git a/crates/imagecore/src/c2pa/trust.rs b/crates/imagecore/src/c2pa/trust.rs new file mode 100644 index 0000000..52f0962 --- /dev/null +++ b/crates/imagecore/src/c2pa/trust.rs @@ -0,0 +1,493 @@ +//! Trust anchors, and the path validation that uses them. +//! +//! Section 15.7 of the specification splits the question a validator answers +//! into two, and the split matters: +//! +//! - **Is the signature intact?** [`super::cose::verify`] answers that from the +//! certificate in `x5chain` alone. It needs nothing external. +//! - **Should anyone believe the certificate?** That needs a trust anchor list +//! and a validation time, and this module is where both arrive. +//! +//! An earlier version of this engine could only answer the first, and said so +//! honestly. That is no longer enough: the Conformance Program requires a +//! Generator Product with validation functionality to produce crJSON results +//! against a supplied C2PA Trust List and TSA Trust List at a supplied +//! validation time. Those three inputs are exactly the arguments here. +//! +//! # What is checked +//! +//! Chain building is by exact issuer/subject `Name` match with the Authority +//! Key Identifier as a hint, then RFC 5280 path validation reduced to the +//! checks that bear on a C2PA claim signature: +//! +//! | Check | Why | +//! |---|---| +//! | signature of each certificate by its issuer | the chain is otherwise decorative | +//! | validity window at the validation time | section 15.8 makes the time-stamp decide this | +//! | `cA` on every CA, and *not* on the leaf | section 14.5: a CA certificate may never sign a claim | +//! | `pathLenConstraint` | an issuing CA that says it issues no CAs must be held to it | +//! | `keyCertSign` on every CA | RFC 5280 §6.1.4 | +//! | unrecognised critical extensions | RFC 5280 §6.1.3: reject rather than ignore | +//! +//! Revocation is not checked. C2PA treats a stapled OCSP response as the way to +//! carry revocation status, and the specification says the manifest is judged +//! on the response captured at signing time; there is no live OCSP fetch to +//! make from a browser tab, and an absent response is `signingCredential.ocsp. +//! skipped` rather than a failure. What is *not* done is pretending otherwise. + +use super::clock::Instant; +use super::verify; +use super::x509::{self, key_usage, Certificate}; + +/// A list of trust anchors, as a C2PA Trust List or TSA Trust List supplies +/// them: a bundle of PEM certificates. +#[derive(Clone, Debug, Default)] +pub struct TrustStore { + anchors: Vec, +} + +impl TrustStore { + /// An empty store. Every chain evaluated against it is untrusted, which is + /// the correct answer when no list has been configured, and the reported + /// status code says which of the two situations produced it. + pub fn empty() -> Self { + TrustStore::default() + } + + /// Load anchors from a PEM bundle. + /// + /// Certificates that will not parse are skipped rather than fatal: a trust + /// list is a long concatenation maintained by someone else, and one bad + /// entry should not disable the other three hundred. The count of what + /// loaded is returned so a caller can report the difference. + pub fn from_pem(pem: &str) -> Result<(Self, usize), String> { + let ders = x509::pem_to_der(pem).map_err(|e| e.to_string())?; + let total = ders.len(); + let anchors: Vec = ders + .iter() + .filter_map(|der| x509::parse_certificate(der).ok()) + .collect(); + let skipped = total - anchors.len(); + Ok((TrustStore { anchors }, skipped)) + } + + pub fn is_empty(&self) -> bool { + self.anchors.is_empty() + } + + pub fn len(&self) -> usize { + self.anchors.len() + } + + /// Anchors whose subject matches the issuer of `certificate`. + fn issuers_of<'a>(&'a self, certificate: &Certificate) -> Vec<&'a Certificate> { + self.anchors + .iter() + .filter(|anchor| anchor.subject_der == certificate.issuer_der) + .filter(|anchor| { + match ( + &certificate.authority_key_identifier, + &anchor.subject_key_identifier, + ) { + // When both sides carry a key identifier they must agree; a + // matching name with a different key is a different CA that + // happens to share a name, which does happen after a rekey. + (Some(akid), Some(skid)) => akid == skid, + _ => true, + } + }) + .collect() + } +} + +/// What the chain is being used for, which decides the profile checks applied +/// to the leaf. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Purpose { + /// A C2PA claim signature. + ClaimSigning, + /// An RFC 3161 time-stamp token. + TimeStamping, +} + +/// The outcome of evaluating one chain. +#[derive(Clone, Debug, Default)] +pub struct ChainOutcome { + /// Whether a path to an anchor was built and every certificate on it + /// verified. + pub trusted: bool, + /// Whether every certificate on the path was inside its validity window at + /// the validation time. Reported separately because section 15.8 lets a + /// time-stamp move the instant this is judged at. + pub inside_validity: bool, + /// The trust anchor the path reached, when it reached one. + pub anchor: Option, + /// Why the evaluation came out the way it did, for the explanation field of + /// the status code. + pub reason: String, + /// The full path, leaf first, anchor last. + pub path: Vec, +} + +impl ChainOutcome { + fn rejected(reason: impl Into) -> Self { + ChainOutcome { + reason: reason.into(), + ..ChainOutcome::default() + } + } +} + +/// Build and validate a path from `chain` to an anchor in `store`. +/// +/// `chain` is the `x5chain` as it arrived: end-entity first, intermediates +/// after, trust anchor absent. `at` is the instant validity is judged at — the +/// time-stamp's attested time when there is a trusted one, otherwise the +/// validation time. +pub fn evaluate( + store: &TrustStore, + chain: &[Vec], + at: Instant, + purpose: Purpose, +) -> ChainOutcome { + let Some(leaf_der) = chain.first() else { + return ChainOutcome::rejected("the signature carries no certificate"); + }; + let leaf = match x509::parse_certificate(leaf_der) { + Ok(leaf) => leaf, + Err(e) => { + return ChainOutcome::rejected(format!("the signing certificate is malformed: {e}")) + } + }; + + // Section 14.5: only end-entity certificates sign claims and time-stamps. + // A CA certificate here is a rejection, not a warning. + if leaf.is_ca { + return ChainOutcome::rejected( + "the signing certificate is a CA certificate, which may not sign claims", + ); + } + if let Some(reason) = profile_violation(&leaf, purpose) { + return ChainOutcome::rejected(reason); + } + + let intermediates: Vec = chain[1..] + .iter() + .filter_map(|der| x509::parse_certificate(der).ok()) + .collect(); + + let mut path = vec![leaf]; + let mut inside_validity = true; + + // Walk up through the intermediates the signature supplied, then look for + // an anchor. Depth is bounded so a chain that loops back on itself cannot + // spin: eight is far past anything a real hierarchy uses. + const MAX_DEPTH: usize = 8; + for depth in 0..MAX_DEPTH { + let current = path.last().expect("the path always holds the leaf"); + inside_validity &= at >= current.not_before_at && at <= current.not_after_at; + + if !current.unrecognised_critical_extensions.is_empty() { + return ChainOutcome::rejected(format!( + "{} carries a critical extension this validator does not understand ({})", + describe(current), + current.unrecognised_critical_extensions.join(", ") + )); + } + + // An anchor terminates the path. Its own signature is not checked: + // a trust anchor is trusted because it is on the list, not because it + // vouches for itself. + if let Some(anchor) = store.issuers_of(current).into_iter().next() { + if let Err(e) = verify_issued_by(current, anchor) { + return ChainOutcome::rejected(format!( + "{} does not verify against the trust anchor {}: {e}", + describe(current), + describe(anchor) + )); + } + inside_validity &= at >= anchor.not_before_at && at <= anchor.not_after_at; + let name = describe(anchor); + path.push(anchor.clone()); + return ChainOutcome { + trusted: true, + inside_validity, + anchor: Some(name.clone()), + reason: format!("the chain reaches the trust anchor {name}"), + path, + }; + } + + // A self-issued certificate that is not on the list is the end of the + // road: there is nothing above it to find. + if current.is_self_issued() { + return ChainOutcome { + trusted: false, + inside_validity, + anchor: None, + reason: format!( + "the chain ends at {}, which is self-signed and not on the trust list", + describe(current) + ), + path, + }; + } + + let Some(issuer) = intermediates + .iter() + .find(|candidate| candidate.subject_der == current.issuer_der) + else { + return ChainOutcome { + trusted: false, + inside_validity, + anchor: None, + reason: format!( + "no certificate for the issuer of {} is on the trust list or in the chain", + describe(current) + ), + path, + }; + }; + + if !issuer.is_ca { + return ChainOutcome::rejected(format!( + "{} is not a CA certificate but issued {}", + describe(issuer), + describe(current) + )); + } + if !issuer.allows(key_usage::KEY_CERT_SIGN) { + return ChainOutcome::rejected(format!( + "{} does not assert keyCertSign", + describe(issuer) + )); + } + // pathLenConstraint counts the non-self-issued intermediates below + // this CA. `depth` is how many we have already walked past. + if let Some(limit) = issuer.path_len { + if depth as u32 > limit { + return ChainOutcome::rejected(format!( + "{} allows a path of {limit}, but the chain is longer", + describe(issuer) + )); + } + } + if let Err(e) = verify_issued_by(current, issuer) { + return ChainOutcome::rejected(format!( + "{} does not verify against {}: {e}", + describe(current), + describe(issuer) + )); + } + + path.push(issuer.clone()); + } + + ChainOutcome::rejected("the certificate chain is longer than this validator will follow") +} + +/// Profile checks that depend on what the leaf is for. +fn profile_violation(leaf: &Certificate, purpose: Purpose) -> Option { + // anyExtendedKeyUsage is forbidden on a C2PA signing certificate whichever + // way it is being used (section 14.4.1). + if leaf.has_eku("2.5.29.37.0") { + return Some(format!( + "{} asserts anyExtendedKeyUsage, which a C2PA signing certificate may not", + describe(leaf) + )); + } + if !leaf.allows(key_usage::DIGITAL_SIGNATURE) { + return Some(format!( + "{} does not assert digitalSignature", + describe(leaf) + )); + } + match purpose { + Purpose::ClaimSigning => { + if leaf.extended_key_usage_oids.is_empty() { + return Some(format!( + "{} carries no extended key usage, which section 14.4.1 requires", + describe(leaf) + )); + } + } + Purpose::TimeStamping => { + // RFC 3161 section 2.3. + if !leaf.has_eku("1.3.6.1.5.5.7.3.8") { + return Some(format!( + "{} does not assert the timeStamping extended key usage", + describe(leaf) + )); + } + } + } + None +} + +fn verify_issued_by(subject: &Certificate, issuer: &Certificate) -> verify::Result<()> { + verify::by_x509_algorithm( + &subject.signature_algorithm, + issuer, + &subject.tbs, + &subject.signature, + ) +} + +fn describe(certificate: &Certificate) -> String { + if certificate.subject_common_name.is_empty() { + certificate.subject.clone() + } else { + certificate.subject_common_name.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::c2pa::testpki; + + fn store() -> TrustStore { + TrustStore::from_pem(testpki::TRUST_LIST_PEM).unwrap().0 + } + + #[test] + fn a_chain_to_a_listed_anchor_is_trusted() { + let outcome = evaluate( + &store(), + &testpki::chain(), + testpki::validation_time(), + Purpose::ClaimSigning, + ); + assert!(outcome.trusted, "{}", outcome.reason); + assert!(outcome.inside_validity); + // Leaf, issuing CA, root. + assert_eq!(outcome.path.len(), 3); + assert!(outcome.anchor.as_deref().unwrap().contains("Root CA")); + } + + #[test] + fn an_empty_trust_list_trusts_nothing() { + let outcome = evaluate( + &TrustStore::empty(), + &testpki::chain(), + testpki::validation_time(), + Purpose::ClaimSigning, + ); + assert!(!outcome.trusted); + assert!(outcome.reason.contains("trust list"), "{}", outcome.reason); + } + + #[test] + fn a_chain_to_the_wrong_anchor_is_untrusted() { + // The TSA list is a perfectly good trust list; it just does not contain + // this signer's root. + let (tsa_store, _) = TrustStore::from_pem(testpki::TSA_TRUST_LIST_PEM).unwrap(); + let outcome = evaluate( + &tsa_store, + &testpki::chain(), + testpki::validation_time(), + Purpose::ClaimSigning, + ); + assert!(!outcome.trusted); + } + + #[test] + fn a_missing_intermediate_is_untrusted_rather_than_assumed() { + let outcome = evaluate( + &store(), + &testpki::chain()[..1], + testpki::validation_time(), + Purpose::ClaimSigning, + ); + assert!(!outcome.trusted); + assert!(outcome.reason.contains("issuer"), "{}", outcome.reason); + } + + #[test] + fn expiry_is_reported_separately_from_trust() { + // A time-stamp can rescue an expired certificate, so "the chain is + // sound" and "it was inside its window at this instant" have to be two + // answers rather than one. + let outcome = evaluate( + &store(), + &testpki::chain(), + testpki::after_expiry(), + Purpose::ClaimSigning, + ); + assert!(outcome.trusted, "{}", outcome.reason); + assert!(!outcome.inside_validity); + } + + #[test] + fn a_ca_certificate_may_not_sign_a_claim() { + let chain = vec![testpki::issuing_ca_der(), testpki::root_ca_der()]; + let outcome = evaluate( + &store(), + &chain, + testpki::validation_time(), + Purpose::ClaimSigning, + ); + assert!(!outcome.trusted); + assert!( + outcome.reason.contains("CA certificate"), + "{}", + outcome.reason + ); + } + + #[test] + fn a_tampered_certificate_does_not_verify_against_its_issuer() { + let mut chain = testpki::chain(); + // Flip a byte in the leaf's TBS. The DER stays well formed enough to + // parse - the byte is inside the subject common name - but the issuing + // CA's signature no longer covers it. + let leaf = &mut chain[0]; + let at = leaf.len() / 3; + leaf[at] ^= 0x01; + let outcome = evaluate( + &store(), + &chain, + testpki::validation_time(), + Purpose::ClaimSigning, + ); + assert!(!outcome.trusted, "{}", outcome.reason); + } + + #[test] + fn the_timestamp_authority_needs_the_time_stamping_usage() { + let (tsa_store, _) = TrustStore::from_pem(testpki::TSA_TRUST_LIST_PEM).unwrap(); + let outcome = evaluate( + &tsa_store, + &[testpki::tsa_signer_der()], + testpki::validation_time(), + Purpose::TimeStamping, + ); + assert!(outcome.trusted, "{}", outcome.reason); + + // The claim signer, which has no timeStamping usage, must not pass as + // a time-stamping authority. + let outcome = evaluate( + &store(), + &testpki::chain(), + testpki::validation_time(), + Purpose::TimeStamping, + ); + assert!(!outcome.trusted); + assert!( + outcome.reason.contains("timeStamping"), + "{}", + outcome.reason + ); + } + + #[test] + fn a_trust_list_with_junk_in_it_still_loads_the_rest() { + let mixed = format!( + "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n{}", + testpki::TRUST_LIST_PEM + ); + let (store, skipped) = TrustStore::from_pem(&mixed).unwrap(); + assert_eq!(skipped, 1); + assert_eq!(store.len(), 1); + } +} diff --git a/crates/imagecore/src/c2pa/verify.rs b/crates/imagecore/src/c2pa/verify.rs new file mode 100644 index 0000000..b3c8419 --- /dev/null +++ b/crates/imagecore/src/c2pa/verify.rs @@ -0,0 +1,415 @@ +//! Signature verification, for the two shapes a signature arrives in. +//! +//! A C2PA validator has to check two different kinds of signature with the same +//! set of public keys: +//! +//! - **COSE signatures**, over a claim. RFC 8152 fixes the encoding: ECDSA is +//! the raw `r || s` pair, fixed width by curve, and RSA is RSASSA-PSS. +//! - **X.509 signatures**, over a `tbsCertificate` or a CMS `SignedAttrs`. +//! Here ECDSA is DER — `SEQUENCE { r INTEGER, s INTEGER }` — and RSA is +//! usually PKCS#1 v1.5. +//! +//! Getting the two confused produces a signature that never verifies for +//! reasons that look like a key mismatch, so they are separate entry points +//! rather than one function with a flag. +//! +//! Every algorithm the C2PA specification allows in section 13.2.1 is handled +//! except Ed25519 and the P-521 curve, which are reported as unsupported rather +//! than silently failing: a validator that says "this does not verify" when it +//! means "I cannot check this" is worse than one that admits the gap. + +use sha2::{Digest, Sha256, Sha384, Sha512}; + +use super::identity::alg; +use super::x509::{self, key_oid, sig_oid, Certificate}; + +#[derive(Debug)] +pub struct VerifyError(String); + +impl std::fmt::Display for VerifyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for VerifyError {} + +pub type Result = std::result::Result; + +fn err(message: impl Into) -> Result { + Err(VerifyError(message.into())) +} + +/// Curve OIDs, for matching a key against the algorithm that claims to use it. +const CURVE_P256: &str = "1.2.840.10045.3.1.7"; +const CURVE_P384: &str = "1.3.132.0.34"; +const CURVE_P521: &str = "1.3.132.0.35"; + +/// Verify a COSE signature: raw `r || s` for ECDSA, PSS for RSA. +pub fn by_cose_algorithm( + algorithm: i64, + certificate: &Certificate, + message: &[u8], + signature: &[u8], +) -> Result<()> { + match algorithm { + alg::ES256 => ecdsa_p256(certificate, message, signature, Raw), + alg::ES384 => ecdsa_p384(certificate, message, signature, Raw), + alg::ES512 => err("ES512 signatures need the P-521 curve, which this build cannot check"), + alg::PS256 => rsa_pss(certificate, message, signature, Sha2::S256), + alg::PS384 => rsa_pss(certificate, message, signature, Sha2::S384), + alg::PS512 => rsa_pss(certificate, message, signature, Sha2::S512), + alg::ED25519 => err("Ed25519 signatures cannot be checked by this build"), + other => err(format!( + "unsupported signature algorithm {other} ({})", + alg::name(other) + )), + } +} + +/// Verify an X.509 or CMS signature made by `issuer` over `message`. +/// +/// `algorithm` is the OID from the `AlgorithmIdentifier`. +pub fn by_x509_algorithm( + algorithm: &str, + issuer: &Certificate, + message: &[u8], + signature: &[u8], +) -> Result<()> { + match algorithm { + sig_oid::ECDSA_SHA256 => ecdsa_p256(issuer, message, signature, Der), + sig_oid::ECDSA_SHA384 => ecdsa_p384(issuer, message, signature, Der), + sig_oid::ECDSA_SHA512 => { + err("ecdsa-with-SHA512 needs the P-521 curve, which this build cannot check") + } + sig_oid::RSA_SHA256 => rsa_pkcs1(issuer, message, signature, Sha2::S256), + sig_oid::RSA_SHA384 => rsa_pkcs1(issuer, message, signature, Sha2::S384), + sig_oid::RSA_SHA512 => rsa_pkcs1(issuer, message, signature, Sha2::S512), + // Certificates signed with PSS carry the hash in their parameters. The + // C2PA profile only allows the SHA-2 family, and salt length equal to + // the digest is what every conforming CA emits. + sig_oid::RSASSA_PSS => rsa_pss(issuer, message, signature, Sha2::S256) + .or_else(|_| rsa_pss(issuer, message, signature, Sha2::S384)) + .or_else(|_| rsa_pss(issuer, message, signature, Sha2::S512)), + sig_oid::ED25519 => err("Ed25519 certificates cannot be checked by this build"), + other => err(format!( + "unsupported certificate signature algorithm {other}" + )), + } +} + +/// Whether an algorithm is one C2PA 2.2 section 13.2.1 allows at all, +/// regardless of whether this build can check it. +pub fn is_allowed_cose_algorithm(algorithm: i64) -> bool { + matches!( + algorithm, + alg::ES256 | alg::ES384 | alg::ES512 | alg::PS256 | alg::PS384 | alg::PS512 | alg::ED25519 + ) +} + +/// How an ECDSA signature is encoded. +#[derive(Clone, Copy)] +struct Raw; +#[derive(Clone, Copy)] +struct Der; + +trait EcdsaEncoding { + /// Normalise to the fixed-width `r || s` the `ecdsa` crate expects. + fn to_fixed(&self, signature: &[u8], coordinate: usize) -> Result>; +} + +impl EcdsaEncoding for Raw { + fn to_fixed(&self, signature: &[u8], coordinate: usize) -> Result> { + if signature.len() != coordinate * 2 { + return err(format!( + "expected a {}-byte ECDSA signature, got {}", + coordinate * 2, + signature.len() + )); + } + Ok(signature.to_vec()) + } +} + +impl EcdsaEncoding for Der { + fn to_fixed(&self, signature: &[u8], coordinate: usize) -> Result> { + let sequence = + x509::read_tlv(signature).map_err(|e| VerifyError(format!("ECDSA signature: {e}")))?; + let parts = x509::children(sequence.value) + .map_err(|e| VerifyError(format!("ECDSA signature: {e}")))?; + let (Some(r), Some(s)) = (parts.first(), parts.get(1)) else { + return err("an ECDSA signature must hold two integers"); + }; + + let mut out = vec![0u8; coordinate * 2]; + for (index, part) in [r, s].into_iter().enumerate() { + // DER integers are signed, so a leading zero guards the sign bit. + let value = part.value.strip_prefix(&[0u8]).unwrap_or(part.value); + if value.len() > coordinate { + return err("an ECDSA signature component is too large for the curve"); + } + let start = index * coordinate + (coordinate - value.len()); + out[start..start + value.len()].copy_from_slice(value); + } + Ok(out) + } +} + +fn require_ec_key(certificate: &Certificate, curve: &str, name: &str) -> Result<()> { + if certificate.public_key_algorithm != key_oid::EC_PUBLIC_KEY { + return err(format!( + "{name} needs an elliptic-curve key, but the certificate holds {}", + certificate.public_key_algorithm + )); + } + // An empty curve means the certificate omitted the named-curve parameter. + // Accepting it would mean guessing, and a wrong guess reads as a bad + // signature rather than as the malformed certificate it is. + if certificate.public_key_curve != curve { + return err(format!( + "{name} needs {}, but the certificate's key is on {}", + curve_name(curve), + curve_name(&certificate.public_key_curve) + )); + } + Ok(()) +} + +fn curve_name(oid: &str) -> &str { + match oid { + CURVE_P256 => "P-256", + CURVE_P384 => "P-384", + CURVE_P521 => "P-521", + "" => "an unnamed curve", + other => other, + } +} + +fn ecdsa_p256( + certificate: &Certificate, + message: &[u8], + signature: &[u8], + encoding: impl EcdsaEncoding, +) -> Result<()> { + use p256::ecdsa::signature::Verifier; + + require_ec_key(certificate, CURVE_P256, "ES256")?; + let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&certificate.public_key) + .map_err(|e| VerifyError(format!("unusable P-256 key: {e}")))?; + let fixed = encoding.to_fixed(signature, 32)?; + let signature = p256::ecdsa::Signature::from_slice(&fixed) + .map_err(|e| VerifyError(format!("malformed ES256 signature: {e}")))?; + key.verify(message, &signature) + .map_err(|_| VerifyError("the signature does not match".into())) +} + +fn ecdsa_p384( + certificate: &Certificate, + message: &[u8], + signature: &[u8], + encoding: impl EcdsaEncoding, +) -> Result<()> { + use p384::ecdsa::signature::Verifier; + + require_ec_key(certificate, CURVE_P384, "ES384")?; + let key = p384::ecdsa::VerifyingKey::from_sec1_bytes(&certificate.public_key) + .map_err(|e| VerifyError(format!("unusable P-384 key: {e}")))?; + let fixed = encoding.to_fixed(signature, 48)?; + let signature = p384::ecdsa::Signature::from_slice(&fixed) + .map_err(|e| VerifyError(format!("malformed ES384 signature: {e}")))?; + key.verify(message, &signature) + .map_err(|_| VerifyError("the signature does not match".into())) +} + +fn rsa_public_key(certificate: &Certificate) -> Result { + use rsa::pkcs1::DecodeRsaPublicKey; + + if certificate.public_key_algorithm != key_oid::RSA_ENCRYPTION + && certificate.public_key_algorithm != key_oid::RSASSA_PSS + { + return err(format!( + "an RSA signature needs an RSA key, but the certificate holds {}", + certificate.public_key_algorithm + )); + } + rsa::RsaPublicKey::from_pkcs1_der(&certificate.public_key) + .map_err(|e| VerifyError(format!("unusable RSA key: {e}"))) +} + +/// Which SHA-2 variant an RSA signature was made with. +#[derive(Clone, Copy, Debug)] +enum Sha2 { + S256, + S384, + S512, +} + +fn rsa_pss(certificate: &Certificate, message: &[u8], signature: &[u8], hash: Sha2) -> Result<()> { + use rsa::signature::Verifier; + + let key = rsa_public_key(certificate)?; + let signature = rsa::pss::Signature::try_from(signature) + .map_err(|e| VerifyError(format!("malformed PSS signature: {e}")))?; + // RFC 8230: the salt is the same length as the digest, which is what + // `VerifyingKey::new` configures. + let outcome = match hash { + Sha2::S256 => rsa::pss::VerifyingKey::::new(key).verify(message, &signature), + Sha2::S384 => rsa::pss::VerifyingKey::::new(key).verify(message, &signature), + Sha2::S512 => rsa::pss::VerifyingKey::::new(key).verify(message, &signature), + }; + outcome.map_err(|_| VerifyError("the signature does not match".into())) +} + +fn rsa_pkcs1( + certificate: &Certificate, + message: &[u8], + signature: &[u8], + hash: Sha2, +) -> Result<()> { + use rsa::signature::Verifier; + + let key = rsa_public_key(certificate)?; + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| VerifyError(format!("malformed PKCS#1 signature: {e}")))?; + let outcome = match hash { + Sha2::S256 => rsa::pkcs1v15::VerifyingKey::::new(key).verify(message, &signature), + Sha2::S384 => rsa::pkcs1v15::VerifyingKey::::new(key).verify(message, &signature), + Sha2::S512 => rsa::pkcs1v15::VerifyingKey::::new(key).verify(message, &signature), + }; + outcome.map_err(|_| VerifyError("the signature does not match".into())) +} + +/// SHA-256 of a slice; the digest C2PA uses by default. +pub fn sha256(bytes: &[u8]) -> Vec { + Sha256::digest(bytes).to_vec() +} + +/// Digest a slice with the algorithm a C2PA `alg` field names. +pub fn digest(algorithm: &str, bytes: &[u8]) -> Option> { + Some(match algorithm.to_ascii_lowercase().as_str() { + "sha256" | "sha-256" => Sha256::digest(bytes).to_vec(), + "sha384" | "sha-384" => Sha384::digest(bytes).to_vec(), + "sha512" | "sha-512" => Sha512::digest(bytes).to_vec(), + _ => return None, + }) +} + +/// Digest a slice with the algorithm a DER OID names, for CMS and RFC 3161. +pub fn digest_by_oid(oid: &str, bytes: &[u8]) -> Option> { + Some(match oid { + "2.16.840.1.101.3.4.2.1" => Sha256::digest(bytes).to_vec(), + "2.16.840.1.101.3.4.2.2" => Sha384::digest(bytes).to_vec(), + "2.16.840.1.101.3.4.2.3" => Sha512::digest(bytes).to_vec(), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::c2pa::testpki; + + #[test] + fn a_raw_cose_signature_verifies() { + let certificate = x509::parse_certificate(&testpki::leaf_der()).unwrap(); + let message = b"the bytes to be signed"; + let signature = testpki::sign_es256(message); + by_cose_algorithm(alg::ES256, &certificate, message, &signature).unwrap(); + } + + #[test] + fn a_der_certificate_signature_verifies() { + // The leaf's own signature, made by the issuing CA over its TBS bytes. + let leaf = x509::parse_certificate(&testpki::leaf_der()).unwrap(); + let issuer = x509::parse_certificate(&testpki::issuing_ca_der()).unwrap(); + by_x509_algorithm( + &leaf.signature_algorithm, + &issuer, + &leaf.tbs, + &leaf.signature, + ) + .expect("the issuing CA signed this leaf"); + } + + #[test] + fn the_wrong_issuer_does_not_verify() { + let leaf = x509::parse_certificate(&testpki::leaf_der()).unwrap(); + let unrelated = x509::parse_certificate(&testpki::tsa_signer_der()).unwrap(); + assert!(by_x509_algorithm( + &leaf.signature_algorithm, + &unrelated, + &leaf.tbs, + &leaf.signature + ) + .is_err()); + } + + #[test] + fn a_flipped_bit_in_the_message_does_not_verify() { + let certificate = x509::parse_certificate(&testpki::leaf_der()).unwrap(); + let signature = testpki::sign_es256(b"the bytes to be signed"); + assert!(by_cose_algorithm( + alg::ES256, + &certificate, + b"the bytes to be sigped", + &signature + ) + .is_err()); + } + + #[test] + fn a_curve_mismatch_is_named_rather_than_reported_as_a_bad_signature() { + // A P-256 key presented for ES384 is a setup error, and saying "the + // signature does not match" would send someone hunting the wrong bug. + let certificate = x509::parse_certificate(&testpki::leaf_der()).unwrap(); + let error = by_cose_algorithm(alg::ES384, &certificate, b"x", &[0u8; 96]).unwrap_err(); + assert!(error.to_string().contains("P-384"), "{error}"); + } + + #[test] + fn unsupported_algorithms_say_so_instead_of_failing_silently() { + let certificate = x509::parse_certificate(&testpki::leaf_der()).unwrap(); + for algorithm in [alg::ED25519, alg::ES512] { + let error = by_cose_algorithm(algorithm, &certificate, b"x", &[0u8; 64]).unwrap_err(); + assert!( + error.to_string().contains("cannot") || error.to_string().contains("cannot check"), + "{error}" + ); + } + // But they are still algorithms the specification allows, which is a + // different question from whether this build can check them. + assert!(is_allowed_cose_algorithm(alg::ED25519)); + assert!(!is_allowed_cose_algorithm(-99)); + } + + #[test] + fn der_ecdsa_components_are_left_padded_not_truncated() { + // A DER integer drops leading zero bytes, so a component shorter than + // the curve width has to be padded on the left. Right-padding it, or + // copying it at offset zero, is a bug that only shows up on the roughly + // one signature in 256 with a short r or s. + let der = [ + 0x30, 0x0A, // SEQUENCE + 0x02, 0x02, 0x00, 0x7F, // r = 0x7F, with the sign guard + 0x02, 0x04, 0x01, 0x02, 0x03, 0x04, // s + ]; + let fixed = Der.to_fixed(&der, 32).unwrap(); + assert_eq!(fixed.len(), 64); + assert_eq!(fixed[31], 0x7F); + assert!(fixed[..31].iter().all(|b| *b == 0)); + assert_eq!(&fixed[60..], &[0x01, 0x02, 0x03, 0x04]); + } + + #[test] + fn digest_names_map_to_the_right_lengths() { + assert_eq!(digest("sha256", b"x").unwrap().len(), 32); + assert_eq!(digest("sha384", b"x").unwrap().len(), 48); + assert_eq!(digest("sha512", b"x").unwrap().len(), 64); + assert!(digest("md5", b"x").is_none()); + assert_eq!( + digest_by_oid("2.16.840.1.101.3.4.2.1", b"x").unwrap().len(), + 32 + ); + assert!(digest_by_oid("1.3.14.3.2.26", b"x").is_none()); // SHA-1, not allowed + } +} diff --git a/crates/imagecore/src/c2pa/x509.rs b/crates/imagecore/src/c2pa/x509.rs index 89d971a..df23630 100644 --- a/crates/imagecore/src/c2pa/x509.rs +++ b/crates/imagecore/src/c2pa/x509.rs @@ -1,20 +1,25 @@ -//! Just enough DER to look inside a signing certificate. +//! Enough DER to read, and to check, a signing certificate. //! -//! Two things are needed from the `x5chain` header: the public key, so a -//! signature can actually be checked, and enough human-readable detail — who -//! this is, who vouched for them, until when — for the UI to say something -//! truthful about the signer. +//! This grew from "report what the certificate says" into a real certificate +//! parser, because the C2PA Conformance Program needs both halves of the job: //! -//! This is deliberately *not* a certificate validator. It does not walk chains, -//! check revocation, or decide whether anyone should be trusted; those need a -//! trust anchor store and a clock, and the app has neither. What it does is -//! report what the certificate says about itself, so the interface can show it -//! next to a clear statement that nobody has vouched for it. Section 14.5.1's -//! profile rules are enforced at certificate *generation* time instead — see -//! `signing/generate.sh`. +//! - The **claim generator** side reads the leaf to learn who is signing, at +//! what Assurance Level, and under which Conforming Products List record. +//! Those three facts live in extensions the C2PA Certificate Policy defines +//! (`c2pa-al`, `c2pa-cpl-record`, `c2pa-kp-claimSigning`) and nowhere else. +//! - The **validator** side needs everything RFC 5280 path validation touches: +//! the `tbsCertificate` bytes and signature so a chain can be verified, names +//! in their raw DER form so issuers can be matched exactly, validity as +//! comparable instants, key usage, basic constraints, and the key identifiers +//! that make chain building cheap. +//! +//! What is *not* here is any policy. This module answers "what does this +//! certificate contain"; [`super::trust`] decides what to make of it. use std::fmt; +use super::clock::{self, Instant}; + #[derive(Debug)] pub struct DerError(String); @@ -47,15 +52,23 @@ const TAG_SEQUENCE: u8 = 0x30; const TAG_SET: u8 = 0x31; /// One tag-length-value triple. +/// +/// Public because the DER this crate parses does not stop at certificates: the +/// claim-signer service reads RFC 3161 responses with the same primitives, and +/// a second copy of a DER reader is a second place for a length-handling bug to +/// live. #[derive(Clone, Copy, Debug)] -struct Tlv<'a> { - tag: u8, - value: &'a [u8], - /// Total encoded size, so a caller can step to the next element. - total: usize, +pub struct Tlv<'a> { + pub tag: u8, + pub value: &'a [u8], + /// Total encoded size, so a caller can step to the next element. Paired + /// with the offsets [`children_with_offsets`] returns, this is how a caller + /// slices an element back out byte for byte - which matters for anything + /// re-derived over the exact encoding, like a `tbsCertificate`. + pub total: usize, } -fn read_tlv(bytes: &[u8]) -> Result> { +pub fn read_tlv(bytes: &[u8]) -> Result> { let tag = *bytes .first() .ok_or_else(|| DerError("input ended".into()))?; @@ -94,7 +107,7 @@ fn read_tlv(bytes: &[u8]) -> Result> { } /// Split a constructed value into its elements. -fn children(bytes: &[u8]) -> Result>> { +pub fn children(bytes: &[u8]) -> Result>> { let mut out = Vec::new(); let mut at = 0; while at < bytes.len() { @@ -105,8 +118,22 @@ fn children(bytes: &[u8]) -> Result>> { Ok(out) } +/// Like [`children`], but also hands back where each element started, for +/// callers that need the exact encoded bytes of an element. +pub fn children_with_offsets(bytes: &[u8]) -> Result)>> { + let mut out = Vec::new(); + let mut at = 0; + while at < bytes.len() { + let tlv = read_tlv(&bytes[at..])?; + let start = at; + at += tlv.total; + out.push((start, tlv)); + } + Ok(out) +} + /// Dotted-decimal form of an OID, for comparison and display. -fn oid_to_string(bytes: &[u8]) -> String { +pub fn oid_to_string(bytes: &[u8]) -> String { let Some((&first, rest)) = bytes.split_first() else { return String::new(); }; @@ -124,7 +151,7 @@ fn oid_to_string(bytes: &[u8]) -> String { out } -fn decode_string(tlv: &Tlv<'_>) -> String { +pub fn decode_string(tlv: &Tlv<'_>) -> String { match tlv.tag { TAG_UTF8_STRING | TAG_PRINTABLE_STRING | TAG_IA5_STRING => { String::from_utf8_lossy(tlv.value).into_owned() @@ -146,32 +173,19 @@ fn decode_string(tlv: &Tlv<'_>) -> String { /// `YYMMDDHHMMSSZ` or `YYYYMMDDHHMMSSZ` rendered as an ISO-8601 date. fn decode_time(tlv: &Tlv<'_>) -> String { let raw = String::from_utf8_lossy(tlv.value); - let digits: String = raw.chars().filter(|c| c.is_ascii_digit()).collect(); - - let (year, rest) = match tlv.tag { - TAG_UTC_TIME if digits.len() >= 10 => { - let two: u32 = digits[..2].parse().unwrap_or(0); - // RFC 5280: 00-49 means 20xx, 50-99 means 19xx. - let year = if two < 50 { 2000 + two } else { 1900 + two }; - (year, &digits[2..]) - } - TAG_GENERALIZED_TIME if digits.len() >= 12 => { - (digits[..4].parse().unwrap_or(0), &digits[4..]) - } - _ => return raw.into_owned(), - }; + match decode_time_instant(tlv) { + Some(instant) => clock::to_rfc3339(instant), + None => raw.into_owned(), + } +} - if rest.len() < 8 { - return raw.into_owned(); - } - format!( - "{year:04}-{}-{}T{}:{}:{}Z", - &rest[0..2], - &rest[2..4], - &rest[4..6], - &rest[6..8], - rest.get(8..10).unwrap_or("00"), - ) +pub fn decode_time_instant(tlv: &Tlv<'_>) -> Option { + let raw = String::from_utf8_lossy(tlv.value); + match tlv.tag { + TAG_UTC_TIME => clock::parse_asn1_time(&raw, true), + TAG_GENERALIZED_TIME => clock::parse_asn1_time(&raw, false), + _ => None, + } } /* ---- Attribute and extension OIDs ---- */ @@ -179,10 +193,57 @@ const OID_COMMON_NAME: &str = "2.5.4.3"; const OID_ORGANISATION: &str = "2.5.4.10"; const OID_ORGANISATIONAL_UNIT: &str = "2.5.4.11"; const OID_COUNTRY: &str = "2.5.4.6"; -const OID_EXTENDED_KEY_USAGE: &str = "2.5.29.37"; +const OID_STATE: &str = "2.5.4.8"; +const OID_LOCALITY: &str = "2.5.4.7"; +const OID_SERIAL_NUMBER_ATTR: &str = "2.5.4.5"; + +const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14"; +const OID_KEY_USAGE: &str = "2.5.29.15"; const OID_BASIC_CONSTRAINTS: &str = "2.5.29.19"; +const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32"; +const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35"; +const OID_EXTENDED_KEY_USAGE: &str = "2.5.29.37"; +const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1"; +const OID_AD_OCSP: &str = "1.3.6.1.5.5.7.48.1"; + +/// OIDs from the C2PA private arc, as the C2PA Certificate Policy v0.2 defines +/// them. +pub mod oid { + /// `c2pa-kp-claimSigning`, the extended key usage a C2PA claim signing + /// certificate must assert. + pub const EKU_CLAIM_SIGNING: &str = "1.3.6.1.4.1.62558.2.1"; + /// `id-c2pa-al`, whose value is the assurance level OID. + pub const ASSURANCE_LEVEL: &str = "1.3.6.1.4.1.62558.3"; + pub const ASSURANCE_LEVEL_1: &str = "1.3.6.1.4.1.62558.3.10"; + pub const ASSURANCE_LEVEL_2: &str = "1.3.6.1.4.1.62558.3.20"; + /// `c2pa-cpl-record`, a UTF8String holding the Conforming Products List + /// record UUID. + pub const CPL_RECORD: &str = "1.3.6.1.4.1.62558.4"; + /// `c2pa-certificate-policy`. + pub const CERTIFICATE_POLICY: &str = "1.3.6.1.4.1.62558.1.1"; +} + +/// Public key algorithm OIDs. +pub mod key_oid { + pub const EC_PUBLIC_KEY: &str = "1.2.840.10045.2.1"; + pub const RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.1"; + pub const RSASSA_PSS: &str = "1.2.840.113549.1.1.10"; + pub const ED25519: &str = "1.3.101.112"; +} -/// Human-readable names for the EKUs C2PA cares about (section 14.4.1). +/// Signature algorithm OIDs, as they appear in `AlgorithmIdentifier`. +pub mod sig_oid { + pub const ECDSA_SHA256: &str = "1.2.840.10045.4.3.2"; + pub const ECDSA_SHA384: &str = "1.2.840.10045.4.3.3"; + pub const ECDSA_SHA512: &str = "1.2.840.10045.4.3.4"; + pub const RSA_SHA256: &str = "1.2.840.113549.1.1.11"; + pub const RSA_SHA384: &str = "1.2.840.113549.1.1.12"; + pub const RSA_SHA512: &str = "1.2.840.113549.1.1.13"; + pub const RSASSA_PSS: &str = "1.2.840.113549.1.1.10"; + pub const ED25519: &str = "1.3.101.112"; +} + +/// Human-readable names for the EKUs C2PA cares about. fn eku_name(oid: &str) -> &str { match oid { "1.3.6.1.5.5.7.3.4" => "emailProtection", @@ -190,32 +251,128 @@ fn eku_name(oid: &str) -> &str { "1.3.6.1.5.5.7.3.8" => "timeStamping", "1.3.6.1.5.5.7.3.9" => "OCSPSigning", "2.5.29.37.0" => "anyExtendedKeyUsage", + oid::EKU_CLAIM_SIGNING => "c2pa-kp-claimSigning", other => other, } } +/// `KeyUsage` bits, in the order RFC 5280 assigns them. +pub mod key_usage { + pub const DIGITAL_SIGNATURE: u16 = 1 << 0; + pub const NON_REPUDIATION: u16 = 1 << 1; + pub const KEY_ENCIPHERMENT: u16 = 1 << 2; + pub const KEY_CERT_SIGN: u16 = 1 << 5; + pub const CRL_SIGN: u16 = 1 << 6; +} + +/// One relative distinguished name, kept as a pair so crJSON can render the +/// whole DN as an object rather than a flattened string. +pub type NameAttribute = (String, String); + /// What a certificate says about itself. #[derive(Clone, Debug, Default)] pub struct Certificate { pub subject: String, pub subject_common_name: String, pub subject_organisation: String, + pub subject_attributes: Vec, pub issuer: String, pub issuer_common_name: String, + pub issuer_attributes: Vec, pub not_before: String, pub not_after: String, + pub not_before_at: Instant, + pub not_after_at: Instant, pub serial: String, - /// SEC1 public key point, ready for `p256`. + /// The serial number's DER integer content, for re-encoding it into a CMS + /// `issuerAndSerialNumber`. Certification authorities issue serials of up + /// to twenty octets, which no integer type here would hold. + pub serial_bytes: Vec, + /// SEC1 public key point for EC keys, or the DER `RSAPublicKey` body for + /// RSA. [`Certificate::public_key_algorithm`] says which. pub public_key: Vec, /// Public key algorithm OID. pub public_key_algorithm: String, + /// Named-curve OID from the key's `AlgorithmIdentifier` parameters, for EC + /// keys. Without it a P-384 key looks like a malformed P-256 one. + pub public_key_curve: String, pub extended_key_usage: Vec, + pub extended_key_usage_oids: Vec, pub is_ca: bool, + pub path_len: Option, + pub key_usage: Option, + pub subject_key_identifier: Option>, + pub authority_key_identifier: Option>, + pub certificate_policies: Vec, + pub ocsp_responders: Vec, + /// The value of `c2pa-al`, reduced to 1 or 2. + pub c2pa_assurance_level: Option, + /// The value of `c2pa-cpl-record`. + pub c2pa_cpl_record_id: Option, + /// Critical extensions this parser did not recognise. RFC 5280 requires a + /// path validator to reject a certificate carrying one of these rather + /// than ignore it. + pub unrecognised_critical_extensions: Vec, + + /* -- the raw material path validation needs -- */ + /// DER of `tbsCertificate`, exactly as encoded: what the issuer signed. + pub tbs: Vec, + /// DER of the subject `Name`, for exact issuer/subject matching. + pub subject_der: Vec, + /// DER of the issuer `Name`. + pub issuer_der: Vec, + /// The certificate's own signature. + pub signature: Vec, + /// OID of the algorithm that signature was made with. + pub signature_algorithm: String, + /// DER of the outer `signatureAlgorithm` field, needed for RSASSA-PSS + /// where the parameters carry the hash and salt length. + pub signature_algorithm_params: Vec, +} + +impl Certificate { + /// Whether the leaf asserts `c2pa-kp-claimSigning`. + pub fn has_claim_signing_eku(&self) -> bool { + self.extended_key_usage_oids + .iter() + .any(|o| o == oid::EKU_CLAIM_SIGNING) + } + + pub fn has_eku(&self, oid: &str) -> bool { + self.extended_key_usage_oids.iter().any(|o| o == oid) + } + + /// Whether the subject and issuer names are byte-identical, which is what + /// makes a certificate a candidate trust anchor. + pub fn is_self_issued(&self) -> bool { + !self.subject_der.is_empty() && self.subject_der == self.issuer_der + } + + pub fn allows(&self, usage: u16) -> bool { + // RFC 5280: an absent KeyUsage places no restriction. + self.key_usage.is_none_or(|bits| bits & usage != 0) + } + + /// Size in bytes of an RSA modulus, or `None` for a non-RSA key. This is + /// how large a PS256/PS384/PS512 signature will be. + pub fn rsa_modulus_bytes(&self) -> Option { + if self.public_key_algorithm != key_oid::RSA_ENCRYPTION + && self.public_key_algorithm != key_oid::RSASSA_PSS + { + return None; + } + let sequence = read_tlv(&self.public_key).ok()?; + let modulus = children(sequence.value).ok()?.into_iter().next()?; + // DER integers carry a leading zero byte when the high bit is set. + Some(modulus.value.len() - usize::from(modulus.value.first() == Some(&0))) + } } -/// A distinguished name, rendered like OpenSSL's `subject=` line. -fn parse_name(bytes: &[u8]) -> Result<(String, String, String)> { +/// A distinguished name, rendered like OpenSSL's `subject=` line, plus its +/// attributes as pairs. +fn parse_name(bytes: &[u8]) -> Result<(String, String, String, Vec)> { let mut parts = Vec::new(); + let mut attributes = Vec::new(); let mut common_name = String::new(); let mut organisation = String::new(); @@ -241,19 +398,22 @@ fn parse_name(bytes: &[u8]) -> Result<(String, String, String)> { OID_ORGANISATION => "O", OID_ORGANISATIONAL_UNIT => "OU", OID_COUNTRY => "C", - _ => continue, + OID_STATE => "ST", + OID_LOCALITY => "L", + OID_SERIAL_NUMBER_ATTR => "serialNumber", + other => other, }; - if oid == OID_COMMON_NAME { - common_name = text.clone(); - } - if oid == OID_ORGANISATION { - organisation = text.clone(); + match label { + "CN" => common_name = text.clone(), + "O" => organisation = text.clone(), + _ => {} } - parts.push(format!("{label}={text}")); + parts.push(format!("{label} = {text}")); + attributes.push((label.to_string(), text)); } } - Ok((parts.join(", "), common_name, organisation)) + Ok((parts.join(", "), common_name, organisation, attributes)) } /// Read a DER-encoded X.509 certificate. @@ -262,63 +422,104 @@ pub fn parse_certificate(der: &[u8]) -> Result { if certificate.tag != TAG_SEQUENCE { return err("a certificate must be a SEQUENCE"); } - let top = children(certificate.value)?; - let tbs = top + let body = certificate.value; + let top = children_with_offsets(body)?; + let (tbs_start, tbs) = *top .first() - .filter(|t| t.tag == TAG_SEQUENCE) + .filter(|(_, t)| t.tag == TAG_SEQUENCE) .ok_or_else(|| DerError("missing tbsCertificate".into()))?; - let fields = children(tbs.value)?; + let mut out = Certificate { + tbs: body[tbs_start..tbs_start + tbs.total].to_vec(), + ..Certificate::default() + }; + + // Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature } + if let Some((start, algorithm)) = top.get(1) { + out.signature_algorithm_params = body[*start..*start + algorithm.total].to_vec(); + out.signature_algorithm = children(algorithm.value)? + .first() + .filter(|o| o.tag == TAG_OID) + .map(|o| oid_to_string(o.value)) + .unwrap_or_default(); + } + if let Some((_, signature)) = top.get(2) { + if signature.tag == TAG_BIT_STRING { + out.signature = signature.value.get(1..).unwrap_or_default().to_vec(); + } + } + + let fields = children_with_offsets(tbs.value)?; let mut at = 0; // version [0] EXPLICIT, present for v3 and absent for v1. - if fields.first().map(|f| f.tag) == Some(0xA0) { + if fields.first().map(|(_, f)| f.tag) == Some(0xA0) { at = 1; } - let serial = fields - .get(at) - .filter(|f| f.tag == TAG_INTEGER) - .map(|f| { - f.value - .iter() - .map(|b| format!("{b:02X}")) - .collect::>() - .join(":") - }) - .unwrap_or_default(); + if let Some((_, field)) = fields.get(at).filter(|(_, f)| f.tag == TAG_INTEGER) { + out.serial_bytes = field.value.to_vec(); + out.serial = field + .value + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":"); + } at += 1; - at += 1; // signature AlgorithmIdentifier + at += 1; // signature AlgorithmIdentifier, repeated inside the TBS - let issuer = fields + let (issuer_start, issuer) = *fields .get(at) .ok_or_else(|| DerError("missing issuer".into()))?; - let (issuer_name, issuer_cn, _) = parse_name(issuer.value)?; + let (issuer_name, issuer_cn, _, issuer_attributes) = parse_name(issuer.value)?; + out.issuer = issuer_name; + out.issuer_common_name = issuer_cn; + out.issuer_attributes = issuer_attributes; + out.issuer_der = tbs.value[issuer_start..issuer_start + issuer.total].to_vec(); at += 1; - let validity = fields + let (_, validity) = *fields .get(at) .ok_or_else(|| DerError("missing validity".into()))?; let times = children(validity.value)?; - let not_before = times.first().map(decode_time).unwrap_or_default(); - let not_after = times.get(1).map(decode_time).unwrap_or_default(); + out.not_before = times.first().map(decode_time).unwrap_or_default(); + out.not_after = times.get(1).map(decode_time).unwrap_or_default(); + out.not_before_at = times.first().and_then(decode_time_instant).unwrap_or(0); + // An unreadable notAfter must not read as "valid forever". + out.not_after_at = times.get(1).and_then(decode_time_instant).unwrap_or(0); at += 1; - let subject = fields + let (subject_start, subject) = *fields .get(at) .ok_or_else(|| DerError("missing subject".into()))?; - let (subject_name, subject_cn, subject_org) = parse_name(subject.value)?; + let (subject_name, subject_cn, subject_org, subject_attributes) = parse_name(subject.value)?; + out.subject = subject_name; + out.subject_common_name = subject_cn; + out.subject_organisation = subject_org; + out.subject_attributes = subject_attributes; + out.subject_der = tbs.value[subject_start..subject_start + subject.total].to_vec(); at += 1; // SubjectPublicKeyInfo ::= SEQUENCE { algorithm, subjectPublicKey BIT STRING } - let spki = fields + let (_, spki) = *fields .get(at) .ok_or_else(|| DerError("missing subjectPublicKeyInfo".into()))?; let spki_parts = children(spki.value)?; - let algorithm = spki_parts + let algorithm_parts = spki_parts + .first() + .map(|a| children(a.value)) + .transpose()? + .unwrap_or_default(); + out.public_key_algorithm = algorithm_parts .first() - .and_then(|a| children(a.value).ok()) - .and_then(|a| a.first().map(|oid| oid_to_string(oid.value))) + .filter(|o| o.tag == TAG_OID) + .map(|o| oid_to_string(o.value)) + .unwrap_or_default(); + out.public_key_curve = algorithm_parts + .get(1) + .filter(|o| o.tag == TAG_OID) + .map(|o| oid_to_string(o.value)) .unwrap_or_default(); let key_bits = spki_parts .get(1) @@ -326,13 +527,11 @@ pub fn parse_certificate(der: &[u8]) -> Result { .ok_or_else(|| DerError("public key is not a BIT STRING".into()))?; // A BIT STRING leads with a count of unused trailing bits, always zero for // a key, and the key itself follows. - let public_key = key_bits.value.get(1..).unwrap_or_default().to_vec(); + out.public_key = key_bits.value.get(1..).unwrap_or_default().to_vec(); at += 1; // Optional [1] issuerUniqueID, [2] subjectUniqueID, [3] extensions. - let mut extended_key_usage = Vec::new(); - let mut is_ca = false; - for field in fields.iter().skip(at) { + for (_, field) in fields.iter().skip(at) { if field.tag != 0xA3 { continue; } @@ -340,54 +539,144 @@ pub fn parse_certificate(der: &[u8]) -> Result { continue; }; for extension in children(sequence.value)? { - let parts = children(extension.value)?; - let Some(oid) = parts.first().filter(|o| o.tag == TAG_OID) else { - continue; - }; - let oid = oid_to_string(oid.value); - // `critical` is optional and defaults to false, so the OCTET STRING - // is whichever of the remaining elements has that tag. - let Some(payload) = parts.iter().find(|p| p.tag == TAG_OCTET_STRING) else { - continue; - }; + read_extension(extension.value, &mut out)?; + } + } - match oid.as_str() { - OID_EXTENDED_KEY_USAGE => { - if let Ok(sequence) = read_tlv(payload.value) { - for oid in children(sequence.value)? { - if oid.tag == TAG_OID { - extended_key_usage - .push(eku_name(&oid_to_string(oid.value)).to_string()); + Ok(out) +} + +fn read_extension(bytes: &[u8], out: &mut Certificate) -> Result<()> { + let parts = children(bytes)?; + let Some(oid) = parts.first().filter(|o| o.tag == TAG_OID) else { + return Ok(()); + }; + let oid = oid_to_string(oid.value); + let critical = parts + .iter() + .any(|p| p.tag == TAG_BOOLEAN && p.value.first() == Some(&0xFF)); + // `critical` is optional and defaults to false, so the OCTET STRING is + // whichever of the remaining elements has that tag. + let Some(payload) = parts.iter().find(|p| p.tag == TAG_OCTET_STRING) else { + return Ok(()); + }; + + match oid.as_str() { + OID_EXTENDED_KEY_USAGE => { + if let Ok(sequence) = read_tlv(payload.value) { + for entry in children(sequence.value)? { + if entry.tag == TAG_OID { + let dotted = oid_to_string(entry.value); + out.extended_key_usage.push(eku_name(&dotted).to_string()); + out.extended_key_usage_oids.push(dotted); + } + } + } + } + OID_BASIC_CONSTRAINTS => { + if let Ok(sequence) = read_tlv(payload.value) { + for entry in children(sequence.value)? { + match entry.tag { + TAG_BOOLEAN => out.is_ca = entry.value.first() == Some(&0xFF), + TAG_INTEGER => { + let mut value = 0u32; + for byte in entry.value { + value = (value << 8) | u32::from(*byte); } + out.path_len = Some(value); } + _ => {} } } - OID_BASIC_CONSTRAINTS => { - if let Ok(sequence) = read_tlv(payload.value) { - is_ca = children(sequence.value)? - .iter() - .any(|c| c.tag == TAG_BOOLEAN && c.value.first() == Some(&0xFF)); + } + } + OID_KEY_USAGE => { + // KeyUsage ::= BIT STRING, big-endian with bit 0 leftmost. + if let Ok(bits) = read_tlv(payload.value) { + let unused = usize::from(*bits.value.first().unwrap_or(&0)); + let mut mask = 0u16; + for (index, byte) in bits.value.iter().skip(1).enumerate() { + for bit in 0..8 { + let position = index * 8 + bit; + if index == bits.value.len() - 2 && bit >= 8 - unused.min(8) { + break; + } + if byte & (0x80 >> bit) != 0 && position < 16 { + mask |= 1 << position; + } } } - _ => {} + out.key_usage = Some(mask); + } + } + OID_SUBJECT_KEY_IDENTIFIER => { + if let Ok(octets) = read_tlv(payload.value) { + out.subject_key_identifier = Some(octets.value.to_vec()); + } + } + OID_AUTHORITY_KEY_IDENTIFIER => { + if let Ok(sequence) = read_tlv(payload.value) { + // keyIdentifier is [0] IMPLICIT OCTET STRING. + for entry in children(sequence.value)? { + if entry.tag == 0x80 { + out.authority_key_identifier = Some(entry.value.to_vec()); + } + } + } + } + OID_CERTIFICATE_POLICIES => { + if let Ok(sequence) = read_tlv(payload.value) { + for policy in children(sequence.value)? { + if let Some(id) = children(policy.value)?.first().filter(|o| o.tag == TAG_OID) { + out.certificate_policies.push(oid_to_string(id.value)); + } + } + } + } + OID_AUTHORITY_INFO_ACCESS => { + if let Ok(sequence) = read_tlv(payload.value) { + for description in children(sequence.value)? { + let parts = children(description.value)?; + let method = parts + .first() + .filter(|o| o.tag == TAG_OID) + .map(|o| oid_to_string(o.value)) + .unwrap_or_default(); + // accessLocation is a GeneralName; uniformResourceIdentifier + // is context tag [6]. + if method == OID_AD_OCSP { + if let Some(location) = parts.get(1).filter(|l| l.tag == 0x86) { + out.ocsp_responders + .push(String::from_utf8_lossy(location.value).into_owned()); + } + } + } + } + } + oid::ASSURANCE_LEVEL => { + if let Ok(value) = read_tlv(payload.value) { + if value.tag == TAG_OID { + out.c2pa_assurance_level = match oid_to_string(value.value).as_str() { + oid::ASSURANCE_LEVEL_1 => Some(1), + oid::ASSURANCE_LEVEL_2 => Some(2), + _ => None, + }; + } + } + } + oid::CPL_RECORD => { + if let Ok(value) = read_tlv(payload.value) { + out.c2pa_cpl_record_id = Some(decode_string(&value)); + } + } + other => { + if critical { + out.unrecognised_critical_extensions.push(other.to_string()); } } } - Ok(Certificate { - subject: subject_name, - subject_common_name: subject_cn, - subject_organisation: subject_org, - issuer: issuer_name, - issuer_common_name: issuer_cn, - not_before, - not_after, - serial, - public_key, - public_key_algorithm: algorithm, - extended_key_usage, - is_ca, - }) + Ok(()) } /// Pull every DER body out of a PEM document, in order. @@ -457,78 +746,148 @@ fn base64_decode(input: &str) -> Result> { #[cfg(test)] mod tests { use super::*; + use crate::c2pa::testpki; - /// The demo signer, compiled in by build.rs. Parsing the real certificate - /// beats a hand-built fixture: it is what actually ships. - fn demo_certificate() -> Certificate { - let der = pem_to_der(crate::c2pa::signer::SIGNING_CERT_CHAIN_PEM).unwrap(); - parse_certificate(&der[0]).unwrap() + fn leaf() -> Certificate { + parse_certificate(&testpki::leaf_der()).unwrap() } #[test] - fn reads_the_shipped_signing_certificate() { - let cert = demo_certificate(); - assert!( - cert.subject_common_name.contains("Signer"), - "unexpected subject {:?}", - cert.subject - ); + fn reads_the_test_claim_signing_certificate() { + let cert = leaf(); + assert_eq!(cert.subject_common_name, "A10city Image Editor"); + assert_eq!(cert.subject_organisation, "A10city Labs"); assert!(!cert.issuer.is_empty()); assert_ne!( cert.issuer, cert.subject, "the leaf must not be self-signed" ); assert!(!cert.serial.is_empty()); + assert!(!cert.is_self_issued()); } #[test] fn extracts_a_usable_p256_public_key() { - let cert = demo_certificate(); + let cert = leaf(); // Uncompressed SEC1 point: 0x04 then two 32-byte coordinates. assert_eq!(cert.public_key.len(), 65); assert_eq!(cert.public_key[0], 0x04); - assert_eq!(cert.public_key_algorithm, "1.2.840.10045.2.1"); // id-ecPublicKey + assert_eq!(cert.public_key_algorithm, key_oid::EC_PUBLIC_KEY); + assert_eq!(cert.public_key_curve, "1.2.840.10045.3.1.7"); // prime256v1 p256::ecdsa::VerifyingKey::from_sec1_bytes(&cert.public_key) .expect("the parsed key should load"); } #[test] - fn the_signer_matches_the_c2pa_certificate_profile() { - // Section 14.5.1: an end-entity certificate needs a non-empty EKU, must - // not claim anyExtendedKeyUsage, and must not be a CA. - let cert = demo_certificate(); - assert!(!cert.is_ca, "a CA certificate may not sign claims"); - assert!(!cert.extended_key_usage.is_empty(), "EKU is required"); - assert!(!cert - .extended_key_usage + fn reads_the_c2pa_certificate_policy_extensions() { + // These three are what separate a certificate issued under the C2PA + // Certificate Policy from any other signing certificate, and the whole + // conformance story rests on reading them correctly. + let cert = leaf(); + assert_eq!(cert.c2pa_assurance_level, Some(1)); + assert_eq!( + cert.c2pa_cpl_record_id.as_deref(), + Some("00000000-0000-0000-0000-000000000000") + ); + assert!(cert.has_claim_signing_eku()); + assert!(cert + .certificate_policies .iter() - .any(|eku| eku == "anyExtendedKeyUsage")); + .any(|p| p == oid::CERTIFICATE_POLICY)); + } + + #[test] + fn the_leaf_matches_the_assurance_level_1_profile() { + let cert = leaf(); + assert!(!cert.is_ca, "cA must be FALSE on a claim signing leaf"); + assert!(cert.allows(key_usage::DIGITAL_SIGNATURE)); + assert!(cert.allows(key_usage::NON_REPUDIATION)); + assert!(!cert.allows(key_usage::KEY_CERT_SIGN)); + assert!( + !cert.has_eku("2.5.29.37.0"), + "anyExtendedKeyUsage is forbidden" + ); assert!( - cert.extended_key_usage + cert.extended_key_usage_oids .iter() - .any(|eku| eku == "emailProtection" || eku == "documentSigning"), - "expected a C2PA claim-signing EKU, got {:?}", - cert.extended_key_usage + .any(|o| o == "1.3.6.1.5.5.7.3.4" || o == "1.3.6.1.5.5.7.3.36"), + "the profile requires emailProtection or documentSigning alongside claimSigning" ); + assert!( + !cert.ocsp_responders.is_empty(), + "AIA with an OCSP URI is required" + ); + assert!(cert.subject_key_identifier.is_some()); + assert!(cert.authority_key_identifier.is_some()); } #[test] - fn reads_validity_dates_as_iso_8601() { - let cert = demo_certificate(); + fn assurance_level_1_caps_validity_at_366_days() { + let cert = leaf(); + let span = cert.not_after_at - cert.not_before_at; + assert!(span > 0); + assert!( + span <= 366 * 86_400, + "a level 1 leaf may not be valid for longer than 366 days, got {} days", + span / 86_400 + ); + } + + #[test] + fn reads_validity_dates_as_iso_8601_and_as_instants() { + let cert = leaf(); assert!( cert.not_before.len() == 20 && cert.not_before.ends_with('Z'), "unexpected notBefore {:?}", cert.not_before ); assert!(cert.not_after > cert.not_before); + assert_eq!( + crate::c2pa::clock::to_rfc3339(cert.not_before_at), + cert.not_before + ); } #[test] - fn recognises_the_root_as_a_ca() { - let der = pem_to_der(crate::c2pa::signer::SIGNING_ROOT_CA_PEM).unwrap(); - let root = parse_certificate(&der[0]).unwrap(); + fn recognises_the_certificate_authorities() { + let root = parse_certificate(&testpki::root_ca_der()).unwrap(); assert!(root.is_ca); - assert_eq!(root.issuer, root.subject, "the root should be self-signed"); + assert!(root.is_self_issued(), "the root should be self-signed"); + assert!(root.allows(key_usage::KEY_CERT_SIGN)); + + let issuing = parse_certificate(&testpki::issuing_ca_der()).unwrap(); + assert!(issuing.is_ca); + assert_eq!(issuing.path_len, Some(0)); + assert!(!issuing.is_self_issued()); + } + + #[test] + fn the_tbs_bytes_are_the_exact_slice_the_issuer_signed() { + // If this is off by so much as the header, every chain verification + // fails with a signature mismatch that looks like a key problem. + let der = testpki::leaf_der(); + let cert = parse_certificate(&der).unwrap(); + let outer = read_tlv(&der).unwrap(); + let tbs = read_tlv(outer.value).unwrap(); + assert_eq!(cert.tbs.len(), tbs.total); + assert_eq!(cert.tbs, &outer.value[..tbs.total]); + } + + #[test] + fn issuer_and_subject_names_match_byte_for_byte_across_the_chain() { + let leaf = leaf(); + let issuing = parse_certificate(&testpki::issuing_ca_der()).unwrap(); + assert_eq!(leaf.issuer_der, issuing.subject_der); + assert_eq!( + leaf.authority_key_identifier, + issuing.subject_key_identifier + ); + } + + #[test] + fn the_timestamp_authority_asserts_only_time_stamping() { + let tsa = parse_certificate(&testpki::tsa_signer_der()).unwrap(); + assert_eq!(tsa.extended_key_usage_oids, vec!["1.3.6.1.5.5.7.3.8"]); } #[test] diff --git a/crates/imagecore/src/lib.rs b/crates/imagecore/src/lib.rs index 9222b16..5a6fea1 100644 --- a/crates/imagecore/src/lib.rs +++ b/crates/imagecore/src/lib.rs @@ -69,6 +69,20 @@ pub struct Editor { /// store is a few kilobytes and is all a new manifest needs to carry the /// provenance chain forward. credentials: Option, + /// An export that has been built up to the point of needing a signature, + /// waiting for the Backend to answer. One slot: the interface only ever + /// has one save in flight, and a queue here would be a way to sign the + /// wrong image. + pending: Option, +} + +/// An export paused mid-flight while the claim-signer signs its claim. +struct PendingSign { + prepared: c2pa::Prepared, + width: u32, + height: u32, + mime: String, + extension: String, } #[wasm_bindgen] @@ -80,9 +94,17 @@ impl Editor { /// `hint` should be the original filename or MIME type. Content sniffing /// covers most formats; TGA has no leading magic number, so without the /// hint it cannot be identified at all. + /// `validation_json` carries what a validator needs and WebAssembly cannot + /// find for itself: the current time, and any trust lists the host wants + /// the signer checked against. See [`ValidationRequest`]. #[wasm_bindgen(js_name = open)] - pub fn open(bytes: &[u8], hint: Option) -> Result { + pub fn open( + bytes: &[u8], + hint: Option, + validation_json: Option, + ) -> Result { let decoded = codec::decode(bytes, hint.as_deref())?; + let options = ValidationRequest::parse(validation_json.as_deref())?; // Read any Content Credentials while the encoded bytes are still to // hand - the hard binding is over those bytes, so it cannot be checked @@ -90,7 +112,7 @@ impl Editor { // reported as "none found" rather than failing the open: a broken // credential is no reason to refuse to edit someone's photo. let credentials = if c2pa::supports_format(&decoded.format) { - c2pa::read_jpeg(bytes).ok().flatten() + c2pa::validate_jpeg(bytes, &options).ok().flatten() } else { None }; @@ -100,6 +122,7 @@ impl Editor { source_format: decoded.format, had_alpha: decoded.had_alpha, credentials, + pending: None, }) } @@ -130,6 +153,7 @@ impl Editor { // Raw pixels arrive already decoded by the browser, so whatever // container they came from is gone and there is nothing to read. credentials: None, + pending: None, }) } @@ -219,16 +243,111 @@ impl Editor { /// Compose at full resolution and encode. `encode_json` accepts /// `{"format":"jpeg","quality":85,"pngCompression":"default","background":[255,255,255]}`. /// - /// `sign_json` requests Content Credentials. Empty means "do not sign"; - /// otherwise it is a [`SignOptions`], and the caller supplies the clock and - /// the randomness because WebAssembly has neither. + /// This is the unsigned path. Writing Content Credentials takes two calls, + /// because the signature comes from the Backend subsystem over the network: + /// [`Editor::prepare_signed_export`] then [`Editor::complete_signed_export`]. #[wasm_bindgen(js_name = renderExport)] pub fn render_export( &mut self, pipeline_json: &str, encode_json: &str, + ) -> Result { + let (encoded, _, _) = self.encode(pipeline_json, encode_json)?; + Ok(encoded) + } + + /// Build a manifest for the export and return the bytes that need signing. + /// + /// What comes back is a `Sig_structure`: a few hundred bytes holding the + /// claim, the certificate chain and a context string. The image is not in + /// it, and does not leave the tab. Hand those bytes to the claim-signer, + /// then pass its answer to [`Editor::complete_signed_export`]. + #[wasm_bindgen(js_name = prepareSignedExport)] + pub fn prepare_signed_export( + &mut self, + pipeline_json: &str, + encode_json: &str, sign_json: &str, + identity_json: &str, + ) -> Result { + let identity = parse_identity(identity_json)?; + let options: SignOptions = + serde_json::from_str(sign_json).map_err(|e| Error::Credentials(e.to_string()))?; + + let (encoded, pipeline, dims) = self.encode(pipeline_json, encode_json)?; + + // Refusing rather than silently skipping. The caller only reaches this + // method when the user asked for a credential, and quietly handing back + // an unsigned file would be the one failure mode worth avoiding. + if !c2pa::supports_format(&encoded.extension) { + return Err(Error::Credentials(format!( + "Content Credentials can only be written to JPEG, not {}", + encoded.extension + )) + .into()); + } + + let prepared = self.build_manifest(&encoded.bytes, &pipeline, dims, &options, identity)?; + let to_be_signed = prepared.to_be_signed.clone(); + let claim_len = prepared.claim.len() as u32; + + self.pending = Some(PendingSign { + prepared, + width: encoded.width, + height: encoded.height, + mime: encoded.mime.clone(), + extension: encoded.extension.clone(), + }); + + Ok(PendingExport { + to_be_signed, + claim_len, + }) + } + + /// Finish the export begun by [`Editor::prepare_signed_export`]. + /// + /// `timestamp` is the DER `TimeStampToken` the Backend obtained, or absent + /// if it could not reach a time-stamping authority. The file is written + /// either way; without one, the credential stops validating when the + /// signing certificate expires, and the interface says so. + #[wasm_bindgen(js_name = completeSignedExport)] + pub fn complete_signed_export( + &mut self, + signature: &[u8], + timestamp: Option>, ) -> Result { + let pending = self + .pending + .take() + .ok_or_else(|| Error::Credentials("no export is waiting for a signature".into()))?; + + let signed = c2pa::manifest::complete(&pending.prepared, signature, timestamp.as_deref()) + .map_err(Error::Credentials)?; + + Ok(ExportResult { + width: pending.width, + height: pending.height, + mime: pending.mime, + extension: pending.extension, + bytes: signed.jpeg, + manifest_bytes: u32::try_from(signed.embedded_len).unwrap_or(u32::MAX), + time_stamped: signed.time_stamped, + }) + } + + /// Throw away a prepared export, for when signing was cancelled or failed. + #[wasm_bindgen(js_name = abandonSignedExport)] + pub fn abandon_signed_export(&mut self) { + self.pending = None; + } + + /// Render and encode, shared by the signed and unsigned paths. + fn encode( + &mut self, + pipeline_json: &str, + encode_json: &str, + ) -> Result<(ExportResult, Pipeline, (u32, u32)), Error> { let pipeline = Pipeline::parse(pipeline_json)?; let request: EncodeRequest = if encode_json.trim().is_empty() { EncodeRequest::default() @@ -245,47 +364,32 @@ impl Editor { png_compression: request.png_compression, background: request.background, }; - let mut bytes = codec::encode(&rendered.image, format, &opts)?; - - let mut manifest_bytes = 0u32; - if !sign_json.trim().is_empty() { - let options: SignOptions = - serde_json::from_str(sign_json).map_err(|e| Error::Credentials(e.to_string()))?; - - // Refusing rather than silently skipping. The caller only sets this - // when the user asked for a credential, and quietly handing back an - // unsigned file would be the one failure mode worth avoiding. - if !c2pa::supports_format(&request.format) { - return Err(Error::Credentials(format!( - "Content Credentials can only be written to JPEG, not {}", - request.format - )) - .into()); - } - - let signed = self.sign(&bytes, &pipeline, (width, height), &options)?; - manifest_bytes = u32::try_from(signed.embedded_len).unwrap_or(u32::MAX); - bytes = signed.jpeg; - } - - Ok(ExportResult { - width, - height, - mime: format.mime().to_string(), - extension: format.extension().to_string(), - bytes, - manifest_bytes, - }) + let bytes = codec::encode(&rendered.image, format, &opts)?; + + Ok(( + ExportResult { + width, + height, + mime: format.mime().to_string(), + extension: format.extension().to_string(), + bytes, + manifest_bytes: 0, + time_stamped: false, + }, + pipeline, + (width, height), + )) } - /// Attach a manifest to freshly encoded JPEG bytes. - fn sign( + /// Build the manifest for freshly encoded JPEG bytes, up to the signature. + fn build_manifest( &mut self, jpeg: &[u8], pipeline: &Pipeline, output: (u32, u32), options: &SignOptions, - ) -> Result { + identity: c2pa::SigningIdentity, + ) -> Result { // Something was opened in every case the editor supports - there is no // "File > New" here - so the first action is always c2pa.opened and // there is always a parentOf ingredient to point it at. @@ -319,7 +423,7 @@ impl Editor { thumbnail, }; - c2pa::sign_jpeg(jpeg, &request).map_err(Error::Credentials) + c2pa::manifest::prepare(jpeg, request, identity).map_err(Error::Credentials) } /// A small JPEG of the finished image for the `c2pa.thumbnail.claim` @@ -479,6 +583,7 @@ pub struct ExportResult { extension: String, bytes: Vec, manifest_bytes: u32, + time_stamped: bool, } #[wasm_bindgen] @@ -508,6 +613,12 @@ impl ExportResult { pub fn manifest_bytes(&self) -> u32 { self.manifest_bytes } + /// Whether the credential carries an RFC 3161 time-stamp, which is what + /// keeps it validating after the signing certificate expires. + #[wasm_bindgen(getter, js_name = timeStamped)] + pub fn time_stamped(&self) -> bool { + self.time_stamped + } /// Moves the encoded bytes out to JS, leaving this result empty. #[wasm_bindgen(js_name = takeBytes)] pub fn take_bytes(&mut self) -> Vec { @@ -569,35 +680,161 @@ pub fn capabilities() -> String { ) } -/// What this build can do with Content Credentials, and who it signs as. +/// What this build can do with Content Credentials. /// -/// The `untrusted` flag is not decoration. A browser claim generator publishes -/// its signing key by existing, so the identity in every credential it writes -/// is unverifiable, and the interface has to say so rather than showing a green -/// tick. See `signing/README.md`. +/// Note what is *not* here: a signer. The Edge subsystem holds no key and no +/// certificate — it learns both from the claim-signer at run time — so +/// capabilities can only report the shape of what it will do, never an +/// identity. That is the visible consequence of the architecture the C2PA +/// Conformance Program requires; see `crates/imagecore/src/c2pa/identity.rs`. fn content_credentials_json() -> String { - let Ok(signer) = c2pa::signer::describe() else { - return "{\"available\":false}".to_string(); - }; + format!( + "{{\"available\":true,\"formats\":[\"jpeg\"],\"specVersion\":\"{}\",\ + \"remoteSigning\":true,\"timeStamping\":true}}", + c2pa::SPEC_VERSION, + ) +} - let escape = |value: &str| value.replace('\\', "\\\\").replace('"', "\\\""); - let usages: Vec = signer - .extended_key_usage - .iter() - .map(|eku| format!("\"{}\"", escape(eku))) - .collect(); +/// Describe a signing identity the host fetched from the claim-signer. +/// +/// The host passes back what `GET /v1/identity` returned; this parses the +/// certificate chain, checks it is usable, and hands back what the interface +/// should show — including the Assurance Level and Conforming Products List +/// record the certificate carries, which are the two facts that distinguish a +/// conformant Generator Product from anything else. +#[wasm_bindgen(js_name = describeSigningIdentity)] +pub fn describe_signing_identity(identity_json: &str) -> Result { + let identity = parse_identity(identity_json)?; + let described = identity.describe().map_err(Error::Credentials)?; + serde_json::to_string(&described) + .map_err(|e| Error::Credentials(e.to_string())) + .map_err(Into::into) +} - format!( - "{{\"available\":true,\"formats\":[\"jpeg\"],\"signer\":{{\ - \"name\":\"{}\",\"organisation\":\"{}\",\"issuer\":\"{}\",\ - \"expires\":\"{}\",\"algorithm\":\"ES256\",\"keyUsage\":[{}],\ - \"untrusted\":{},\"timeStamped\":false,\"source\":\"{}\"}}}}", - escape(&signer.common_name), - escape(&signer.organisation), - escape(&signer.issuer), - escape(&signer.not_after), - usages.join(","), - signer.anchor_is_self_signed, - signer.credential_source, +/// Validate the Content Credentials in a JPEG without opening it for editing. +/// +/// Returns the report as JSON, or `None` when the file carries none. +#[wasm_bindgen(js_name = inspectJpeg)] +pub fn inspect_jpeg(bytes: &[u8], validation_json: &str) -> Result, JsError> { + let options = ValidationRequest::parse(Some(validation_json))?; + let report = c2pa::validate_jpeg(bytes, &options).map_err(Error::Credentials)?; + Ok(report.map(|report| report.to_json())) +} + +/// Validate a JPEG and return the result as crJSON. +/// +/// This is the same validator the interface uses, serialised the way the C2PA +/// Conformance Program asks for evidence. `crates/c2pa-harness` is the +/// command-line front end over the identical code path, so what a reviewer sees +/// is what a user sees. +#[wasm_bindgen(js_name = validateToCrJson)] +pub fn validate_to_crjson(bytes: &[u8], validation_json: &str) -> Result { + let options = ValidationRequest::parse(Some(validation_json))?; + let report = c2pa::validate_jpeg(bytes, &options) + .map_err(Error::Credentials)? + .ok_or_else(|| Error::Credentials("the file carries no Content Credentials".into()))?; + Ok(c2pa::to_crjson(&report).to_string()) +} + +/// The signing identity as the claim-signer publishes it. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct IdentityRequest { + /// PEM certificate chain, end-entity first, trust anchor omitted. + chain_pem: String, + /// COSE algorithm name, e.g. `ES256`. + algorithm: String, + /// Which key version this chain belongs to. + #[serde(default)] + key_id: String, + /// Bytes to reserve for a time-stamp token. Zero means the Backend has no + /// time-stamping authority configured. + #[serde(default)] + timestamp_budget: usize, +} + +fn parse_identity(json: &str) -> Result { + let request: IdentityRequest = + serde_json::from_str(json).map_err(|e| Error::Credentials(e.to_string()))?; + let algorithm = c2pa::identity::alg::from_name(&request.algorithm).ok_or_else(|| { + Error::Credentials(format!( + "{} is not a signature algorithm this build understands", + request.algorithm + )) + })?; + c2pa::SigningIdentity::from_pem( + &request.chain_pem, + algorithm, + request.key_id, + request.timestamp_budget, ) + .map_err(Error::Credentials) +} + +/// What a validator needs that WebAssembly cannot find for itself. +/// +/// These are the Conformance Program's harness inputs, in the shape the browser +/// passes them: a validation time and two trust lists. Every field is optional, +/// and an absent trust list means "check the integrity, report the identity as +/// unverified" rather than "trust everything". +#[derive(serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct ValidationRequest { + /// RFC 3339. Required in practice - there is no clock here - but a missing + /// one falls back to the Unix epoch, which makes every certificate read as + /// not yet valid rather than silently valid. + #[serde(default)] + now: String, + #[serde(default)] + trust_list_pem: String, + #[serde(default)] + tsa_trust_list_pem: String, +} + +impl ValidationRequest { + fn parse(json: Option<&str>) -> Result { + let request: ValidationRequest = match json { + Some(json) if !json.trim().is_empty() => { + serde_json::from_str(json).map_err(|e| Error::Credentials(e.to_string()))? + } + _ => ValidationRequest::default(), + }; + + let load = |pem: &str| -> Result { + if pem.trim().is_empty() { + return Ok(c2pa::TrustStore::empty()); + } + c2pa::TrustStore::from_pem(pem) + .map(|(store, _)| store) + .map_err(Error::Credentials) + }; + + Ok(c2pa::ValidationOptions { + trust: load(&request.trust_list_pem)?, + tsa_trust: load(&request.tsa_trust_list_pem)?, + validation_time: c2pa::clock::parse_rfc3339(&request.now).unwrap_or(0), + }) + } +} + +/// An export waiting on the claim-signer. +#[wasm_bindgen] +pub struct PendingExport { + to_be_signed: Vec, + claim_len: u32, +} + +#[wasm_bindgen] +impl PendingExport { + /// The `Sig_structure` to send to the claim-signer. Moves the buffer out. + #[wasm_bindgen(js_name = takeToBeSigned)] + pub fn take_to_be_signed(&mut self) -> Vec { + std::mem::take(&mut self.to_be_signed) + } + /// Size of the claim inside it, for the interface to show what is being + /// sent. + #[wasm_bindgen(getter, js_name = claimBytes)] + pub fn claim_bytes(&self) -> u32 { + self.claim_len + } } diff --git a/crates/imagecore/tests/c2pa.rs b/crates/imagecore/tests/c2pa.rs index f8ad16a..4be6fca 100644 --- a/crates/imagecore/tests/c2pa.rs +++ b/crates/imagecore/tests/c2pa.rs @@ -2,11 +2,23 @@ //! //! The unit tests inside each module check one layer at a time. These check the //! thing that actually matters: that a JPEG signed by this engine validates -//! when read back, that tampering with it stops validating, and that a second -//! edit chains onto the first instead of erasing it. +//! when read back, that tampering with it stops validating, that the signer +//! chains to a trust anchor, that a time-stamp outlives the certificate, and +//! that a second edit chains onto the first instead of erasing it. +//! +//! # Standing in for the Backend +//! +//! Signing is a network call in production: the Edge hands over a +//! `Sig_structure`, `services/claim-signer` returns a signature and a +//! time-stamp. [`sign`] below does the same thing with the test key in-process. +//! The seam it exercises is the real one — `prepare` and `complete` are the +//! same functions the browser calls — so nothing about the two-phase flow is +//! mocked away. use image::{ImageFormat, Rgb, RgbImage}; -use imagecore::c2pa::{self, manifest, SignRequest}; +use imagecore::c2pa::{ + self, clock, manifest, testpki, SignRequest, Signed, TrustStore, ValidationOptions, +}; use imagecore::pipeline::{AdjustSpec, Crop, Pipeline, Resize}; use std::io::Cursor; @@ -41,6 +53,60 @@ fn request(title: &str) -> SignRequest { } } +/// Sign as the Backend subsystem would, with no time-stamp. +fn sign(jpeg: &[u8], request: &SignRequest) -> Signed { + sign_with(jpeg, request, testpki::identity_without_timestamps(), false) +} + +/// Sign and time-stamp, as a Backend with a configured TSA would. +fn sign_timestamped(jpeg: &[u8], request: &SignRequest) -> Signed { + sign_with(jpeg, request, testpki::identity(), true) +} + +fn sign_with( + jpeg: &[u8], + request: &SignRequest, + identity: c2pa::SigningIdentity, + timestamp: bool, +) -> Signed { + let prepared = + manifest::prepare(jpeg, request.clone(), identity).expect("preparing the manifest"); + + // What crosses the wire, and all that crosses it. + let signature = testpki::sign_es256(&prepared.to_be_signed); + + // RFC 3161 stamps the signature, not the claim. + let token = timestamp.then(|| testpki::issue_timestamp(&signature, testpki::validation_time())); + + manifest::complete(&prepared, &signature, token.as_deref()).expect("completing the manifest") +} + +/// Validation with the test trust lists, at a time inside the certificate's +/// window. +fn trusting() -> ValidationOptions { + ValidationOptions { + trust: TrustStore::from_pem(testpki::TRUST_LIST_PEM).unwrap().0, + tsa_trust: TrustStore::from_pem(testpki::TSA_TRUST_LIST_PEM).unwrap().0, + validation_time: testpki::validation_time(), + } +} + +/// Validation with no trust lists, which is what a browser with no list +/// configured does. +fn untrusting() -> ValidationOptions { + ValidationOptions::untrusted(testpki::validation_time()) +} + +fn read(jpeg: &[u8], options: &ValidationOptions) -> c2pa::ValidationReport { + c2pa::validate_jpeg(jpeg, options) + .expect("reading") + .expect("the signed file should carry a manifest") +} + +fn codes(status: &[manifest::Status]) -> Vec<&str> { + status.iter().map(|s| s.code.as_str()).collect() +} + fn edited_pipeline() -> Pipeline { Pipeline { crop: Some(Crop { @@ -75,10 +141,8 @@ fn a_signed_jpeg_validates() { store: None, }); - let signed = c2pa::sign_jpeg(&source, &request).expect("signing"); - let report = c2pa::read_jpeg(&signed.jpeg) - .expect("reading") - .expect("the signed file should carry a manifest"); + let signed = sign(&source, &request); + let report = read(&signed.jpeg, &trusting()); assert!( report.is_valid(), @@ -86,17 +150,22 @@ fn a_signed_jpeg_validates() { report.active.status.failure ); - // The three checks that matter, each reported by its specification code. - let codes: Vec<&str> = report - .active - .status - .success - .iter() - .map(|s| s.code.as_str()) - .collect(); - assert!(codes.contains(&"assertion.dataHash.match"), "{codes:?}"); - assert!(codes.contains(&"assertion.hashedURI.match"), "{codes:?}"); - assert!(codes.contains(&"claimSignature.validated"), "{codes:?}"); + // Every check that matters, each reported by its specification code. + let success = codes(&report.active.status.success); + assert!(success.contains(&"assertion.dataHash.match"), "{success:?}"); + assert!( + success.contains(&"assertion.hashedURI.match"), + "{success:?}" + ); + assert!(success.contains(&"claimSignature.validated"), "{success:?}"); + assert!( + success.contains(&"signingCredential.trusted"), + "{success:?}" + ); + assert!( + success.contains(&"claimSignature.insideValidity"), + "{success:?}" + ); assert_eq!(report.active.title, "holiday.jpg"); assert_eq!(report.active.claim_version, 2); @@ -104,11 +173,74 @@ fn a_signed_jpeg_validates() { assert_eq!(report.active.signature.algorithm, "ES256"); } +#[test] +fn the_claim_declares_the_specification_version_it_was_built_to() { + // A Conformance Program requirement: the value has to match the product's + // Conforming Products List record. + let signed = sign(&jpeg(160, 120), &request("photo.jpg")); + let report = read(&signed.jpeg, &untrusting()); + assert_eq!(report.active.spec_version, c2pa::SPEC_VERSION); +} + +#[test] +fn the_actions_assertion_declares_that_it_is_complete() { + // allActionsIncluded is optional in the specification and mandatory under + // the Conformance Program, because an asset rubric cannot classify + // provenance that might be missing steps. + let mut request = request("edited.jpg"); + request.actions = c2pa::actions_for(&edited_pipeline(), true, (80, 60)); + let signed = sign(&jpeg(320, 240), &request); + let report = read(&signed.jpeg, &untrusting()); + + let (_, actions) = report + .active + .raw + .assertions + .iter() + .find(|(label, _)| label == "c2pa.actions.v2") + .expect("the actions assertion should be present"); + let actions = actions.as_ref().expect("it should decode"); + assert_eq!( + actions.get("allActionsIncluded"), + Some(&imagecore::c2pa::cbor::Value::Bool(true)) + ); +} + +#[test] +fn every_action_that_needs_a_digital_source_type_carries_one_in_the_file() { + // Checked on the way back out, not just on the way in: a field that is + // dropped by the encoder would pass the unit test and fail conformance. + let mut request = request("edited.jpg"); + request.actions = c2pa::actions_for(&edited_pipeline(), true, (80, 60)); + let signed = sign(&jpeg(320, 240), &request); + let report = read(&signed.jpeg, &untrusting()); + + for action in &report.active.actions { + if c2pa::requires_digital_source_type(&action.action) { + assert!( + action + .digital_source_type + .starts_with("http://cv.iptc.org/"), + "{} came back with digitalSourceType {:?}", + action.action, + action.digital_source_type + ); + } + if c2pa::forbids_digital_source_type(&action.action) { + assert!( + action.digital_source_type.is_empty(), + "{} must not carry a digitalSourceType", + action.action + ); + } + } +} + #[test] fn the_signed_file_is_still_a_readable_jpeg() { // A credential nobody can open is worse than no credential. let source = jpeg(200, 150); - let signed = c2pa::sign_jpeg(&source, &request("photo.jpg")).unwrap(); + let signed = sign(&source, &request("photo.jpg")); let decoded = image::load_from_memory_with_format(&signed.jpeg, ImageFormat::Jpeg) .expect("a signed JPEG must still decode"); @@ -127,8 +259,8 @@ fn the_actions_survive_the_round_trip() { let mut request = request("edited.jpg"); request.actions = c2pa::actions_for(&edited_pipeline(), true, (80, 60)); - let signed = c2pa::sign_jpeg(&jpeg(320, 240), &request).unwrap(); - let report = c2pa::read_jpeg(&signed.jpeg).unwrap().unwrap(); + let signed = sign(&jpeg(320, 240), &request); + let report = read(&signed.jpeg, &untrusting()); let names: Vec<&str> = report .active @@ -161,22 +293,17 @@ fn the_actions_survive_the_round_trip() { #[test] fn tampering_with_the_pixels_is_caught() { - let signed = c2pa::sign_jpeg(&jpeg(200, 150), &request("photo.jpg")).unwrap(); + let signed = sign(&jpeg(200, 150), &request("photo.jpg")); // Flip a byte deep in the entropy-coded scan data, well past any header. let mut tampered = signed.jpeg.clone(); let target = tampered.len() - 32; tampered[target] ^= 0xFF; - let report = c2pa::read_jpeg(&tampered).unwrap().unwrap(); + let report = read(&tampered, &trusting()); assert!(!report.is_valid(), "a modified image must not validate"); assert!( - report - .active - .status - .failure - .iter() - .any(|s| s.code == "assertion.dataHash.mismatch"), + codes(&report.active.status.failure).contains(&"assertion.dataHash.mismatch"), "expected a data hash mismatch, got {:?}", report.active.status.failure ); @@ -186,12 +313,11 @@ fn tampering_with_the_pixels_is_caught() { fn tampering_with_an_assertion_is_caught() { // Rewriting an action inside the manifest leaves the image bytes alone, so // only the assertion's hashed URI catches it. - let signed = c2pa::sign_jpeg(&jpeg(200, 150), &{ + let signed = sign(&jpeg(200, 150), &{ let mut request = request("photo.jpg"); request.actions = vec![manifest::Action::new("c2pa.cropped").describe("cropped to A")]; request - }) - .unwrap(); + }); let needle = b"cropped to A"; let at = signed @@ -203,15 +329,10 @@ fn tampering_with_an_assertion_is_caught() { let mut tampered = signed.jpeg.clone(); tampered[at + needle.len() - 1] = b'B'; - let report = c2pa::read_jpeg(&tampered).unwrap().unwrap(); + let report = read(&tampered, &trusting()); assert!(!report.is_valid()); assert!( - report - .active - .status - .failure - .iter() - .any(|s| s.code == "assertion.hashedURI.mismatch"), + codes(&report.active.status.failure).contains(&"assertion.hashedURI.mismatch"), "expected a hashed URI mismatch, got {:?}", report.active.status.failure ); @@ -219,7 +340,7 @@ fn tampering_with_an_assertion_is_caught() { #[test] fn tampering_with_the_claim_is_caught_by_the_signature() { - let signed = c2pa::sign_jpeg(&jpeg(200, 150), &request("truthful-title.jpg")).unwrap(); + let signed = sign(&jpeg(200, 150), &request("truthful-title.jpg")); let needle = b"truthful-title.jpg"; let at = signed @@ -231,32 +352,189 @@ fn tampering_with_the_claim_is_caught_by_the_signature() { let mut tampered = signed.jpeg.clone(); tampered[at] = b'T'; - let report = c2pa::read_jpeg(&tampered).unwrap().unwrap(); + let report = read(&tampered, &trusting()); assert!(!report.is_valid()); assert!( - report - .active - .status - .failure - .iter() - .any(|s| s.code == "claimSignature.mismatch"), + codes(&report.active.status.failure).contains(&"claimSignature.mismatch"), "expected a signature mismatch, got {:?}", report.active.status.failure ); } +/* ------------------------------------------------------------------------- +Trust +------------------------------------------------------------------------- */ + +#[test] +fn a_signer_on_the_trust_list_is_reported_as_trusted() { + let signed = sign(&jpeg(160, 120), &request("photo.jpg")); + let report = read(&signed.jpeg, &trusting()); + + assert!(report.active.signature.trusted); + assert!(report + .active + .signature + .trust_anchor + .contains("Test Root CA")); + // The two facts a relying party actually needs, straight from the + // certificate rather than from anything the manifest says about itself. + assert_eq!(report.active.signature.assurance_level, Some(1)); + assert_eq!( + report.active.signature.cpl_record_id, + "00000000-0000-0000-0000-000000000000" + ); +} + +#[test] +fn a_signer_with_no_trust_list_is_reported_as_unchecked_not_as_failed() { + // The distinction matters: "nobody looked" and "we looked and it failed" + // call for different words in front of a reader. + let signed = sign(&jpeg(160, 120), &request("photo.jpg")); + let report = read(&signed.jpeg, &untrusting()); + + assert!(!report.active.signature.trusted); + assert!( + report.is_valid(), + "an unchecked signer is not a validation failure: {:?}", + report.active.status.failure + ); + assert!(codes(&report.active.status.informational).contains(&"signingCredential.untrusted")); +} + +#[test] +fn a_signer_that_fails_against_a_supplied_trust_list_is_a_failure() { + let signed = sign(&jpeg(160, 120), &request("photo.jpg")); + // A real trust list that simply does not contain this signer's root. + let options = ValidationOptions { + trust: TrustStore::from_pem(testpki::TSA_TRUST_LIST_PEM).unwrap().0, + tsa_trust: TrustStore::empty(), + validation_time: testpki::validation_time(), + }; + + let report = read(&signed.jpeg, &options); + assert!(!report.is_valid()); + assert!(codes(&report.active.status.failure).contains(&"signingCredential.untrusted")); +} + +/* ------------------------------------------------------------------------- +Time-stamps +------------------------------------------------------------------------- */ + +#[test] +fn a_time_stamped_credential_carries_its_attested_time() { + let signed = sign_timestamped(&jpeg(200, 150), &request("photo.jpg")); + assert!(signed.time_stamped); + + let report = read(&signed.jpeg, &trusting()); + assert!( + report.is_valid(), + "failures: {:?}", + report.active.status.failure + ); + assert!(report.active.signature.time_stamped); + assert_eq!( + report.active.signature.time_stamp, + clock::to_rfc3339(testpki::validation_time()) + ); + let success = codes(&report.active.status.success); + assert!(success.contains(&"timeStamp.trusted"), "{success:?}"); + assert!(success.contains(&"timeStamp.validated"), "{success:?}"); +} + +#[test] +fn a_time_stamp_keeps_a_credential_valid_after_the_certificate_expires() { + // This is the whole reason time-stamping was added. An Assurance Level 1 + // certificate lasts at most 366 days; without a time-stamp every image the + // editor ever signed would stop validating on that anniversary. + let signed = sign_timestamped(&jpeg(200, 150), &request("photo.jpg")); + + let long_after = ValidationOptions { + validation_time: testpki::after_expiry(), + ..trusting() + }; + let report = read(&signed.jpeg, &long_after); + + assert!( + report.is_valid(), + "a time-stamped credential must outlive its certificate; failures: {:?}", + report.active.status.failure + ); + assert!(codes(&report.active.status.success).contains(&"claimSignature.insideValidity")); +} + +#[test] +fn without_a_time_stamp_an_expired_certificate_fails() { + let signed = sign(&jpeg(200, 150), &request("photo.jpg")); + + let long_after = ValidationOptions { + validation_time: testpki::after_expiry(), + ..trusting() + }; + let report = read(&signed.jpeg, &long_after); + + assert!(!report.is_valid()); + assert!( + codes(&report.active.status.failure).contains(&"claimSignature.outsideValidity"), + "got {:?}", + report.active.status.failure + ); +} + +#[test] +fn a_time_stamp_from_an_untrusted_authority_is_ignored_not_fatal() { + // Section 15.8.2: an unusable time-stamp is informational, and validation + // falls back to the current time. + let signed = sign_timestamped(&jpeg(200, 150), &request("photo.jpg")); + + let no_tsa_list = ValidationOptions { + tsa_trust: TrustStore::empty(), + ..trusting() + }; + let report = read(&signed.jpeg, &no_tsa_list); + + assert!( + report.is_valid(), + "failures: {:?}", + report.active.status.failure + ); + assert!(!report.active.signature.time_stamped); + assert!(codes(&report.active.status.informational).contains(&"timestamp.untrusted")); +} + +#[test] +fn reserving_room_for_a_time_stamp_does_not_change_the_file_when_none_arrives() { + // The Backend may fail to reach its TSA. The manifest was already sized for + // one, so the reservation has to become padding without disturbing a single + // offset. + let request = request("photo.jpg"); + let source = jpeg(200, 150); + let with_room = sign_with(&source, &request, testpki::identity(), false); + let stamped = sign_with(&source, &request, testpki::identity(), true); + + assert_eq!( + with_room.embedded_len, stamped.embedded_len, + "the manifest must be the same size whether or not a token arrived" + ); + assert!(read(&with_room.jpeg, &trusting()).is_valid()); + assert!(read(&stamped.jpeg, &trusting()).is_valid()); +} + +/* ------------------------------------------------------------------------- +Provenance +------------------------------------------------------------------------- */ + #[test] fn a_second_edit_chains_onto_the_first() { // The point of provenance: editing an already-credentialed image should // carry its history forward rather than starting over. - let first = c2pa::sign_jpeg(&jpeg(320, 240), &{ + let first = sign(&jpeg(320, 240), &{ let mut request = request("original.jpg"); - request.actions = vec![manifest::Action::new("c2pa.created")]; + request.actions = vec![manifest::Action::new("c2pa.created") + .source_type("http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture")]; request - }) - .unwrap(); + }); - let parent_report = c2pa::read_jpeg(&first.jpeg).unwrap().unwrap(); + let parent_report = read(&first.jpeg, &trusting()); assert!(parent_report.is_valid()); let mut second = request("edited.jpg"); @@ -276,8 +554,8 @@ fn a_second_edit_chains_onto_the_first() { // The second edit is applied to a re-encode of the first, as the real // pipeline does; the credential describes the new bytes. - let signed = c2pa::sign_jpeg(&jpeg(80, 60), &second).unwrap(); - let report = c2pa::read_jpeg(&signed.jpeg).unwrap().unwrap(); + let signed = sign(&jpeg(80, 60), &second); + let report = read(&signed.jpeg, &trusting()); assert!( report.is_valid(), @@ -313,10 +591,10 @@ fn a_second_edit_chains_onto_the_first() { #[test] fn re_signing_replaces_the_credential() { - let once = c2pa::sign_jpeg(&jpeg(200, 150), &request("first.jpg")).unwrap(); - let twice = c2pa::sign_jpeg(&once.jpeg, &request("second.jpg")).unwrap(); + let once = sign(&jpeg(200, 150), &request("first.jpg")); + let twice = sign(&once.jpeg, &request("second.jpg")); - let report = c2pa::read_jpeg(&twice.jpeg).unwrap().unwrap(); + let report = read(&twice.jpeg, &trusting()); assert!( report.is_valid(), "re-signing must produce a valid file; failures: {:?}", @@ -332,8 +610,8 @@ fn a_thumbnail_survives_the_round_trip() { let mut request = request("photo.jpg"); request.thumbnail = Some(thumbnail.clone()); - let signed = c2pa::sign_jpeg(&jpeg(200, 150), &request).unwrap(); - let report = c2pa::read_jpeg(&signed.jpeg).unwrap().unwrap(); + let signed = sign(&jpeg(200, 150), &request); + let report = read(&signed.jpeg, &trusting()); assert!(report.is_valid()); assert_eq!(report.active.thumbnail.as_ref(), Some(&thumbnail)); @@ -350,14 +628,14 @@ fn a_manifest_that_spans_several_app11_segments_validates() { let mut request = request("large.jpg"); request.thumbnail = Some(jpeg(900, 700)); - let signed = c2pa::sign_jpeg(&jpeg(200, 150), &request).unwrap(); + let signed = sign(&jpeg(200, 150), &request); assert!( signed.manifest_len > 64_000, "expected a multi-segment manifest, got {} bytes", signed.manifest_len ); - let report = c2pa::read_jpeg(&signed.jpeg).unwrap().unwrap(); + let report = read(&signed.jpeg, &trusting()); assert!( report.is_valid(), "failures: {:?}", @@ -367,15 +645,17 @@ fn a_manifest_that_spans_several_app11_segments_validates() { #[test] fn an_unsigned_jpeg_reports_no_credentials() { - assert!(c2pa::read_jpeg(&jpeg(64, 64)).unwrap().is_none()); + assert!(c2pa::validate_jpeg(&jpeg(64, 64), &untrusting()) + .unwrap() + .is_none()); } #[test] fn the_exclusion_range_is_exactly_the_manifest() { // If the excluded range were even a byte off, the hard binding would either // hash part of its own manifest or leave image bytes unprotected. - let signed = c2pa::sign_jpeg(&jpeg(200, 150), &request("photo.jpg")).unwrap(); - let report = c2pa::read_jpeg(&signed.jpeg).unwrap().unwrap(); + let signed = sign(&jpeg(200, 150), &request("photo.jpg")); + let report = read(&signed.jpeg, &trusting()); assert!(report.is_valid()); assert_eq!(report.store_len, signed.embedded_len); @@ -393,7 +673,7 @@ fn the_exclusion_range_is_exactly_the_manifest() { fn widening_the_exclusion_range_is_rejected() { // The attack the exclusion check exists for: claim a bigger excluded region // and hide a change to the image inside it. - let signed = c2pa::sign_jpeg(&jpeg(200, 150), &request("photo.jpg")).unwrap(); + let signed = sign(&jpeg(200, 150), &request("photo.jpg")); let extracted = imagecore::c2pa::jpegxt::extract(&signed.jpeg) .unwrap() .unwrap(); @@ -410,7 +690,7 @@ fn widening_the_exclusion_range_is_rejected() { let mut tampered = signed.jpeg.clone(); tampered[at..at + 4].copy_from_slice(&(extracted.length as u32 + 64).to_be_bytes()); - let report = c2pa::read_jpeg(&tampered).unwrap().unwrap(); + let report = read(&tampered, &trusting()); assert!(!report.is_valid(), "a widened exclusion must be rejected"); assert!(report .active @@ -426,26 +706,64 @@ fn signing_the_same_input_twice_is_byte_identical() { // makes every other test here reproducible, and it means a rebuild of the // same edit produces the same file rather than a gratuitously new one. let source = jpeg(200, 150); - let first = c2pa::sign_jpeg(&source, &request("photo.jpg")).unwrap(); - let second = c2pa::sign_jpeg(&source, &request("photo.jpg")).unwrap(); + let first = sign(&source, &request("photo.jpg")); + let second = sign(&source, &request("photo.jpg")); assert_eq!(first.jpeg, second.jpeg); } #[test] -fn the_signer_reports_itself_as_untrusted() { - // The UI depends on this being visible rather than inferred. - let signed = c2pa::sign_jpeg(&jpeg(120, 90), &request("photo.jpg")).unwrap(); - let report = c2pa::read_jpeg(&signed.jpeg).unwrap().unwrap(); +fn the_signature_leaves_the_tab_but_the_image_does_not() { + // The one property the whole distributed architecture exists to preserve: + // what goes to the Backend is a claim, and the picture is not in it. + let source = jpeg(200, 150); + let prepared = manifest::prepare( + &source, + request("photo.jpg"), + testpki::identity_without_timestamps(), + ) + .unwrap(); + // The Sig_structure is dominated by the certificate chain, not by pixels. assert!( - report - .active - .status - .informational - .iter() - .any(|s| s.code == "signingCredential.untrusted"), - "validation must say the signer was never checked against a trust list" + prepared.to_be_signed.len() < source.len() / 4, + "the bytes sent for signing ({}) should be a fraction of the image ({})", + prepared.to_be_signed.len(), + source.len() + ); + + // And no run of image bytes appears inside it. + let scan = &source[source.len() - 256..]; + assert!( + !prepared.to_be_signed.windows(scan.len()).any(|w| w == scan), + "image data must not appear in what is sent for signing" + ); +} + +#[test] +fn a_signature_of_the_wrong_length_is_refused() { + // A Backend that answered with a DER signature, or with a P-384 one, would + // otherwise produce a manifest whose offsets are silently wrong. + let prepared = manifest::prepare( + &jpeg(120, 90), + request("photo.jpg"), + testpki::identity_without_timestamps(), + ) + .unwrap(); + let error = manifest::complete(&prepared, &[0u8; 70], None).unwrap_err(); + assert!(error.contains("70 bytes"), "{error}"); +} + +#[test] +fn an_oversized_time_stamp_is_reported_so_the_caller_can_reserve_more() { + let identity = testpki::identity(); + let prepared = + manifest::prepare(&jpeg(120, 90), request("photo.jpg"), identity.clone()).unwrap(); + let signature = testpki::sign_es256(&prepared.to_be_signed); + let huge = vec![0u8; prepared.timestamp_budget() + 8192]; + + let error = manifest::complete(&prepared, &signature, Some(&huge)).unwrap_err(); + assert!( + error.contains(imagecore::c2pa::cose::ERR_RESERVATION_TOO_SMALL), + "{error}" ); - assert!(!report.active.signature.time_stamped); - assert!(report.active.signature.subject.contains("Untrusted")); } diff --git a/crates/imagecore/tests/evidence.rs b/crates/imagecore/tests/evidence.rs new file mode 100644 index 0000000..7148267 --- /dev/null +++ b/crates/imagecore/tests/evidence.rs @@ -0,0 +1,202 @@ +//! Sample assets for the C2PA Conformance Program. +//! +//! The Program asks a Generator Product applicant for "sample output media +//! files of every asserted generate and validate media type", with their +//! crJSON alongside. This writes the media files; `c2pa-harness` writes the +//! crJSON, and `conformance/scripts/generate-evidence.sh` runs both. +//! +//! Marked `#[ignore]` because it writes into the repository rather than +//! asserting anything, so an ordinary `cargo test` does not touch the working +//! tree. Run it with: +//! +//! ```text +//! cargo test -p imagecore --test evidence -- --ignored --nocapture +//! ``` +//! +//! Each sample is chosen to exercise a different part of what an assessor will +//! look at, rather than to be a set of near-identical files. + +use std::path::PathBuf; + +use image::{ImageFormat, Rgb, RgbImage}; +use imagecore::c2pa::{self, manifest, testpki, SignRequest}; +use imagecore::pipeline::{AdjustSpec, Crop, Pipeline, Resize}; + +fn output_dir() -> PathBuf { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../conformance/evidence/assets"); + std::fs::create_dir_all(&dir).expect("creating the evidence directory"); + dir +} + +fn jpeg(width: u32, height: u32) -> Vec { + let image = RgbImage::from_fn(width, height, |x, y| { + Rgb([ + (x * 5 % 256) as u8, + (y * 9 % 256) as u8, + ((x ^ y) % 256) as u8, + ]) + }); + let mut bytes = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut bytes), ImageFormat::Jpeg) + .expect("encoding a JPEG"); + bytes +} + +/// Everything non-deterministic is pinned, so re-running produces byte-identical +/// evidence and a reviewer can diff two runs. +fn request(title: &str, manifest_id: &str) -> SignRequest { + SignRequest { + title: title.to_string(), + generator: c2pa::generator(), + now: "2026-08-25T12:00:00Z".to_string(), + instance_id: format!("xmp:iid:{manifest_id}"), + manifest_id: format!("urn:c2pa:{manifest_id}"), + actions: Vec::new(), + parent: Some(manifest::Parent { + title: "camera-original.jpg".into(), + format: "image/jpeg".into(), + instance_id: "xmp:iid:00000000-0000-4000-8000-000000000000".into(), + store: None, + }), + thumbnail: None, + } +} + +fn sign(source: &[u8], request: SignRequest, timestamped: bool) -> Vec { + let identity = if timestamped { + testpki::identity() + } else { + testpki::identity_without_timestamps() + }; + let prepared = manifest::prepare(source, request, identity).expect("preparing"); + 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()) + .expect("completing") + .jpeg +} + +fn write(name: &str, bytes: &[u8]) { + let path = output_dir().join(name); + std::fs::write(&path, bytes).expect("writing the sample"); + println!("wrote {} ({} bytes)", path.display(), bytes.len()); +} + +#[test] +#[ignore = "writes evidence into the repository; run with --ignored"] +fn write_conformance_samples() { + let source = jpeg(640, 480); + + // 1. The ordinary case: an edited photograph, time-stamped. This is what + // the product produces in normal operation and what most of the + // assessment will be about. + let mut edited = request( + "edited-photograph.jpg", + "11111111-1111-4111-8111-111111111111", + ); + edited.actions = c2pa::actions_for( + &Pipeline { + crop: Some(Crop { + x: 20, + y: 30, + width: 400, + height: 300, + }), + quarter_turns: 1, + resize: Some(Resize { + width: 200, + height: 150, + filter: "lanczos3".into(), + }), + adjust: AdjustSpec { + brightness: 0.1, + saturation: -0.2, + ..Default::default() + }, + ..Default::default() + }, + true, + (200, 150), + ); + edited.thumbnail = Some(jpeg(160, 120)); + write("01-edited-timestamped.jpg", &sign(&source, edited, true)); + + // 2. The same edit with no time-stamp, so an assessor can see what the + // validator says about long-term validity when the TSA was unreachable. + let mut untimestamped = request( + "edited-photograph.jpg", + "22222222-2222-4222-8222-222222222222", + ); + untimestamped.actions = c2pa::actions_for(&Pipeline::default(), true, (640, 480)); + write("02-no-timestamp.jpg", &sign(&source, untimestamped, false)); + + // 3. A file opened and saved with nothing changed. The actions assertion + // has to say so honestly rather than implying an edit. + let mut untouched = request("unchanged.jpg", "33333333-3333-4333-8333-333333333333"); + untouched.actions = c2pa::actions_for(&Pipeline::default(), true, (640, 480)); + write("03-opened-unchanged.jpg", &sign(&source, untouched, true)); + + // 4. Two generations, so the ingredient and the inherited manifest are both + // in the evidence. This is the sample that shows the product ingests + // manifests, which the Program asks about separately. + let first = sign( + &source, + { + let mut r = request("generation-one.jpg", "44444444-4444-4444-8444-444444444444"); + r.actions = c2pa::actions_for(&Pipeline::default(), true, (640, 480)); + r + }, + true, + ); + let parent = c2pa::validate_jpeg( + &first, + &c2pa::ValidationOptions::untrusted(testpki::validation_time()), + ) + .expect("reading generation one") + .expect("generation one should carry a manifest"); + + let mut second = request("generation-two.jpg", "55555555-5555-4555-8555-555555555555"); + second.actions = c2pa::actions_for( + &Pipeline { + adjust: AdjustSpec { + grayscale: true, + ..Default::default() + }, + ..Default::default() + }, + true, + (320, 240), + ); + second.parent = Some(manifest::Parent { + title: "generation-one.jpg".into(), + format: "image/jpeg".into(), + instance_id: parent.active.instance_id.clone(), + store: Some(manifest::ParentStore { + bytes: parent.store.clone(), + active_manifest: parent.active.label.clone(), + status: parent.active.status.clone(), + }), + }); + write( + "04-two-generations.jpg", + &sign(&jpeg(320, 240), second, true), + ); + + // 5. A deliberately broken one. The Program's own library includes assets + // that must fail, and evidence that the validator reports a failure is + // as important as evidence that it reports a success. + let mut tampered = sign( + &source, + { + let mut r = request("tampered.jpg", "66666666-6666-4666-8666-666666666666"); + r.actions = c2pa::actions_for(&Pipeline::default(), true, (640, 480)); + r + }, + true, + ); + let at = tampered.len() - 64; + tampered[at] ^= 0xFF; + write("05-tampered-pixels.jpg", &tampered); +} diff --git a/package.json b/package.json index e5dc103..8777461 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,14 @@ "description": "A WebAssembly image editor - convert, resize, crop and rotate entirely in the browser. Rust engine, no uploads.", "type": "module", "scripts": { - "build:wasm": "wasm-pack build crates/imagecore --target web --out-dir ../../src/wasm --out-name imagecore --release", - "dev": "npm run build:wasm && vite", - "build": "npm run build:wasm && tsc && vite build", - "preview": "vite preview", - "test": "cargo test -p imagecore", - "typecheck": "tsc --noEmit" + "build:wasm": "wasm-pack build crates/imagecore --target web --out-dir ../../apps/editor/src/wasm --out-name imagecore --release", + "dev": "npm run build:wasm && vite apps/editor", + "build": "npm run build:wasm && npm run typecheck && vite build apps/editor", + "preview": "vite preview apps/editor", + "test": "cargo test --workspace", + "typecheck": "tsc --noEmit -p apps/editor/tsconfig.json", + "sbom": "./conformance/scripts/sbom.sh", + "audit:supply-chain": "./conformance/scripts/vulnerability-scan.sh" }, "devDependencies": { "typescript": "~5.9.3", diff --git a/services/claim-signer/Cargo.toml b/services/claim-signer/Cargo.toml new file mode 100644 index 0000000..ff1888c --- /dev/null +++ b/services/claim-signer/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "claim-signer" +version = "0.1.0" +edition = "2021" +description = "The Backend subsystem of the A10city Image Editor Generator Product: the only place a C2PA claim signing key exists" +license = "MIT" +publish = false + +[[bin]] +name = "claim-signer" +path = "src/main.rs" + +[dependencies] +# Shared with the Edge subsystem so the two cannot disagree about DER, the +# certificate profile, or what a time-stamp token looks like. +imagecore = { path = "../../crates/imagecore", default-features = false } + +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] } +axum-server = { version = "0.7", features = ["tls-rustls-no-provider"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] } +tower = { version = "0.5", default-features = false } +tower-http = { version = "0.6", features = ["limit", "timeout", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rustls-pemfile = "2" +rustls-pki-types = "1" +webpki-roots = "1" + +# The RFC 3161 client. rustls only: the C2PA Conformance Program's O.5 asks for +# TLS 1.3 on every hop between subsystems, and a native-TLS build would put that +# in the hands of whatever the host OS happens to ship. +ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" + +# Key protection. AES-256-GCM for the key at rest, HMAC-SHA256 for request +# authentication, and zeroize so the plaintext key does not outlive the signing +# operation that needed it. +aes-gcm = { version = "0.10", features = ["aes"] } +hmac = "0.12" +sha2 = "0.10" +subtle = "2" +zeroize = { version = "1", features = ["zeroize_derive"] } +rand = "0.8" + +p256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } +p384 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } + +[dev-dependencies] +imagecore = { path = "../../crates/imagecore", default-features = false, features = ["test-pki"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } diff --git a/services/claim-signer/README.md b/services/claim-signer/README.md new file mode 100644 index 0000000..b04d023 --- /dev/null +++ b/services/claim-signer/README.md @@ -0,0 +1,187 @@ +# claim-signer + +The Backend subsystem of the A10city Image Editor Generator Product, and the +only place a C2PA claim signing key exists. + +```text + browser (Edge) claim-signer (Backend) + ──────────────── ────────────────────────────── + decode, edit, encode + build claim + assertions + Sig_structure ────── TLS 1.3 ──────▶ authenticate the caller + (~1 KB; no pixels) decrypt the key for one operation + sign + ask the TSA to stamp the signature + ◀───── signature ───── zeroise, log + + TimeStampToken + assemble COSE_Sign1, embed +``` + +## Why it exists + +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 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. + +Moving the *signature* off the client is the smallest change that fixes it. The +image is not sent: what crosses the wire is the `Sig_structure` — the claim, the +certificate chain and a context string — and it comes back with 64 bytes of +signature attached. + +## Running it + +```sh +export CLAIM_SIGNER_KEYSTORE=/var/lib/claim-signer +export CLAIM_SIGNER_KEK_FILE=/run/secrets/claim-signer-kek +export CLAIM_SIGNER_CLIENTS=/etc/claim-signer/clients.json +export CLAIM_SIGNER_TLS_CERT=/etc/claim-signer/tls.pem +export CLAIM_SIGNER_TLS_KEY=/etc/claim-signer/tls.key +export CLAIM_SIGNER_TSA_URL=https://ts.example.com/rfc3161 + +claim-signer serve +``` + +| Variable | Meaning | +|---|---| +| `CLAIM_SIGNER_BIND` | listen address, default `0.0.0.0:8443` | +| `CLAIM_SIGNER_KEYSTORE` | directory holding the sealed signing keys | +| `CLAIM_SIGNER_KEK_FILE` | file holding the key-encryption key, 32 Base64 bytes | +| `CLAIM_SIGNER_KEK` | the same, as an environment variable — the fallback, because `/proc` is readable | +| `CLAIM_SIGNER_CLIENTS` | JSON: `{ "": "" }` | +| `CLAIM_SIGNER_TLS_CERT`, `CLAIM_SIGNER_TLS_KEY` | the server certificate and key | +| `CLAIM_SIGNER_CLIENT_CA` | optional: require mutual TLS against this CA bundle | +| `CLAIM_SIGNER_TSA_URL` | RFC 3161 endpoint; unset disables time-stamping | +| `CLAIM_SIGNER_TIMESTAMP_BUDGET` | bytes the Edge reserves for a token, default 12288 | +| `CLAIM_SIGNER_ALLOW_PLAINTEXT` | development only; logs a warning naming O.5 | + +Every one of these fails the service closed if it is missing or wrong. A +key-encryption key that does not decrypt the active version, or a key that does +not match the certificate beside it, refuses to start — a mismatched deployment +should not come up and fail one user's save, it should not come up. + +### Endpoints + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `GET` | `/healthz` | none | liveness, active key id, `notAfter`, whether time-stamping is on | +| `GET` | `/v1/identity` | none | the public credential: chain PEM, algorithm, key id, time-stamp budget, Assurance Level, CPL record id | +| `POST` | `/v1/sign` | HMAC | `{"toBeSigned": ""}` → `{"signature", "timestampToken"?, "keyId", "timestampError"?}` | + +`/v1/identity` is unauthenticated because everything it returns is public, and +needing a credential to learn which certificate the service holds would make the +system harder to debug for no gain. + +### Authenticating a caller + +```text +Authorization: C2PA-HMAC-SHA256 key=, ts=, nonce=, mac= + +mac = HMAC-SHA256(secret, + method ‖ "\n" ‖ path ‖ "\n" ‖ ts ‖ "\n" ‖ nonce ‖ "\n" ‖ hex(SHA-256(body))) +``` + +Symmetric key MAC is one of the methods O.2 names. It covers the request rather +than being a bearer token, so a captured header cannot be pointed at a different +claim. The timestamp must be within 120 seconds, nonces are remembered for that +window, and a failed attempt does not consume its nonce — otherwise an attacker +could burn one they had observed and the real request behind it would be +rejected as a replay. + +Unknown key and bad MAC produce the same response, so the endpoint is not a way +to enumerate valid key ids. The log distinguishes them. + +The browser gets its secret from the application server, minted per session and +short-lived. It is a rate and abuse control, which is exactly the role the +requirement scopes it to — "only for the purposes of limiting access to the +Backend subsystem" — and not a proof of identity. A browser cannot keep a +secret; the trust model rests on the key in this service, not on that one. + +## The keystore + +```text +keystore/ + active the id of the version to sign with + 2026-08-signer/ + key.enc nonce ‖ AES-256-GCM(PKCS#8 DER), mode 0600 + chain.pem x5chain: leaf first, trust anchor omitted + meta.json { "algorithm": "ES256", "importedAt": … } +``` + +The key-encryption key never lives on the same filesystem as the ciphertext, and +the ciphertext is bound to its version id as additional authenticated data — so +lifting a `key.enc` from a retired version into the active one fails to decrypt +rather than silently signing with the wrong key. + +The plaintext exists inside `Keystore::sign` and nowhere else, in a buffer that +zeroes on drop. There is no accessor that hands the key to a caller, because a +key you cannot get hold of cannot be leaked by the next person to add a feature. + +### Rotation + +```sh +claim-signer import --id 2027-01-signer --key new.key --chain new.pem +claim-signer activate --id 2027-01-signer # then restart +claim-signer versions # '*' marks the active one +``` + +Two commands, deliberately: a new credential can be staged and inspected while +the old one is still signing. Retired versions stay — images signed under them +are still in the world. + +The Edge notices a rotation mid-export and retries rather than shipping a +signature that does not match the certificate already committed to in the +manifest. + +## Time-stamping + +An Assurance Level 1 certificate lasts at most 366 days, and §15.8 judges an +untimestamped manifest against the validity window *at the moment someone looks +at it*. 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. + +The service asks the TSA for a stamp over the signature — a 32-byte digest +crosses that hop, nothing else — and returns it. Where the authority is +unreachable, the response says so, the file is still written, and the interface +passes the reason on. Refusing to save someone's photograph because a third +party was down would be the wrong trade. + +The token is checked before it is returned: it must parse, its imprint must +cover the signature that was sent, and it must carry the TSA's certificate. A +stamp over the wrong bytes would otherwise be embedded, shipped, and noticed +only by someone else's validator. + +## Deploying it + +Objective **O.6** covers the hosting environment, and it is a property of the +deployment rather than of this code. What must be in place, and what an assessor +will ask to see, is set out in +`conformance/generator-product-security-architecture.md` §2.6. In summary: + +- a single-purpose project whose only workload is claim signing +- three IAM roles — runtime, operator, auditor — and no principal holding both + operator and control of the audit log destination +- the key-encryption key in the provider's secret manager, readable by the + runtime identity alone +- the container running as an unprivileged account with no shell +- weekly base-image rebuilds, and on any advisory affecting it +- 30/90/180-day remediation for High/Moderate/Low findings + +The service logs one structured JSON record per signature — the client, the key +id, a digest of the signature, and whether it was time-stamped — and one per +refusal with its reason. The claim itself is never logged. + +## Testing + +```sh +cargo test -p claim-signer +``` + +Twenty-five tests. The ones worth knowing about assert properties rather than +behaviour: that the plaintext key never appears in `key.enc`, that a sealed key +moved between versions does not decrypt, that a key which does not match its +certificate stops the service starting, that a replayed request is refused and a +forged one does not consume its nonce, and that the TSA request carries a digest +and not the signature. diff --git a/services/claim-signer/src/auth.rs b/services/claim-signer/src/auth.rs new file mode 100644 index 0000000..3cc0070 --- /dev/null +++ b/services/claim-signer/src/auth.rs @@ -0,0 +1,461 @@ +//! Authenticating the Edge subsystem to the Backend. +//! +//! # What the requirement is +//! +//! Objective O.2, Assurance Level 1, for the **Distributed** implementation +//! class this product uses: +//! +//! > The usage of the Edge subsystem authentication key (API Key) SHALL only be +//! > for the purposes of limiting access to the Backend subsystem. +//! > +//! > Edge and Backend subsystems SHALL be mutually authenticated […] +//! > +//! > For Distributed Implementation Class, the remote claim signing Backend +//! > subsystem of the GP TOE SHALL securely authenticate the calling client, +//! > positively confirming that the calling client is a valid instance of the +//! > Edge subsystem of the GP TOE, before signing a claim […] +//! +//! Symmetric key MAC is one of the methods the requirement names. That is what +//! this implements, over the whole request rather than as a bearer token, so +//! that a captured header cannot be replayed against a different body. +//! +//! ```text +//! Authorization: C2PA-HMAC-SHA256 key=, ts=, nonce=, mac= +//! +//! mac = HMAC-SHA256(secret, +//! method ‖ "\n" ‖ path ‖ "\n" ‖ ts ‖ "\n" ‖ nonce ‖ "\n" ‖ SHA-256(body)) +//! ``` +//! +//! The other direction — the Edge authenticating the Backend — is TLS. The +//! Edge only ever talks to a URL it was configured with, over TLS 1.3, and the +//! server certificate is what proves the Backend is the Backend. Where the +//! deployment also enables mutual TLS, that is a second, stronger client +//! check layered under this one; see `main.rs`. +//! +//! # Why the client secret is not enough on its own, and why that is fine +//! +//! A browser cannot keep a secret, so an attacker who reads the page can obtain +//! whatever the page holds. That is why the requirement scopes the Edge key to +//! "limiting access to the Backend subsystem" and nothing more: it is a rate +//! and abuse control, not a proof of identity, and the Backend's own key is +//! what the C2PA trust model rests on. In deployment the Edge secret is minted +//! per session by the application server, short-lived, and rate-limited — see +//! `conformance/generator-product-security-architecture.md`. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +type HmacSha256 = Hmac; + +/// How far a request's timestamp may be from the server's clock. +/// +/// Two minutes covers ordinary clock drift on a client machine without leaving +/// a captured request useful for long. +const CLOCK_SKEW_SECONDS: i64 = 120; + +/// How many nonces to remember. At the skew window above this is far more than +/// any real deployment produces, and it bounds the memory a flood can consume. +const NONCE_CAPACITY: usize = 16_384; + +#[derive(Debug, PartialEq, Eq)] +pub enum Denied { + Missing, + Malformed(&'static str), + UnknownKey, + BadSignature, + Stale, + Replayed, +} + +impl Denied { + /// What to tell the caller. + /// + /// Deliberately coarse: distinguishing "unknown key" from "bad signature" + /// to an unauthenticated caller turns the endpoint into an oracle for + /// enumerating valid key ids. The log records which it was. + pub fn public_message(&self) -> &'static str { + match self { + Denied::Missing => "an Authorization header is required", + Denied::Malformed(_) => "the Authorization header is malformed", + Denied::Stale => "the request timestamp is outside the accepted window", + Denied::Replayed => "this request has already been seen", + Denied::UnknownKey | Denied::BadSignature => "authentication failed", + } + } + + pub fn detail(&self) -> String { + match self { + Denied::Missing => "no Authorization header".into(), + Denied::Malformed(what) => format!("malformed Authorization header: {what}"), + Denied::UnknownKey => "unknown key id".into(), + Denied::BadSignature => "MAC mismatch".into(), + Denied::Stale => "timestamp outside the skew window".into(), + Denied::Replayed => "nonce already used".into(), + } + } +} + +/// The Edge instances allowed to ask for a signature. +pub struct Clients { + secrets: HashMap>, + seen: Mutex, +} + +#[derive(Default)] +struct Nonces { + /// `(expires_at, key, nonce)`, oldest first. + entries: std::collections::VecDeque<(i64, String, String)>, + live: std::collections::HashSet<(String, String)>, +} + +impl Clients { + /// Load client secrets from a JSON object of `{ "": "" }`. + pub fn from_json(json: &str) -> Result { + let raw: HashMap = + serde_json::from_str(json).map_err(|e| format!("the client secret file: {e}"))?; + if raw.is_empty() { + return Err("the client secret file names no clients".into()); + } + + let mut secrets = HashMap::new(); + for (id, encoded) in raw { + let secret = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|e| format!("the secret for '{id}' is not Base64: {e}"))?; + // 32 bytes is the output width of the MAC; a shorter secret adds + // nothing and a much shorter one is a mistake worth refusing. + if secret.len() < 32 { + return Err(format!( + "the secret for '{id}' is {} bytes; at least 32 are required", + secret.len() + )); + } + secrets.insert(id, secret); + } + + Ok(Clients { + secrets, + seen: Mutex::new(Nonces::default()), + }) + } + + pub fn len(&self) -> usize { + self.secrets.len() + } + + /// Authenticate a request, returning the client id it came from. + pub fn authenticate( + &self, + header: Option<&str>, + method: &str, + path: &str, + body: &[u8], + now: i64, + ) -> Result { + let header = header.ok_or(Denied::Missing)?; + let credential = Credential::parse(header)?; + + if (now - credential.timestamp).abs() > CLOCK_SKEW_SECONDS { + return Err(Denied::Stale); + } + + let secret = self + .secrets + .get(&credential.key_id) + .ok_or(Denied::UnknownKey)?; + + let expected = sign_request( + secret, + method, + path, + credential.timestamp, + &credential.nonce, + body, + ); + // Constant time: a byte-by-byte comparison here leaks the MAC one byte + // at a time to anyone willing to measure. + if expected.ct_eq(&credential.mac).unwrap_u8() != 1 { + return Err(Denied::BadSignature); + } + + // Only now, once the MAC is known good, is the nonce recorded. Doing it + // earlier would let an unauthenticated caller fill the table and evict + // real entries. + self.remember(&credential.key_id, &credential.nonce, now)?; + Ok(credential.key_id) + } + + fn remember(&self, key_id: &str, nonce: &str, now: i64) -> Result<(), Denied> { + let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner()); + // Drop anything that can no longer be replayed anyway. + while let Some((expires, key, value)) = seen.entries.front().cloned() { + if expires > now && seen.entries.len() < NONCE_CAPACITY { + break; + } + seen.entries.pop_front(); + seen.live.remove(&(key, value)); + } + + let entry = (key_id.to_string(), nonce.to_string()); + if !seen.live.insert(entry.clone()) { + return Err(Denied::Replayed); + } + seen.entries + .push_back((now + CLOCK_SKEW_SECONDS, entry.0, entry.1)); + Ok(()) + } +} + +struct Credential { + key_id: String, + timestamp: i64, + nonce: String, + mac: Vec, +} + +impl Credential { + fn parse(header: &str) -> Result { + let rest = header + .strip_prefix("C2PA-HMAC-SHA256 ") + .ok_or(Denied::Malformed("unrecognised scheme"))?; + + let mut key_id = None; + let mut timestamp = None; + let mut nonce = None; + let mut mac = None; + for part in rest.split(',') { + let (name, value) = part + .trim() + .split_once('=') + .ok_or(Denied::Malformed("expected name=value pairs"))?; + match name { + "key" => key_id = Some(value.to_string()), + "ts" => timestamp = value.parse::().ok(), + "nonce" => nonce = Some(value.to_string()), + "mac" => mac = base64::engine::general_purpose::STANDARD.decode(value).ok(), + _ => {} + } + } + + let credential = Credential { + key_id: key_id.ok_or(Denied::Malformed("no key id"))?, + timestamp: timestamp.ok_or(Denied::Malformed("no or unreadable timestamp"))?, + nonce: nonce.ok_or(Denied::Malformed("no nonce"))?, + mac: mac.ok_or(Denied::Malformed("no or unreadable MAC"))?, + }; + // A predictable nonce is no nonce at all, and a 16-byte one is what the + // client library sends. + if credential.nonce.len() < 16 { + return Err(Denied::Malformed("the nonce is too short")); + } + if credential.mac.len() != 32 { + return Err(Denied::Malformed("the MAC is not 32 bytes")); + } + Ok(credential) + } +} + +/// The canonical string, MACed. Exposed so the client library and the tests +/// build it the same way this does. +pub fn sign_request( + secret: &[u8], + method: &str, + path: &str, + timestamp: i64, + nonce: &str, + body: &[u8], +) -> Vec { + let digest = Sha256::digest(body); + let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); + mac.update(method.as_bytes()); + mac.update(b"\n"); + mac.update(path.as_bytes()); + mac.update(b"\n"); + mac.update(timestamp.to_string().as_bytes()); + mac.update(b"\n"); + mac.update(nonce.as_bytes()); + mac.update(b"\n"); + mac.update(&hex(&digest)); + mac.finalize().into_bytes().to_vec() +} + +fn hex(bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(bytes.len() * 2); + for byte in bytes { + out.extend_from_slice(format!("{byte:02x}").as_bytes()); + } + out +} + +/// Seconds since the epoch, for the skew check. +pub fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: [u8; 32] = [3u8; 32]; + + fn clients() -> Clients { + let encoded = base64::engine::general_purpose::STANDARD.encode(SECRET); + Clients::from_json(&format!("{{\"edge-1\":\"{encoded}\"}}")).unwrap() + } + + fn header(nonce: &str, timestamp: i64, body: &[u8], secret: &[u8]) -> String { + let mac = sign_request(secret, "POST", "/v1/sign", timestamp, nonce, body); + format!( + "C2PA-HMAC-SHA256 key=edge-1, ts={timestamp}, nonce={nonce}, mac={}", + base64::engine::general_purpose::STANDARD.encode(mac) + ) + } + + #[test] + fn a_correctly_signed_request_is_accepted() { + let clients = clients(); + let body = br#"{"toBeSigned":"AAAA"}"#; + let header = header("0123456789abcdef", 1_800_000_000, body, &SECRET); + assert_eq!( + clients + .authenticate(Some(&header), "POST", "/v1/sign", body, 1_800_000_000) + .unwrap(), + "edge-1" + ); + } + + #[test] + fn a_changed_body_invalidates_the_mac() { + // The point of MACing the body rather than issuing a bearer token: a + // captured header cannot be pointed at a different claim. + let clients = clients(); + let header = header("0123456789abcdef", 1_800_000_000, b"one", &SECRET); + assert_eq!( + clients.authenticate(Some(&header), "POST", "/v1/sign", b"two", 1_800_000_000), + Err(Denied::BadSignature) + ); + } + + #[test] + fn a_changed_path_or_method_invalidates_the_mac() { + let clients = clients(); + let body = b"x"; + let header = header("0123456789abcdef", 1_800_000_000, body, &SECRET); + assert_eq!( + clients.authenticate(Some(&header), "POST", "/v1/identity", body, 1_800_000_000), + Err(Denied::BadSignature) + ); + assert_eq!( + clients.authenticate(Some(&header), "GET", "/v1/sign", body, 1_800_000_000), + Err(Denied::BadSignature) + ); + } + + #[test] + fn a_replayed_request_is_refused() { + let clients = clients(); + let body = b"x"; + let header = header("0123456789abcdef", 1_800_000_000, body, &SECRET); + assert!(clients + .authenticate(Some(&header), "POST", "/v1/sign", body, 1_800_000_000) + .is_ok()); + assert_eq!( + clients.authenticate(Some(&header), "POST", "/v1/sign", body, 1_800_000_000), + Err(Denied::Replayed) + ); + } + + #[test] + fn a_stale_request_is_refused_before_the_secret_is_consulted() { + let clients = clients(); + let body = b"x"; + let header = header("0123456789abcdef", 1_800_000_000, body, &SECRET); + assert_eq!( + clients.authenticate(Some(&header), "POST", "/v1/sign", body, 1_800_000_600), + Err(Denied::Stale) + ); + // And a request from the future, which is the same problem mirrored. + assert_eq!( + clients.authenticate(Some(&header), "POST", "/v1/sign", body, 1_799_999_400), + Err(Denied::Stale) + ); + } + + #[test] + fn a_failed_request_does_not_consume_its_nonce() { + // Otherwise anyone could burn a nonce they had observed, and the real + // request behind it would be rejected as a replay. + let clients = clients(); + let body = b"x"; + let forged = header("0123456789abcdef", 1_800_000_000, body, &[9u8; 32]); + assert_eq!( + clients.authenticate(Some(&forged), "POST", "/v1/sign", body, 1_800_000_000), + Err(Denied::BadSignature) + ); + + let genuine = header("0123456789abcdef", 1_800_000_000, body, &SECRET); + assert!(clients + .authenticate(Some(&genuine), "POST", "/v1/sign", body, 1_800_000_000) + .is_ok()); + } + + #[test] + fn an_unknown_key_and_a_bad_mac_look_the_same_from_outside() { + // The endpoint must not become a way to enumerate valid key ids. + assert_eq!( + Denied::UnknownKey.public_message(), + Denied::BadSignature.public_message() + ); + // But the log distinguishes them. + assert_ne!(Denied::UnknownKey.detail(), Denied::BadSignature.detail()); + } + + #[test] + fn malformed_headers_are_rejected_rather_than_parsed_loosely() { + let clients = clients(); + for header in [ + "Bearer abc", + "C2PA-HMAC-SHA256 ", + "C2PA-HMAC-SHA256 key=edge-1", + "C2PA-HMAC-SHA256 key=edge-1, ts=notanumber, nonce=0123456789abcdef, mac=AAAA", + "C2PA-HMAC-SHA256 key=edge-1, ts=1800000000, nonce=short, mac=AAAA", + ] { + assert!( + matches!( + clients.authenticate(Some(header), "POST", "/v1/sign", b"x", 1_800_000_000), + Err(Denied::Malformed(_)) + ), + "{header:?} should not parse" + ); + } + assert_eq!( + clients.authenticate(None, "POST", "/v1/sign", b"x", 1_800_000_000), + Err(Denied::Missing) + ); + } + + #[test] + fn a_short_client_secret_is_refused_at_load_time() { + let short = base64::engine::general_purpose::STANDARD.encode([1u8; 8]); + // `unwrap_err` would need `Clients: Debug`, and deriving that on a + // type holding client secrets is exactly how secrets end up in logs. + let Err(error) = Clients::from_json(&format!("{{\"edge-1\":\"{short}\"}}")) else { + panic!("a short secret should be refused"); + }; + assert!(error.contains("at least 32"), "{error}"); + } + + #[test] + fn an_empty_client_list_is_refused() { + assert!(Clients::from_json("{}").is_err()); + } +} diff --git a/services/claim-signer/src/keystore.rs b/services/claim-signer/src/keystore.rs new file mode 100644 index 0000000..42c77d7 --- /dev/null +++ b/services/claim-signer/src/keystore.rs @@ -0,0 +1,702 @@ +//! Where the claim signing key lives, and how briefly it exists in the clear. +//! +//! # What objective O.2 actually asks for +//! +//! The C2PA Generator Product Security Requirements, Assurance Level 1: +//! +//! > Where persistent storage is required, the GP TOE SHALL store the claim +//! > signing key in encrypted form, using industry best practices for +//! > encryption algorithms and key lengths. The GP TOE SHALL keep the claim +//! > signing key encrypted when present in volatile memory, except when the key +//! > is being prepared for use in signing claims […] +//! > +//! > GP TOE SHALL control access to the signing key in decrypted form, +//! > following the principle of least privilege […] +//! > +//! > GP TOE SHALL be capable of rotating the claim signing key. +//! +//! Three requirements, and this module is the answer to all three: +//! +//! - **Encrypted at rest.** AES-256-GCM, with a key-encryption key that comes +//! from outside the file system the ciphertext lives on. In the reference +//! deployment that is a cloud KMS; in development it is an environment +//! variable, and the service says which it used at startup so nobody +//! discovers the difference in production. +//! - **Encrypted in memory except while signing.** [`Keystore`] holds only the +//! ciphertext. The plaintext exists inside [`Keystore::sign`] and nowhere +//! else, in a buffer that zeroes itself on the way out. There is no accessor +//! that hands the key to a caller, because a key you cannot get hold of +//! cannot be leaked by the next person to add a feature. +//! - **Rotatable.** Versions are directories; one symlink-free `active` file +//! names the current one. `claim-signer import` and `claim-signer activate` +//! do the rotation, and old versions stay readable so that images signed +//! under them keep validating. +//! +//! # Layout +//! +//! ```text +//! keystore/ +//! active the id of the version to sign with +//! 2026-08-signer-1/ +//! key.enc nonce ‖ AES-256-GCM(private key, PKCS#8 DER) +//! chain.pem x5chain: leaf first, trust anchor omitted +//! meta.json { "algorithm": "ES256", "importedAt": … } +//! ``` + +use std::path::Path; + +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::Aes256Gcm; +use imagecore::c2pa::x509; +use rand::RngCore; +use zeroize::Zeroizing; + +/// Additional authenticated data, so a `key.enc` cannot be moved between +/// versions or between deployments without the decryption failing. +const AAD_PREFIX: &[u8] = b"a10city/claim-signer/key/v1/"; +const NONCE_LEN: usize = 12; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum Error { + /// Something about the deployment is wrong: a missing file, an unreadable + /// key, a key-encryption key of the wrong length. + Configuration(String), + /// The ciphertext did not authenticate. Either the key-encryption key is + /// wrong or the stored key has been tampered with; both are fatal and + /// neither should be distinguished to a caller. + Unusable(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Configuration(m) => write!(f, "{m}"), + Error::Unusable(m) => write!(f, "{m}"), + } + } +} + +impl std::error::Error for Error {} + +fn config(message: impl Into) -> Result { + Err(Error::Configuration(message.into())) +} + +/// A 32-byte key-encryption key, held only as long as the process runs. +/// +/// Wrapped rather than passed as a `[u8; 32]` so that it zeroes on drop and so +/// that its provenance travels with it — the startup banner reports whether the +/// deployment is using a KMS or an environment variable, and that reporting is +/// only honest if the answer is recorded where the key is. +pub struct KeyEncryptionKey { + material: Zeroizing<[u8; 32]>, + pub source: &'static str, +} + +impl KeyEncryptionKey { + /// Read the key-encryption key from the environment. + /// + /// `CLAIM_SIGNER_KEK_FILE` wins over `CLAIM_SIGNER_KEK`: a file can be a + /// mounted secret with its own access control, where an environment + /// variable is visible to anything that can read `/proc`. Both take + /// standard Base64 of exactly 32 bytes. + pub fn from_environment() -> Result { + let (encoded, source) = match ( + std::env::var("CLAIM_SIGNER_KEK_FILE").ok(), + std::env::var("CLAIM_SIGNER_KEK").ok(), + ) { + (Some(path), _) => { + let contents = std::fs::read_to_string(&path) + .map_err(|e| Error::Configuration(format!("reading {path}: {e}")))?; + (contents.trim().to_string(), "file") + } + (None, Some(value)) => (value.trim().to_string(), "environment"), + (None, None) => { + return config( + "no key-encryption key: set CLAIM_SIGNER_KEK_FILE (preferred) or \ + CLAIM_SIGNER_KEK to 32 Base64-encoded bytes", + ) + } + }; + + Self::from_base64(&encoded, source) + } + + pub fn from_base64(encoded: &str, source: &'static str) -> Result { + use base64::Engine as _; + let decoded = Zeroizing::new( + base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|e| { + Error::Configuration(format!("the key-encryption key is not Base64: {e}")) + })?, + ); + if decoded.len() != 32 { + return config(format!( + "the key-encryption key must be 32 bytes, not {}", + decoded.len() + )); + } + let mut material = Zeroizing::new([0u8; 32]); + material.copy_from_slice(&decoded); + Ok(KeyEncryptionKey { material, source }) + } + + fn cipher(&self) -> Aes256Gcm { + Aes256Gcm::new(self.material.as_slice().into()) + } +} + +/// One version of the signing credential. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionMeta { + /// COSE algorithm name, e.g. `ES256`. + pub algorithm: String, + /// When this version was imported, RFC 3339. + pub imported_at: String, + /// Free text: which CA issued it, which order it came from. + #[serde(default)] + pub note: String, +} + +/// The signing credential, with the private half still encrypted. +pub struct Version { + pub id: String, + pub meta: VersionMeta, + /// PEM chain, leaf first. Public, and served to the Edge. + pub chain_pem: String, + /// The leaf, parsed, so the service can report the Assurance Level and + /// notice an expiring certificate before a validator does. + pub leaf: x509::Certificate, + /// `nonce ‖ ciphertext ‖ tag`, exactly as stored. + sealed: Vec, +} + +/// The signing credentials on disk, and the operations allowed on them. +pub struct Keystore { + kek: KeyEncryptionKey, + active: Version, +} + +impl Keystore { + /// Open a keystore and load the active version. + pub fn open(root: impl AsRef, kek: KeyEncryptionKey) -> Result { + let root = root.as_ref(); + let id = active_id(root)?; + let active = load_version(root, &id)?; + + // Prove the key decrypts and matches its certificate now, at startup, + // rather than on the first signing request. A deployment with a + // mismatched key should refuse to come up, not fail one user's save. + let store = Keystore { kek, active }; + store.verify_active()?; + Ok(store) + } + + pub fn active(&self) -> &Version { + &self.active + } + + pub fn kek_source(&self) -> &'static str { + self.kek.source + } + + /// Sign `message` with the active key. + /// + /// The plaintext key exists only inside this function. It is decrypted, + /// used, and zeroed before returning — `Zeroizing` for the DER, and the + /// `p256`/`p384` key types zero their own scalars on drop. + pub fn sign(&self, message: &[u8]) -> Result> { + let der = self.unseal()?; + match self.active.meta.algorithm.as_str() { + "ES256" => { + use p256::ecdsa::signature::Signer; + use p256::pkcs8::DecodePrivateKey; + let key = p256::ecdsa::SigningKey::from_pkcs8_der(&der) + .map_err(|e| Error::Unusable(format!("the stored key is not P-256: {e}")))?; + let signature: p256::ecdsa::Signature = key.sign(message); + Ok(signature.to_bytes().to_vec()) + } + "ES384" => { + use p384::ecdsa::signature::Signer; + use p384::pkcs8::DecodePrivateKey; + let key = p384::ecdsa::SigningKey::from_pkcs8_der(&der) + .map_err(|e| Error::Unusable(format!("the stored key is not P-384: {e}")))?; + let signature: p384::ecdsa::Signature = key.sign(message); + Ok(signature.to_bytes().to_vec()) + } + other => config(format!( + "{other} is not a signature algorithm this build can sign with" + )), + } + } + + /// Decrypt the active key. Private on purpose: see the module docs. + fn unseal(&self) -> Result>> { + let sealed = &self.active.sealed; + if sealed.len() <= NONCE_LEN { + return Err(Error::Unusable("the stored key is truncated".into())); + } + let (nonce, ciphertext) = sealed.split_at(NONCE_LEN); + let aad = aad_for(&self.active.id); + self.kek + .cipher() + .decrypt( + nonce.into(), + Payload { + msg: ciphertext, + aad: &aad, + }, + ) + .map(Zeroizing::new) + .map_err(|_| { + Error::Unusable( + "the stored key did not decrypt: the key-encryption key is wrong, or the \ + keystore has been altered" + .into(), + ) + }) + } + + /// Check the active key matches the certificate beside it. + /// + /// A mismatch produces signatures that fail against the very certificate + /// shipped with them, and the failure would otherwise surface in someone + /// else's validator days later. + fn verify_active(&self) -> Result<()> { + let probe = b"claim-signer startup self-check"; + let signature = self.sign(probe)?; + let algorithm = match self.active.meta.algorithm.as_str() { + "ES256" => imagecore::c2pa::identity::alg::ES256, + "ES384" => imagecore::c2pa::identity::alg::ES384, + other => return config(format!("unsupported algorithm {other}")), + }; + imagecore::c2pa::verify::by_cose_algorithm(algorithm, &self.active.leaf, probe, &signature) + .map_err(|e| { + Error::Configuration(format!( + "the active signing key does not match the certificate beside it: {e}" + )) + }) + } + + /// Import a new version. This is half of key rotation; [`Keystore::activate`] + /// is the other half, kept separate so a new credential can be staged and + /// checked before anything starts signing with it. + pub fn import( + root: impl AsRef, + kek: &KeyEncryptionKey, + id: &str, + key_pem: &str, + chain_pem: &str, + meta: VersionMeta, + ) -> Result<()> { + if id.is_empty() + || !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return config("a version id may only hold letters, digits, '-' and '_'"); + } + let directory = root.as_ref().join(id); + if directory.exists() { + return config(format!("version {id} already exists")); + } + + let der = private_key_der(key_pem)?; + let chain = x509::pem_to_der(chain_pem) + .map_err(|e| Error::Configuration(format!("the certificate chain: {e}")))?; + let leaf = chain + .first() + .ok_or_else(|| Error::Configuration("the certificate chain is empty".into())) + .and_then(|der| { + x509::parse_certificate(der) + .map_err(|e| Error::Configuration(format!("the leaf certificate: {e}"))) + })?; + if leaf.is_ca { + return config("the end-entity certificate must not be a CA"); + } + + let mut nonce = [0u8; NONCE_LEN]; + rand::thread_rng().fill_bytes(&mut nonce); + let aad = aad_for(id); + let sealed = kek + .cipher() + .encrypt( + (&nonce).into(), + Payload { + msg: &der, + aad: &aad, + }, + ) + .map_err(|_| Error::Unusable("could not encrypt the key".into()))?; + + std::fs::create_dir_all(&directory) + .map_err(|e| Error::Configuration(format!("creating {}: {e}", directory.display())))?; + let mut on_disk = nonce.to_vec(); + on_disk.extend_from_slice(&sealed); + write_private(&directory.join("key.enc"), &on_disk)?; + write_file(&directory.join("chain.pem"), chain_pem.as_bytes())?; + write_file( + &directory.join("meta.json"), + serde_json::to_string_pretty(&meta) + .map_err(|e| Error::Configuration(e.to_string()))? + .as_bytes(), + )?; + + Ok(()) + } + + /// Point `active` at an existing version. + pub fn activate(root: impl AsRef, id: &str) -> Result<()> { + let root = root.as_ref(); + if !root.join(id).join("key.enc").exists() { + return config(format!("version {id} is not in the keystore")); + } + write_file(&root.join("active"), id.as_bytes()) + } + + /// Every version present, newest first by id. + pub fn versions(root: impl AsRef) -> Result> { + let root = root.as_ref(); + let mut ids: Vec = std::fs::read_dir(root) + .map_err(|e| Error::Configuration(format!("reading {}: {e}", root.display())))? + .filter_map(std::result::Result::ok) + .filter(|entry| entry.path().join("key.enc").exists()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect(); + ids.sort(); + ids.reverse(); + Ok(ids) + } +} + +fn aad_for(id: &str) -> Vec { + let mut aad = AAD_PREFIX.to_vec(); + aad.extend_from_slice(id.as_bytes()); + aad +} + +fn active_id(root: &Path) -> Result { + let path = root.join("active"); + let id = std::fs::read_to_string(&path) + .map_err(|e| Error::Configuration(format!("reading {}: {e}", path.display())))?; + let id = id.trim().to_string(); + if id.is_empty() { + return config(format!("{} is empty", path.display())); + } + Ok(id) +} + +fn load_version(root: &Path, id: &str) -> Result { + let directory = root.join(id); + let sealed = std::fs::read(directory.join("key.enc")).map_err(|e| { + Error::Configuration(format!("reading the sealed key for version {id}: {e}")) + })?; + let chain_pem = std::fs::read_to_string(directory.join("chain.pem")) + .map_err(|e| Error::Configuration(format!("reading the chain for version {id}: {e}")))?; + let meta: VersionMeta = serde_json::from_str( + &std::fs::read_to_string(directory.join("meta.json")).map_err(|e| { + Error::Configuration(format!("reading the metadata for version {id}: {e}")) + })?, + ) + .map_err(|e| Error::Configuration(format!("the metadata for version {id}: {e}")))?; + + let chain = x509::pem_to_der(&chain_pem) + .map_err(|e| Error::Configuration(format!("the chain for version {id}: {e}")))?; + let leaf = x509::parse_certificate( + chain + .first() + .ok_or_else(|| Error::Configuration(format!("version {id} has an empty chain")))?, + ) + .map_err(|e| Error::Configuration(format!("the leaf for version {id}: {e}")))?; + + Ok(Version { + id: id.to_string(), + meta, + chain_pem, + leaf, + sealed, + }) +} + +/// PKCS#8 DER from a PEM private key. +fn private_key_der(pem: &str) -> Result>> { + for marker in ["PRIVATE KEY"] { + if pem.contains(marker) { + let blocks = x509::pem_to_der(pem) + .map_err(|e| Error::Configuration(format!("the private key: {e}")))?; + return Ok(Zeroizing::new( + blocks.into_iter().next().unwrap_or_default(), + )); + } + } + config("the private key must be PEM-encoded PKCS#8 (-----BEGIN PRIVATE KEY-----)") +} + +fn write_file(path: &Path, bytes: &[u8]) -> Result<()> { + std::fs::write(path, bytes) + .map_err(|e| Error::Configuration(format!("writing {}: {e}", path.display()))) +} + +/// Write a file only the owner can read. +/// +/// Least privilege, as O.2 requires: the sealed key is useless without the +/// key-encryption key, but there is no reason for anything else on the host to +/// be able to read it either. +fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { + write_file(path, bytes)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| Error::Configuration(format!("securing {}: {e}", path.display())))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kek() -> KeyEncryptionKey { + use base64::Engine as _; + KeyEncryptionKey::from_base64( + &base64::engine::general_purpose::STANDARD.encode([7u8; 32]), + "test", + ) + .unwrap() + } + + struct Temp(std::path::PathBuf); + + impl Temp { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "claim-signer-keystore-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + Temp(path) + } + } + + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// `unwrap_err` would need `Debug` on the key types, and deriving that on + /// anything holding key material is how key material ends up in a log. + fn expect_err(result: Result) -> String { + match result { + Ok(_) => panic!("expected this to fail"), + Err(e) => e.to_string(), + } + } + + fn meta() -> VersionMeta { + VersionMeta { + algorithm: "ES256".into(), + imported_at: "2026-08-26T00:00:00Z".into(), + note: "test".into(), + } + } + + fn import(root: &Path, id: &str) { + Keystore::import( + root, + &kek(), + id, + imagecore::c2pa::testpki::CLAIM_SIGNER_KEY_PEM, + imagecore::c2pa::testpki::CLAIM_SIGNER_CHAIN_PEM, + meta(), + ) + .unwrap(); + } + + #[test] + fn a_key_round_trips_through_the_keystore_and_signs() { + let temp = Temp::new("roundtrip"); + import(&temp.0, "v1"); + Keystore::activate(&temp.0, "v1").unwrap(); + + let store = Keystore::open(&temp.0, kek()).unwrap(); + assert_eq!(store.active().id, "v1"); + + let message = b"a Sig_structure, more or less"; + let signature = store.sign(message).unwrap(); + assert_eq!(signature.len(), 64); + imagecore::c2pa::verify::by_cose_algorithm( + imagecore::c2pa::identity::alg::ES256, + &store.active().leaf, + message, + &signature, + ) + .expect("the signature should verify against the stored certificate"); + } + + #[test] + fn the_key_is_never_stored_in_the_clear() { + // The single most important property in this file, so it is asserted + // against the bytes on disk rather than argued for in a comment. + let temp = Temp::new("sealed"); + import(&temp.0, "v1"); + + let sealed = std::fs::read(temp.0.join("v1/key.enc")).unwrap(); + let plaintext = private_key_der(imagecore::c2pa::testpki::CLAIM_SIGNER_KEY_PEM).unwrap(); + assert!( + !sealed + .windows(plaintext.len()) + .any(|window| window == plaintext.as_slice()), + "the plaintext key must not appear in key.enc" + ); + assert!( + sealed.len() > plaintext.len(), + "nonce and tag should be present" + ); + } + + #[cfg(unix)] + #[test] + fn the_sealed_key_is_readable_only_by_its_owner() { + use std::os::unix::fs::PermissionsExt; + let temp = Temp::new("perms"); + import(&temp.0, "v1"); + let mode = std::fs::metadata(temp.0.join("v1/key.enc")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn the_wrong_key_encryption_key_cannot_open_the_store() { + use base64::Engine as _; + let temp = Temp::new("wrong-kek"); + import(&temp.0, "v1"); + Keystore::activate(&temp.0, "v1").unwrap(); + + let other = KeyEncryptionKey::from_base64( + &base64::engine::general_purpose::STANDARD.encode([9u8; 32]), + "test", + ) + .unwrap(); + let error = expect_err(Keystore::open(&temp.0, other)); + assert!(error.to_string().contains("did not decrypt"), "{error}"); + } + + #[test] + fn a_sealed_key_moved_between_versions_does_not_decrypt() { + // The version id is authenticated data, so lifting `key.enc` from a + // retired version into the active one is caught rather than silently + // signing with the wrong key. + let temp = Temp::new("moved"); + import(&temp.0, "v1"); + import(&temp.0, "v2"); + std::fs::copy(temp.0.join("v1/key.enc"), temp.0.join("v2/key.enc")).unwrap(); + Keystore::activate(&temp.0, "v2").unwrap(); + + let error = expect_err(Keystore::open(&temp.0, kek())); + assert!(error.to_string().contains("did not decrypt"), "{error}"); + } + + #[test] + fn a_tampered_ciphertext_is_refused() { + let temp = Temp::new("tampered"); + import(&temp.0, "v1"); + Keystore::activate(&temp.0, "v1").unwrap(); + + let path = temp.0.join("v1/key.enc"); + let mut sealed = std::fs::read(&path).unwrap(); + let at = sealed.len() / 2; + sealed[at] ^= 0x01; + std::fs::write(&path, &sealed).unwrap(); + + assert!(Keystore::open(&temp.0, kek()).is_err()); + } + + #[test] + fn a_key_that_does_not_match_its_certificate_is_caught_at_startup() { + let temp = Temp::new("mismatch"); + // The TSA key with the claim signer's chain: both are valid, and they + // do not go together. + Keystore::import( + &temp.0, + &kek(), + "v1", + imagecore::c2pa::testpki::TSA_SIGNER_KEY_PEM, + imagecore::c2pa::testpki::CLAIM_SIGNER_CHAIN_PEM, + meta(), + ) + .unwrap(); + Keystore::activate(&temp.0, "v1").unwrap(); + + let error = expect_err(Keystore::open(&temp.0, kek())); + assert!( + error.to_string().contains("does not match the certificate"), + "{error}" + ); + } + + #[test] + fn rotation_stages_a_version_before_switching_to_it() { + let temp = Temp::new("rotate"); + import(&temp.0, "2026-01-signer"); + Keystore::activate(&temp.0, "2026-01-signer").unwrap(); + assert_eq!( + Keystore::open(&temp.0, kek()).unwrap().active().id, + "2026-01-signer" + ); + + // Staging the next credential does not change what is signing. + import(&temp.0, "2027-01-signer"); + assert_eq!( + Keystore::open(&temp.0, kek()).unwrap().active().id, + "2026-01-signer" + ); + + Keystore::activate(&temp.0, "2027-01-signer").unwrap(); + assert_eq!( + Keystore::open(&temp.0, kek()).unwrap().active().id, + "2027-01-signer" + ); + + // And the retired version is still there, because images signed under + // it are still out in the world. + let versions = Keystore::versions(&temp.0).unwrap(); + assert_eq!(versions, vec!["2027-01-signer", "2026-01-signer"]); + } + + #[test] + fn activating_a_version_that_does_not_exist_is_refused() { + let temp = Temp::new("missing"); + assert!(Keystore::activate(&temp.0, "nope").is_err()); + } + + #[test] + fn a_ca_certificate_cannot_be_imported_as_a_signer() { + let temp = Temp::new("ca"); + let error = expect_err(Keystore::import( + &temp.0, + &kek(), + "v1", + imagecore::c2pa::testpki::CLAIM_SIGNER_KEY_PEM, + imagecore::c2pa::testpki::ISSUING_CA_PEM, + meta(), + )); + assert!(error.to_string().contains("must not be a CA"), "{error}"); + } + + #[test] + fn a_key_encryption_key_of_the_wrong_length_is_refused() { + use base64::Engine as _; + let short = base64::engine::general_purpose::STANDARD.encode([1u8; 16]); + let error = expect_err(KeyEncryptionKey::from_base64(&short, "test")); + assert!(error.to_string().contains("32 bytes"), "{error}"); + } +} diff --git a/services/claim-signer/src/main.rs b/services/claim-signer/src/main.rs new file mode 100644 index 0000000..d268639 --- /dev/null +++ b/services/claim-signer/src/main.rs @@ -0,0 +1,570 @@ +//! The Backend subsystem: the only place a C2PA claim signing key exists. +//! +//! # What this is for +//! +//! The A10city Image Editor is a **Distributed** Generator Product in the C2PA +//! Conformance Program's terms. The browser is the Edge subsystem: it opens the +//! image, applies the edits, builds the assertions and the claim, and computes +//! the `Sig_structure`. This service is the Backend subsystem: it holds the +//! claim signing key, signs that structure, and fetches an RFC 3161 time-stamp +//! over the resulting signature. +//! +//! The split exists because Assurance Level 1 cannot be reached without it. +//! Objective O.2 requires the signing key to be encrypted at rest and in +//! memory, access-controlled by least privilege, and rotatable. A key compiled +//! into a WebAssembly module and served to every visitor satisfies none of +//! those, and no amount of obfuscation changes that. +//! +//! ```text +//! browser (Edge) claim-signer (Backend) +//! ───────────────── ────────────────────────────── +//! decode, edit, encode +//! build claim + assertions +//! Sig_structure ───── TLS 1.3 ─────▶ authenticate the caller (O.2) +//! (a few hundred bytes; decrypt the key for one operation +//! no pixels) sign +//! ask the TSA to stamp the signature +//! ◀─── signature ───── re-encrypt, zeroise, log +//! + TST +//! assemble COSE_Sign1, embed +//! ``` +//! +//! # Endpoints +//! +//! | Method | Path | Purpose | +//! |---|---|---| +//! | `GET` | `/v1/identity` | the public credential: chain, algorithm, key id, time-stamp budget | +//! | `POST` | `/v1/sign` | sign a `Sig_structure`, and stamp the result | +//! | `GET` | `/healthz` | liveness, with no authentication and no secrets | +//! +//! # Configuration +//! +//! Everything comes from the environment, so that a deployment is described by +//! its orchestration rather than by a file inside the image. +//! +//! | Variable | Meaning | +//! |---|---| +//! | `CLAIM_SIGNER_BIND` | address to listen on (default `0.0.0.0:8443`) | +//! | `CLAIM_SIGNER_KEYSTORE` | directory holding the sealed signing keys | +//! | `CLAIM_SIGNER_KEK_FILE` / `CLAIM_SIGNER_KEK` | the key-encryption key, 32 Base64 bytes | +//! | `CLAIM_SIGNER_CLIENTS` | JSON file of `{ "": "" }` | +//! | `CLAIM_SIGNER_TLS_CERT`, `CLAIM_SIGNER_TLS_KEY` | server certificate and key | +//! | `CLAIM_SIGNER_CLIENT_CA` | optional: require mutual TLS against this CA bundle | +//! | `CLAIM_SIGNER_TSA_URL` | RFC 3161 endpoint; unset disables time-stamping | +//! | `CLAIM_SIGNER_TIMESTAMP_BUDGET` | bytes the Edge should reserve (default 12288) | +//! | `CLAIM_SIGNER_ALLOW_PLAINTEXT` | development only: serve HTTP instead of TLS | +//! +//! # Subcommands +//! +//! ```text +//! claim-signer serve run the service +//! claim-signer import --id … --key … --chain … stage a new credential +//! claim-signer activate --id … rotate onto a staged credential +//! claim-signer versions list what the keystore holds +//! ``` + +mod auth; +mod keystore; +mod tsa; + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use base64::Engine as _; +use imagecore::c2pa::cose::TIMESTAMP_BUDGET; +use serde::{Deserialize, Serialize}; +use tower_http::limit::RequestBodyLimitLayer; + +use auth::Clients; +use keystore::{KeyEncryptionKey, Keystore, VersionMeta}; +use tsa::Tsa; + +/// The largest `Sig_structure` worth accepting. +/// +/// A claim with a long ingredient chain and a three-deep certificate chain runs +/// to a few kilobytes. Anything approaching a megabyte is not a claim, and a +/// signing endpoint is exactly the kind of thing worth capping hard. +const MAX_BODY: usize = 256 * 1024; + +struct Service { + keystore: Keystore, + clients: Clients, + tsa: Option, + timestamp_budget: usize, +} + +type Shared = Arc; + +#[tokio::main] +async fn main() -> std::process::ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "claim_signer=info,tower_http=warn".into()), + ) + .json() + .init(); + + match run().await { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(message) => { + // Deliberately not `tracing::error!`: a configuration failure needs + // to be legible in a container log before the JSON formatter is + // something anyone is reading. + eprintln!("claim-signer: {message}"); + std::process::ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), String> { + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + None | Some("serve") => serve().await, + Some("import") => import(args.collect()), + Some("activate") => activate(args.collect()), + Some("versions") => versions(), + Some("-h") | Some("--help") => { + println!("{}", usage()); + Ok(()) + } + Some(other) => Err(format!("unknown command '{other}'\n\n{}", usage())), + } +} + +fn usage() -> &'static str { + "\ +claim-signer — the Backend subsystem of the A10city Image Editor Generator Product + +USAGE: + claim-signer serve + claim-signer import --id --key --chain [--algorithm ES256] [--note TEXT] + claim-signer activate --id + claim-signer versions + +Configuration comes from the environment; see the module documentation." +} + +/* ------------------------------------------------------------------------- */ +/* Serving */ +/* ------------------------------------------------------------------------- */ + +async fn serve() -> Result<(), String> { + let keystore_dir = env("CLAIM_SIGNER_KEYSTORE")?; + let kek = KeyEncryptionKey::from_environment().map_err(|e| e.to_string())?; + let keystore = Keystore::open(&keystore_dir, kek).map_err(|e| e.to_string())?; + + let clients_path = env("CLAIM_SIGNER_CLIENTS")?; + let clients = Clients::from_json( + &std::fs::read_to_string(&clients_path) + .map_err(|e| format!("reading {clients_path}: {e}"))?, + )?; + + let timestamp_budget = std::env::var("CLAIM_SIGNER_TIMESTAMP_BUDGET") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(TIMESTAMP_BUDGET); + let tsa = std::env::var("CLAIM_SIGNER_TSA_URL") + .ok() + .filter(|url| !url.trim().is_empty()) + .map(|url| Tsa::new(url, std::time::Duration::from_secs(10))); + + let leaf = &keystore.active().leaf; + tracing::info!( + key_id = %keystore.active().id, + algorithm = %keystore.active().meta.algorithm, + subject = %leaf.subject, + not_after = %leaf.not_after, + assurance_level = ?leaf.c2pa_assurance_level, + cpl_record = ?leaf.c2pa_cpl_record_id, + claim_signing_eku = leaf.has_claim_signing_eku(), + kek_source = keystore.kek_source(), + clients = clients.len(), + tsa = tsa.as_ref().map(Tsa::url).unwrap_or("none"), + "claim-signer starting" + ); + + // Say so loudly rather than discovering it in a validator. A certificate + // issued under the C2PA Certificate Policy carries both of these; one that + // does not is a test credential, and a deployment running on a test + // credential should know it is. + if leaf.c2pa_assurance_level.is_none() { + tracing::warn!( + "the active certificate carries no c2pa-al extension: it was not issued under the \ + C2PA Certificate Policy, and manifests signed with it will not be recognised as \ + coming from a conforming Generator Product" + ); + } + if !leaf.has_claim_signing_eku() { + tracing::warn!( + "the active certificate does not assert c2pa-kp-claimSigning (1.3.6.1.4.1.62558.2.1)" + ); + } + + let service = Arc::new(Service { + keystore, + clients, + tsa, + timestamp_budget, + }); + + let app = Router::new() + .route("/healthz", get(healthz)) + .route("/v1/identity", get(identity)) + .route("/v1/sign", post(sign)) + .layer(RequestBodyLimitLayer::new(MAX_BODY)) + .layer(tower_http::timeout::TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + std::time::Duration::from_secs(30), + )) + .layer(tower_http::trace::TraceLayer::new_for_http()) + .with_state(service); + + let bind: SocketAddr = std::env::var("CLAIM_SIGNER_BIND") + .unwrap_or_else(|_| "0.0.0.0:8443".into()) + .parse() + .map_err(|e| format!("CLAIM_SIGNER_BIND: {e}"))?; + + if std::env::var("CLAIM_SIGNER_ALLOW_PLAINTEXT").is_ok() { + // Objective O.5 requires TLS 1.3 between subsystems. This path exists + // so a developer can run the service behind a local proxy, and it + // announces itself every time. + tracing::warn!( + %bind, + "serving plaintext HTTP: CLAIM_SIGNER_ALLOW_PLAINTEXT is set. This is not a \ + conformant configuration - objective O.5 requires TLS 1.3 between the Edge and \ + Backend subsystems." + ); + let listener = tokio::net::TcpListener::bind(bind) + .await + .map_err(|e| format!("binding {bind}: {e}"))?; + axum::serve(listener, app) + .await + .map_err(|e| format!("serving: {e}")) + } else { + let config = tls_config()?; + tracing::info!(%bind, "listening with TLS 1.3"); + axum_server::bind_rustls( + bind, + axum_server::tls_rustls::RustlsConfig::from_config(config), + ) + .serve(app.into_make_service()) + .await + .map_err(|e| format!("serving: {e}")) + } +} + +/// TLS 1.3 only, with optional mutual authentication. +/// +/// Objective O.5, Assurance Level 1: "Network communication channels between +/// the subsystems SHALL be protected using TLS v1.3 (or higher) or an +/// equivalent protocol." Configured as the only permitted version rather than +/// as a minimum, so a downgrade is impossible rather than merely discouraged. +fn tls_config() -> Result, String> { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let certificate_path = env("CLAIM_SIGNER_TLS_CERT")?; + let key_path = env("CLAIM_SIGNER_TLS_KEY")?; + + let certificates: Vec> = + rustls_pemfile::certs(&mut std::io::BufReader::new( + std::fs::File::open(&certificate_path) + .map_err(|e| format!("opening {certificate_path}: {e}"))?, + )) + .collect::>() + .map_err(|e| format!("reading {certificate_path}: {e}"))?; + + let key = rustls_pemfile::private_key(&mut std::io::BufReader::new( + std::fs::File::open(&key_path).map_err(|e| format!("opening {key_path}: {e}"))?, + )) + .map_err(|e| format!("reading {key_path}: {e}"))? + .ok_or_else(|| format!("{key_path} holds no private key"))?; + + let builder = rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]); + + let mut config = match std::env::var("CLAIM_SIGNER_CLIENT_CA").ok() { + Some(path) if !path.trim().is_empty() => { + let mut roots = rustls::RootCertStore::empty(); + for certificate in rustls_pemfile::certs(&mut std::io::BufReader::new( + std::fs::File::open(&path).map_err(|e| format!("opening {path}: {e}"))?, + )) { + roots + .add(certificate.map_err(|e| format!("reading {path}: {e}"))?) + .map_err(|e| format!("adding a client CA from {path}: {e}"))?; + } + let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots)) + .build() + .map_err(|e| format!("building the client verifier: {e}"))?; + tracing::info!(%path, "requiring mutual TLS"); + builder.with_client_cert_verifier(verifier) + } + _ => builder.with_no_client_auth(), + } + .with_single_cert(certificates, key) + .map_err(|e| format!("the TLS certificate and key do not go together: {e}"))?; + + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(Arc::new(config)) +} + +fn env(name: &str) -> Result { + std::env::var(name).map_err(|_| format!("{name} is not set")) +} + +/* ------------------------------------------------------------------------- */ +/* Handlers */ +/* ------------------------------------------------------------------------- */ + +async fn healthz(State(service): State) -> Json { + // No authentication and no secrets: a health check that needed a credential + // would be one more secret in the orchestration for no benefit. + Json(serde_json::json!({ + "status": "ok", + "keyId": service.keystore.active().id, + "notAfter": service.keystore.active().leaf.not_after, + "timeStamping": service.tsa.is_some(), + })) +} + +/// The public half of the signing credential. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Identity { + /// PEM chain, leaf first, trust anchor omitted. + chain_pem: String, + algorithm: String, + key_id: String, + /// Bytes the Edge should reserve for a time-stamp token. Zero when no + /// authority is configured, which is what tells the Edge not to reserve + /// space it will never use. + timestamp_budget: usize, + assurance_level: Option, + cpl_record_id: Option, + not_after: String, +} + +async fn identity(State(service): State) -> Json { + let active = service.keystore.active(); + Json(Identity { + chain_pem: active.chain_pem.clone(), + algorithm: active.meta.algorithm.clone(), + key_id: active.id.clone(), + timestamp_budget: if service.tsa.is_some() { + service.timestamp_budget + } else { + 0 + }, + assurance_level: active.leaf.c2pa_assurance_level, + cpl_record_id: active.leaf.c2pa_cpl_record_id.clone(), + not_after: active.leaf.not_after.clone(), + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SignRequestBody { + /// Base64 of the `Sig_structure` to sign. + to_be_signed: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SignResponseBody { + signature: String, + /// Base64 DER `TimeStampToken`, absent when none could be obtained. + #[serde(skip_serializing_if = "Option::is_none")] + timestamp_token: Option, + key_id: String, + /// Why there is no time-stamp, when there is none. Reported rather than + /// left to inference: the Edge shows the user that the credential will stop + /// validating when the certificate expires. + #[serde(skip_serializing_if = "Option::is_none")] + timestamp_error: Option, +} + +/// Sign a `Sig_structure` and, where an authority is configured, stamp it. +async fn sign(State(service): State, request: Request) -> Response { + let (parts, body) = request.into_parts(); + let header = parts + .headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + + let body = match axum::body::to_bytes(body, MAX_BODY).await { + Ok(bytes) => bytes, + Err(_) => { + return problem( + StatusCode::PAYLOAD_TOO_LARGE, + "the request body is too large", + ) + } + }; + + // Authenticate before parsing, so a malformed body from an unauthenticated + // caller costs nothing but a MAC computation. + let client = match service.clients.authenticate( + header.as_deref(), + parts.method.as_str(), + parts.uri.path(), + &body, + auth::now(), + ) { + Ok(client) => client, + Err(denied) => { + tracing::warn!(reason = %denied.detail(), "refused a signing request"); + return problem(StatusCode::UNAUTHORIZED, denied.public_message()); + } + }; + + let parsed: SignRequestBody = match serde_json::from_slice(&body) { + Ok(parsed) => parsed, + Err(e) => return problem(StatusCode::BAD_REQUEST, &format!("malformed request: {e}")), + }; + let to_be_signed = match base64::engine::general_purpose::STANDARD.decode(&parsed.to_be_signed) + { + Ok(bytes) if !bytes.is_empty() => bytes, + _ => { + return problem( + StatusCode::BAD_REQUEST, + "toBeSigned must be non-empty Base64", + ) + } + }; + + let service = service.clone(); + let outcome = tokio::task::spawn_blocking(move || { + let signature = service.keystore.sign(&to_be_signed)?; + let stamped = match &service.tsa { + Some(tsa) => match tsa.stamp(&signature) { + Ok(token) => (Some(token), None), + // A TSA outage must not stop people saving their photographs. + // The credential is written without a stamp, the response says + // so, and the interface passes that on. + Err(why) => (None, Some(why)), + }, + None => (None, None), + }; + Ok::<_, keystore::Error>((signature, stamped.0, stamped.1, service)) + }) + .await; + + let (signature, token, timestamp_error, service) = match outcome { + Ok(Ok(result)) => result, + Ok(Err(e)) => { + tracing::error!(error = %e, "signing failed"); + return problem(StatusCode::INTERNAL_SERVER_ERROR, "signing failed"); + } + Err(e) => { + tracing::error!(error = %e, "the signing task did not complete"); + return problem(StatusCode::INTERNAL_SERVER_ERROR, "signing failed"); + } + }; + + if let Some(why) = ×tamp_error { + tracing::warn!(error = %why, "no time-stamp was obtained"); + } + + // The audit record. Not the claim, and not the signature: a digest of what + // was signed, which is enough to correlate a manifest with a request and + // nothing more. + tracing::info!( + client = %client, + key_id = %service.keystore.active().id, + digest = %hex(&sha256(&signature)), + time_stamped = token.is_some(), + "signed a claim" + ); + + let engine = base64::engine::general_purpose::STANDARD; + Json(SignResponseBody { + signature: engine.encode(&signature), + timestamp_token: token.map(|token| engine.encode(token)), + key_id: service.keystore.active().id.clone(), + timestamp_error, + }) + .into_response() +} + +fn problem(status: StatusCode, detail: &str) -> Response { + (status, Json(serde_json::json!({ "error": detail }))).into_response() +} + +fn sha256(bytes: &[u8]) -> Vec { + use sha2::Digest; + sha2::Sha256::digest(bytes).to_vec() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/* ------------------------------------------------------------------------- */ +/* Key rotation */ +/* ------------------------------------------------------------------------- */ + +fn flag(args: &[String], name: &str) -> Option { + args.iter() + .position(|a| a == name) + .and_then(|at| args.get(at + 1)) + .cloned() +} + +fn import(args: Vec) -> Result<(), String> { + let root = env("CLAIM_SIGNER_KEYSTORE")?; + let kek = KeyEncryptionKey::from_environment().map_err(|e| e.to_string())?; + + let id = flag(&args, "--id").ok_or("import needs --id")?; + let key_path = flag(&args, "--key").ok_or("import needs --key")?; + let chain_path = flag(&args, "--chain").ok_or("import needs --chain")?; + let algorithm = flag(&args, "--algorithm").unwrap_or_else(|| "ES256".into()); + let note = flag(&args, "--note").unwrap_or_default(); + + let meta = VersionMeta { + algorithm, + imported_at: imagecore::c2pa::clock::to_rfc3339(auth::now()), + note, + }; + + Keystore::import( + &root, + &kek, + &id, + &std::fs::read_to_string(&key_path).map_err(|e| format!("reading {key_path}: {e}"))?, + &std::fs::read_to_string(&chain_path).map_err(|e| format!("reading {chain_path}: {e}"))?, + meta, + ) + .map_err(|e| e.to_string())?; + + println!( + "imported {id}. It is not signing yet — run `claim-signer activate --id {id}` when you \ + are ready to rotate onto it." + ); + Ok(()) +} + +fn activate(args: Vec) -> Result<(), String> { + let root = env("CLAIM_SIGNER_KEYSTORE")?; + let id = flag(&args, "--id").ok_or("activate needs --id")?; + Keystore::activate(&root, &id).map_err(|e| e.to_string())?; + println!("{id} is now the active signing credential. Restart the service to pick it up."); + Ok(()) +} + +fn versions() -> Result<(), String> { + let root = env("CLAIM_SIGNER_KEYSTORE")?; + let active = std::fs::read_to_string(std::path::Path::new(&root).join("active")) + .unwrap_or_default() + .trim() + .to_string(); + for id in Keystore::versions(&root).map_err(|e| e.to_string())? { + let marker = if id == active { "* " } else { " " }; + println!("{marker}{id}"); + } + Ok(()) +} diff --git a/services/claim-signer/src/tsa.rs b/services/claim-signer/src/tsa.rs new file mode 100644 index 0000000..9a0e867 --- /dev/null +++ b/services/claim-signer/src/tsa.rs @@ -0,0 +1,190 @@ +//! The RFC 3161 client: asking a time-stamping authority to stamp a signature. +//! +//! # Why the Backend does this and the Edge cannot +//! +//! Two reasons, and the second is the interesting one. +//! +//! The obvious one is reach: a time-stamping authority is an HTTP endpoint with +//! its own TLS, and the browser tab would need it to serve permissive CORS +//! headers, which authorities do not. +//! +//! The real one is that a C2PA v2 time-stamp covers the *signature*, not the +//! claim (section 10.3.2.5.2). The signature does not exist until the Backend +//! has made it. So the stamp has to be fetched between signing and answering, +//! in the same request, which is exactly where this sits. +//! +//! # What is sent +//! +//! A `TimeStampReq` carrying only a SHA-256 digest of the signature. Nothing +//! about the image, the claim, or the person reaches the authority — the same +//! property that holds between the browser and this service holds between this +//! service and the TSA. +//! +//! ```text +//! TimeStampReq ::= SEQUENCE { +//! version INTEGER { v1(1) }, +//! messageImprint MessageImprint, +//! nonce INTEGER OPTIONAL, +//! certReq BOOLEAN DEFAULT FALSE -- asserted: the token has to +//! } carry the TSA certificate +//! or no validator can check it +//! ``` + +use imagecore::c2pa::{der, timestamp}; +use rand::Rng; +use sha2::{Digest, Sha256}; + +const OID_SHA256: &str = "2.16.840.1.101.3.4.2.1"; +const CONTENT_TYPE: &str = "application/timestamp-query"; +const RESPONSE_TYPE: &str = "application/timestamp-reply"; + +/// A configured time-stamping authority. +pub struct Tsa { + url: String, + timeout: std::time::Duration, +} + +impl Tsa { + pub fn new(url: impl Into, timeout: std::time::Duration) -> Self { + Tsa { + url: url.into(), + timeout, + } + } + + pub fn url(&self) -> &str { + &self.url + } + + /// Ask for a token over `signature`, returning the DER `TimeStampToken`. + /// + /// Blocking, and called from a blocking task: the request is a single small + /// round trip and an async HTTP stack for it would be more moving parts + /// than the job needs. + pub fn stamp(&self, signature: &[u8]) -> Result, String> { + let nonce: u64 = rand::thread_rng().gen(); + let request = build_request(signature, nonce); + + let agent = ureq::AgentBuilder::new() + .timeout(self.timeout) + .user_agent(concat!("a10city-claim-signer/", env!("CARGO_PKG_VERSION"))) + .build(); + + let response = agent + .post(&self.url) + .set("Content-Type", CONTENT_TYPE) + .send_bytes(&request) + .map_err(|e| format!("the time-stamping authority could not be reached: {e}"))?; + + // Some authorities answer with the generic octet-stream type; refusing + // those would be pedantry. A HTML error page, though, is worth naming + // rather than letting the DER parser produce something cryptic. + let content_type = response.content_type().to_string(); + if content_type.contains("html") || content_type.contains("json") { + return Err(format!( + "the time-stamping authority answered with {content_type}, not {RESPONSE_TYPE}" + )); + } + + let mut body = Vec::new(); + response + .into_reader() + .take(1024 * 1024) + .read_to_end(&mut body) + .map_err(|e| format!("reading the time-stamp response: {e}"))?; + + let token = timestamp::token_from_response(&body)?; + + // Check the token before it is handed on. A stamp over the wrong bytes + // would be embedded, shipped, and only noticed by someone else's + // validator; catching it here turns that into a log line. + let parsed = timestamp::parse(&token) + .map_err(|e| format!("the time-stamp token did not parse: {e}"))?; + let expected = Sha256::digest(signature); + if parsed.imprint != expected[..] { + return Err("the time-stamp covers something other than the signature sent".into()); + } + if parsed.certificates.is_empty() { + return Err( + "the time-stamp carries no certificates, so no validator could check it".into(), + ); + } + + Ok(token) + } +} + +use std::io::Read as _; + +/// Build the DER `TimeStampReq`. +fn build_request(signature: &[u8], nonce: u64) -> Vec { + let digest = Sha256::digest(signature); + der::sequence(&[ + der::integer(1), + der::sequence(&[ + der::algorithm_with_null(OID_SHA256), + der::octet_string(&digest), + ]), + der::integer(nonce), + // certReq: without it most authorities omit their certificate, and a + // token whose signer cannot be found fails section 15.8.2. + der::boolean(true), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use imagecore::c2pa::x509; + + #[test] + fn the_request_carries_only_a_digest() { + // The privacy property, asserted rather than described: the authority + // learns 32 bytes and a nonce. + let signature = vec![0xAB; 64]; + let request = build_request(&signature, 42); + assert!( + !request + .windows(signature.len()) + .any(|w| w == signature.as_slice()), + "the signature itself must not be sent" + ); + assert!( + request.len() < 128, + "the request is {} bytes", + request.len() + ); + } + + #[test] + fn the_request_is_well_formed_der() { + let request = build_request(&[1, 2, 3], 7); + let sequence = x509::read_tlv(&request).unwrap(); + assert_eq!(sequence.tag, 0x30); + assert_eq!(sequence.total, request.len()); + + let fields = x509::children(sequence.value).unwrap(); + assert_eq!(fields.len(), 4, "version, imprint, nonce, certReq"); + assert_eq!(fields[0].tag, 0x02); + assert_eq!(fields[0].value, [1]); + assert_eq!(fields[3].tag, 0x01); + assert_eq!(fields[3].value, [0xFF], "certReq must be asserted"); + } + + #[test] + fn the_imprint_is_the_sha256_of_the_signature() { + let signature = b"a signature"; + let request = build_request(signature, 1); + let fields = x509::children(x509::read_tlv(&request).unwrap().value).unwrap(); + let imprint = x509::children(fields[1].value).unwrap(); + assert_eq!(imprint[1].value, &Sha256::digest(signature)[..]); + } + + #[test] + fn each_request_carries_a_fresh_nonce() { + // A fixed nonce would let a replayed response pass for a new stamp. + let one = build_request(b"x", rand::random()); + let two = build_request(b"x", rand::random()); + assert_ne!(one, two); + } +} diff --git a/signing/README.md b/signing/README.md deleted file mode 100644 index bf086c4..0000000 --- a/signing/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# Signing credentials - -These files sign the Content Credentials this editor writes. **The private key -in this directory is public.** That is deliberate, and this file explains why, -because "the key is in the repo" usually means someone made a mistake. - -``` -demo-root-ca.pem self-signed P-256 root, CA:TRUE (never leaves the repo) -demo-signer.pem P-256 leaf signed by that root (goes into x5chain) -demo-signer.key PKCS#8 private key for the leaf (compiled into the wasm) -generate.sh regenerates all three -``` - -## Why the key is in the open - -The whole point of this app is that images never leave the tab. Signing happens -in the browser, so the signing key has to *be* in the browser. Anything shipped -to a browser is readable by whoever receives it — bundling, minifying or -fetching it at runtime changes how long it takes to find, not whether it can be -found. - -So there is no arrangement in which a client-side claim generator holds a secret -signing key. The choice is not "hidden key vs. exposed key", it is "exposed key, -honestly labelled" vs. "exposed key, dishonestly labelled". This app picks the -first and says so in the UI: every credential it writes is marked as coming from -an untrusted demo signer, and the certificate's own subject reads -`OU = Untrusted demonstration signer`. - -C2PA is built for exactly this. Trust in a manifest comes from the signing -certificate chaining to a trust anchor a validator recognises — in practice the -[C2PA Trust List](https://opensource.contentauthenticity.org/docs/verify-known-list). -This root is not on it and will never be. A validator will therefore report -these images as *"signed, contents intact, signer not known"*. That is the -correct answer, and it is a genuinely useful one: the hard binding still proves -the pixels have not been touched since signing, and the actions still describe -what the editor did. Only the identity claim is unverifiable. - -## What GitHub secrets are and are not good for - -Short answer: **they cannot make a browser-side signing key secret, and this -repository does not pretend otherwise.** A GitHub Actions secret is decrypted -inside the Actions runner. Whatever the runner bakes into `dist/` is served to -every visitor. Moving the key from the repository into a secret moves *where the -build reads it from*; it does not stop the built artefact from containing it. - -They are still wired up, for one narrow thing that is real: keeping a key out of -public git history. If you fork this and want the deployed site signed by a -different key — one you can rotate, one that isn't in every clone and every -mirror of the repo — set two repository secrets: - -| Secret | Contents | -|---|---| -| `C2PA_SIGNING_CERT` | PEM certificate chain, leaf first | -| `C2PA_SIGNING_KEY` | PKCS#8 PEM private key for the leaf | - -`crates/imagecore/build.rs` reads those two environment variables and compiles -whatever it finds into the engine. If either is missing it falls back to the -demo files here, so local builds and forks without secrets keep working. The -deploy workflow passes them through; CI does not, so pull requests always build -against the committed demo key. - -Rotating the secret still only rotates a *published* key. Treat it as "this -build's identity", never as a credential. - -### If you want credentials people can actually trust - -Sign somewhere the key can stay private. Two designs fit this app without -giving up its no-upload property: - -- **Remote signing.** The browser builds the claim and sends only the hash of - it to a signing endpoint, which returns a signature. The image itself never - leaves the tab — only 32 bytes of hash do. This is what the C2PA - [remote signing](https://opensource.contentauthenticity.org/docs/signing/) - flow is for, and the code here is already shaped for it: `sign_claim` in - `crates/imagecore/src/c2pa/cose.rs` takes the bytes to be signed and returns - a signature, so swapping the local key for a network round-trip is a change - at one call site. -- **Per-user certificates.** Let a signed-in user supply their own certificate - and key, held only for the session. Then the identity in the credential is - theirs, and the app never holds a key at all. - -Both are out of scope for a proof of concept. Neither changes any of the -manifest-building code — only where the 64 signature bytes come from. - -## Certificate profile - -`generate.sh` follows the certificate profile in C2PA 2.2 §14.5.1: - -- ECDSA on `prime256v1`, signed with SHA-256, giving `ES256` COSE signatures -- v3 certificates, no `issuerUniqueID` / `subjectUniqueID` -- Key Usage present and critical; the leaf asserts `digitalSignature` only, and - does not assert `keyCertSign` -- Extended Key Usage present, non-empty and critical on the leaf: - `emailProtection` (1.3.6.1.5.5.7.3.4), one of the EKUs §14.4.1 names for - C2PA signing. `anyExtendedKeyUsage` is absent, as required -- Basic Constraints `cA` asserted on the root, explicitly not on the leaf -- Subject Key Identifier on both; Authority Key Identifier on the leaf - -The chain is two certificates deep, so `x5chain` carries the leaf alone — the -spec says to include the signer and any intermediates but not the trust anchor. -The root sits here only so the app can name the issuer in its UI. - -The certificates are dated 20 years out. That looks careless and isn't: a -manifest with no RFC 3161 time-stamp stops validating the moment its signing -certificate expires, and this demo has no Time Stamp Authority. A one-year -certificate would quietly invalidate every image the app had ever signed. A -production deployment should get a time-stamp at signing time and use a -normal-lived certificate instead. - -## Regenerating - -```sh -./signing/generate.sh -``` - -Requires OpenSSL 3. It prints the leaf's extensions and verifies the chain -before finishing. Nothing caches the old key, so rebuild the engine afterwards -(`npm run build:wasm`) — and note that images signed with the previous key stay -verifiable only for as long as someone keeps that certificate around. diff --git a/signing/demo-root-ca.pem b/signing/demo-root-ca.pem deleted file mode 100644 index 97d0ca6..0000000 --- a/signing/demo-root-ca.pem +++ /dev/null @@ -1,13 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIB5jCCAY2gAwIBAgIURxJTZU34q5xtkTS2fbJ3heMqdyEwCgYIKoZIzj0EAwIw -UDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEqMCgGA1UEAwwh -QTEwY2l0eSBJbWFnZSBFZGl0b3IgRGVtbyBSb290IENBMB4XDTI2MDgyNTE1Mzky -OFoXDTQ2MDgyMDE1MzkyOFowUDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNp -dHkgTGFiczEqMCgGA1UEAwwhQTEwY2l0eSBJbWFnZSBFZGl0b3IgRGVtbyBSb290 -IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvk3pF/YthUHTwx9DqYZU8E9Z -PPEzqBWu+jIOPfsrFold7/qmbztUpt+avkzLFVIW4q1tbV2nLacundvWk72UgKNF -MEMwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE -FDYBR3yBvZtmwfmkoqpiIO0vQ2aoMAoGCCqGSM49BAMCA0cAMEQCIHrPf1aO0jVf -xKySyscca5Um7I8Z3pUYDJNGxAO/B3HMAiBz+aCcGo1gRECkBz172x8aB1xJHdnT -PFcBOxBZbXbtaA== ------END CERTIFICATE----- diff --git a/signing/demo-signer.key b/signing/demo-signer.key deleted file mode 100644 index bbd25d6..0000000 --- a/signing/demo-signer.key +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgM5cTPokqk6cHsemf -hSspi5FkdqN0Jlt/lU2bsC3V/UihRANCAAQnSx30QrkrvAc1ywkzZVnj7J36Mqop -M4azoENcvheGb6rO/y7tH7BVAc35g17oDjC6I6l3MWVSjOoJl6tl8TYx ------END PRIVATE KEY----- diff --git a/signing/demo-signer.pem b/signing/demo-signer.pem deleted file mode 100644 index ff184ce..0000000 --- a/signing/demo-signer.pem +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICQjCCAeigAwIBAgIUftCN6c/yZif4pfMc8I+S3U+D4uEwCgYIKoZIzj0EAwIw -UDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNpdHkgTGFiczEqMCgGA1UEAwwh -QTEwY2l0eSBJbWFnZSBFZGl0b3IgRGVtbyBSb290IENBMB4XDTI2MDgyNTE1Mzky -OFoXDTQ2MDgyMDE1MzkyOFoweDELMAkGA1UEBhMCSU4xFTATBgNVBAoMDEExMGNp -dHkgTGFiczEnMCUGA1UECwweVW50cnVzdGVkIGRlbW9uc3RyYXRpb24gc2lnbmVy -MSkwJwYDVQQDDCBBMTBjaXR5IEltYWdlIEVkaXRvciBEZW1vIFNpZ25lcjBZMBMG -ByqGSM49AgEGCCqGSM49AwEHA0IABCdLHfRCuSu8BzXLCTNlWePsnfoyqikzhrOg -Q1y+F4Zvqs7/Lu0fsFUBzfmDXugOMLojqXcxZVKM6gmXq2XxNjGjeDB2MAwGA1Ud -EwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwME -MB0GA1UdDgQWBBSLUDMSGFLtM8a3mXn7ymjEePtYRzAfBgNVHSMEGDAWgBQ2AUd8 -gb2bZsH5pKKqYiDtL0NmqDAKBggqhkjOPQQDAgNIADBFAiEAukJ61K//wXoS4DEM -2p5S+gxdxMt3zB+J1Ory+ymjkKICIATVgVEAj4X20NGv6o1V2S17pOzB9gxyiGNG -Nd+RdDLg ------END CERTIFICATE----- diff --git a/signing/generate.sh b/signing/generate.sh deleted file mode 100755 index 129d651..0000000 --- a/signing/generate.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -# -# Regenerate the demo signing chain used by the browser claim generator. -# -# Everything this produces is PUBLIC by design: the private key ships inside a -# static web app, so it is readable by anyone who opens the bundle. See -# README.md in this directory for why that is the honest design rather than a -# shortcut, and what the GitHub Actions secrets path does and does not buy. -# -# Usage: ./generate.sh [output-dir] -# -# The certificate profile follows C2PA 2.2 section 14.5.1 ("Certificate -# Profile"): -# * ECDSA on prime256v1, signed with ES256 -# * v3 certificates -# * Key Usage present and critical; leaf asserts digitalSignature only -# * Extended Key Usage present and non-empty on the leaf; emailProtection -# (1.3.6.1.5.5.7.3.4) is one of the EKUs the spec names for C2PA signing -# * anyExtendedKeyUsage (2.5.29.37.0) absent -# * Basic Constraints cA asserted on the root, not asserted on the leaf -# * Authority Key Identifier on the leaf (it is not self-signed) -# * Subject Key Identifier on both -set -euo pipefail - -out="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" -mkdir -p "$out" -cfg="$(mktemp -d)" -trap 'rm -rf "$cfg"' EXIT - -# Long validity on purpose. A C2PA manifest without an RFC 3161 time-stamp -# stops validating the moment its signing certificate expires, and this demo -# has no Time Stamp Authority, so a short-lived certificate would silently -# break every image the app has ever signed. -days=7300 - -cat > "$cfg/root.cnf" <<'EOF' -[req] -distinguished_name = dn -prompt = no -[dn] -C = IN -O = A10city Labs -CN = A10city Image Editor Demo Root CA -[v3] -basicConstraints = critical, CA:TRUE, pathlen:0 -keyUsage = critical, keyCertSign, cRLSign -subjectKeyIdentifier = hash -EOF - -cat > "$cfg/leaf.cnf" <<'EOF' -[req] -distinguished_name = dn -prompt = no -[dn] -C = IN -O = A10city Labs -OU = Untrusted demonstration signer -CN = A10city Image Editor Demo Signer -[v3] -basicConstraints = critical, CA:FALSE -keyUsage = critical, digitalSignature -extendedKeyUsage = critical, emailProtection -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid:always -EOF - -echo "==> root key + self-signed root certificate" -openssl ecparam -name prime256v1 -genkey -noout -out "$cfg/root.key" -openssl req -new -x509 -key "$cfg/root.key" -sha256 -days "$days" \ - -config "$cfg/root.cnf" -extensions v3 -out "$out/demo-root-ca.pem" - -echo "==> leaf key + certificate signed by the root" -openssl ecparam -name prime256v1 -genkey -noout -out "$cfg/leaf.ec.key" -# The engine parses PKCS#8, which is the modern default and what every other -# toolchain hands you. `ecparam` still emits SEC1, so convert. -openssl pkcs8 -topk8 -nocrypt -in "$cfg/leaf.ec.key" -out "$out/demo-signer.key" -openssl req -new -key "$cfg/leaf.ec.key" -config "$cfg/leaf.cnf" -out "$cfg/leaf.csr" -openssl x509 -req -in "$cfg/leaf.csr" -CA "$out/demo-root-ca.pem" -CAkey "$cfg/root.key" \ - -CAcreateserial -sha256 -days "$days" \ - -extfile "$cfg/leaf.cnf" -extensions v3 -out "$out/demo-signer.pem" - -# The x5chain COSE header carries the signer plus every intermediate, but not -# the trust anchor (C2PA 2.2 section 13.2.2). With a two-certificate chain that -# is the leaf alone; the root is kept beside it only so the app can show who -# issued the signer. -cat "$out/demo-signer.pem" > "$cfg/chain.pem" - -echo "==> verify" -openssl verify -CAfile "$out/demo-root-ca.pem" "$out/demo-signer.pem" -openssl x509 -in "$out/demo-signer.pem" -noout -text | sed -n '/X509v3/,/Signature Algorithm/p' - -rm -f "$out/demo-root-ca.srl" -echo "==> wrote demo-root-ca.pem, demo-signer.pem, demo-signer.key into $out"