From b05e7f3f2db2a026af0a092403ad68d709133f64 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Thu, 3 Sep 2026 20:45:07 +0000 Subject: [PATCH 01/11] mirror_worker: add configurable witness service Consolidate witness and mirror deployments behind role-specific modes and identities while sharing per-origin checkpoint state. --- .github/workflows/integration.yml | 22 +- AGENTS.md | 5 +- Cargo.lock | 43 +- Cargo.toml | 12 +- README.md | 1 + crates/integration_tests/tests/tlog_mirror.rs | 156 +++- .../integration_tests/tests/tlog_witness.rs | 64 +- crates/mirror_worker/.dev.vars | 1 + .../.gitignore | 0 crates/mirror_worker/Cargo.toml | 8 +- crates/mirror_worker/README.md | 43 + crates/mirror_worker/build.rs | 2 +- crates/mirror_worker/config.dev.json | 49 +- crates/mirror_worker/config.mirror.json | 23 + crates/mirror_worker/config.schema.json | 116 ++- crates/mirror_worker/config.witness.json | 20 + crates/mirror_worker/config/Cargo.toml | 1 + crates/mirror_worker/config/src/lib.rs | 831 +++++++----------- crates/mirror_worker/src/add_entries.rs | 94 +- crates/mirror_worker/src/cleaner_do.rs | 10 +- crates/mirror_worker/src/frontend_worker.rs | 646 ++++++-------- crates/mirror_worker/src/lib.rs | 517 +++++++---- crates/mirror_worker/src/mirror_state_do.rs | 283 +++--- crates/mirror_worker/wrangler.jsonc | 63 +- crates/tlog_checkpoint/src/lib.rs | 2 +- crates/witness_worker/.dev.vars | 1 - crates/witness_worker/Cargo.toml | 58 -- crates/witness_worker/LICENSE | 1 - crates/witness_worker/README.md | 47 - crates/witness_worker/build.rs | 20 - crates/witness_worker/config.dev.json | 15 - crates/witness_worker/config.schema.json | 51 -- crates/witness_worker/config/Cargo.toml | 17 - crates/witness_worker/config/LICENSE | 1 - crates/witness_worker/config/src/lib.rs | 300 ------- crates/witness_worker/reset-dev.sh | 2 - crates/witness_worker/src/frontend_worker.rs | 676 -------------- crates/witness_worker/src/lib.rs | 501 ----------- crates/witness_worker/src/witness_state_do.rs | 295 ------- crates/witness_worker/wrangler.jsonc | 40 - 40 files changed, 1521 insertions(+), 3516 deletions(-) rename crates/{witness_worker => mirror_worker}/.gitignore (100%) create mode 100644 crates/mirror_worker/README.md create mode 100644 crates/mirror_worker/config.mirror.json create mode 100644 crates/mirror_worker/config.witness.json delete mode 100644 crates/witness_worker/.dev.vars delete mode 100644 crates/witness_worker/Cargo.toml delete mode 120000 crates/witness_worker/LICENSE delete mode 100644 crates/witness_worker/README.md delete mode 100644 crates/witness_worker/build.rs delete mode 100644 crates/witness_worker/config.dev.json delete mode 100644 crates/witness_worker/config.schema.json delete mode 100644 crates/witness_worker/config/Cargo.toml delete mode 120000 crates/witness_worker/config/LICENSE delete mode 100644 crates/witness_worker/config/src/lib.rs delete mode 100755 crates/witness_worker/reset-dev.sh delete mode 100644 crates/witness_worker/src/frontend_worker.rs delete mode 100644 crates/witness_worker/src/lib.rs delete mode 100644 crates/witness_worker/src/witness_state_do.rs delete mode 100644 crates/witness_worker/wrangler.jsonc diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 463ebdd0..2db2125c 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -114,7 +114,7 @@ jobs: integration-tlog-mirror: name: TLog Mirror Integration Tests runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -124,13 +124,18 @@ jobs: with: node-version: "22" + - name: Install worker-build + run: cargo install worker-build@0.8.5 --locked + - name: Install Wrangler run: npm install --global wrangler@4.80.0 - - name: Install worker-build - run: cargo install worker-build@0.8.5 --locked + - name: Check standalone mirror worker modes + run: | + DEPLOY_ENV=witness cargo check -p mirror_worker --all-targets + DEPLOY_ENV=mirror cargo check -p mirror_worker --all-targets - - name: Build mirror_worker (dev environment) + - name: Build mirror_worker (combined dev environment) working-directory: crates/mirror_worker run: DEPLOY_ENV=dev RUSTFLAGS='-Cllvm-args=-wasm-use-legacy-eh' worker-build --release --panic-unwind @@ -151,7 +156,12 @@ jobs: echo "wrangler dev failed to start in time" exit 1 - - name: Run TLog Mirror integration tests - run: cargo test -p integration_tests --test tlog_mirror --verbose + - name: Run TLog integration tests + run: cargo test -p integration_tests --test tlog_witness --verbose + env: + BASE_URL: http://localhost:8787 + RUST_TEST_THREADS: "1" + - run: cargo test -p integration_tests --test tlog_mirror --verbose env: BASE_URL: http://localhost:8787 + RUST_TEST_THREADS: "1" diff --git a/AGENTS.md b/AGENTS.md index f2f72ef2..6e0a5dfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ Azul is a Rust workspace implementing tiled transparency logs for deployment on ``` crates/ct_worker/ - Static CT API Worker (deployable); wrangler.jsonc here +crates/mirror_worker/ - Configurable tlog mirror/witness Worker (deployable) crates/generic_log_worker/ - Shared Durable Object logic (Sequencer, Batcher, Cleaner) crates/tlog_tiles/ - C2SP tlog-tiles spec impl (published to crates.io) crates/static_ct_api/ - C2SP static-ct-api spec impl (published to crates.io) @@ -48,9 +49,9 @@ npx wrangler -e=${ENV} tail - Worker crates use `crate-type = ["cdylib"]`; library crates use `rlib` - Worker build is handled by `worker-build`, not `cargo build` directly — wrangler.jsonc invokes it automatically -- Config types live in separate sub-crates such as `crates/ct_worker/config/` +- Config types live in separate sub-crates such as `crates/ct_worker/config/` and `crates/mirror_worker/config/` - `DEPLOY_ENV=` env var must be set when invoking `worker-build` manually; wrangler.jsonc sets it per environment -- Route HTTP with `axum::Router` (worker features `["http", "axum"]`), not the `worker::Router`. The `#[event(fetch)]` handler takes a `HttpRequest`, returns `axum::http::Response`, and dispatches via `tower_service::Service::call`; handlers return `impl IntoResponse`. See `witness_worker`/`ct_worker` for the pattern. +- Route HTTP with `axum::Router` (worker features `["http", "axum"]`), not the `worker::Router`. The `#[event(fetch)]` handler takes a `HttpRequest`, returns `axum::http::Response`, and dispatches via `tower_service::Service::call`; handlers return `impl IntoResponse`. See `mirror_worker`/`ct_worker` for the pattern. ## Workflow diff --git a/Cargo.lock b/Cargo.lock index f7a0e782..75f4df33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1832,8 +1832,6 @@ version = "0.2.0" dependencies = [ "axum", "base64", - "console_error_panic_hook", - "console_log", "ed25519-dalek", "flate2", "futures-util", @@ -1867,6 +1865,7 @@ dependencies = [ name = "mirror_worker_config" version = "0.2.0" dependencies = [ + "ed25519-dalek", "ml-dsa", "serde", "serde_json", @@ -4147,46 +4146,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "witness_worker" -version = "0.2.0" -dependencies = [ - "axum", - "base64", - "console_error_panic_hook", - "console_log", - "ed25519-dalek", - "generic_log_worker", - "getrandom 0.3.4", - "getrandom 0.4.2", - "log", - "ml-dsa", - "p256", - "pkcs8", - "serde", - "serde_json", - "serde_with", - "signed_note", - "tlog_checkpoint", - "tlog_core", - "tlog_cosignature", - "tlog_witness", - "tower-service", - "witness_worker_config", - "worker", - "worker_build_config", -] - -[[package]] -name = "witness_worker_config" -version = "0.2.0" -dependencies = [ - "ed25519-dalek", - "serde", - "serde_with", - "signed_note", -] - [[package]] name = "wnaf" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index 49aa139a..8b41b50e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,6 @@ members = [ "crates/generic_log_worker", "crates/integration_tests", "crates/length_prefixed", - "crates/mirror_worker", - "crates/mirror_worker/config", "crates/sct_validator", "crates/signed_note", "crates/signed_note_wasm", @@ -21,11 +19,11 @@ members = [ "crates/tlog_cosignature", "crates/tlog_entry", "crates/tlog_mirror", + "crates/mirror_worker", + "crates/mirror_worker/config", "crates/tlog_tiles", "crates/tlog_tiles_wasm", "crates/tlog_witness", - "crates/witness_worker", - "crates/witness_worker/config", "crates/worker_build_config", "crates/x509_util", "fuzz", @@ -46,8 +44,6 @@ default-members = [ "crates/ct_worker", "crates/generic_log_worker", "crates/length_prefixed", - "crates/mirror_worker", - "crates/mirror_worker/config", "crates/sct_validator", "crates/signed_note", "crates/signed_note_wasm", @@ -57,11 +53,11 @@ default-members = [ "crates/tlog_cosignature", "crates/tlog_entry", "crates/tlog_mirror", + "crates/mirror_worker", + "crates/mirror_worker/config", "crates/tlog_tiles", "crates/tlog_tiles_wasm", "crates/tlog_witness", - "crates/witness_worker", - "crates/witness_worker/config", "crates/worker_build_config", "crates/x509_util", ] diff --git a/README.md b/README.md index ba0fca1b..a399adab 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Azul (short for [azulejos](https://en.wikipedia.org/wiki/Azulejo), the colorful The crates in the repository are organized as follows: - **[ct_worker](crates/ct_worker)**: A Static CT API log implementation for deployment on Cloudflare Workers. +- **[mirror_worker](crates/mirror_worker)**: A configurable [tlog-mirror](https://c2sp.org/tlog-mirror), [tlog-witness](https://c2sp.org/tlog-witness), or combined worker. Logs are keyed by exact checkpoint origin with Ed25519 or ML-DSA-44 checkpoint signers. - **[static_ct_api](crates/static_ct_api)** ([crates.io](https://crates.io/crates/static_ct_api)): An implementation of the [C2SP static-ct-api](https://c2sp.org/static-ct-api) specification. - **[signed_note](crates/signed_note)** ([crates.io](https://crates.io/crates/signed_note)): An implementation of the [C2SP signed-note](https://c2sp.org/signed-note) specification. - **[tlog_tiles](crates/tlog_tiles)** ([crates.io](https://crates.io/crates/tlog_tiles)): An implementation of the [C2SP tlog-tiles](https://c2sp.org/tlog-tiles) and [C2SP checkpoint](https://c2sp.org/tlog-checkpoint) specifications. diff --git a/crates/integration_tests/tests/tlog_mirror.rs b/crates/integration_tests/tests/tlog_mirror.rs index 34184442..f9450a4a 100644 --- a/crates/integration_tests/tests/tlog_mirror.rs +++ b/crates/integration_tests/tests/tlog_mirror.rs @@ -1,10 +1,10 @@ // Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause -//! End-to-end integration tests for the `mirror_worker` implementation of +//! End-to-end integration tests for the mirror role in `mirror_worker`. //! [c2sp.org/tlog-mirror][spec]. //! -//! These tests require a local `wrangler dev` instance of `mirror_worker` +//! These tests require a local combined `wrangler dev` instance of `mirror_worker` //! on `localhost:8787` (or a loopback `BASE_URL`), backed by fresh state. //! Delete `crates/mirror_worker/.wrangler/state/` between runs. CI starts //! with fresh state. @@ -13,16 +13,15 @@ //! threads an in-memory [`ToyLog`] through `add-checkpoint`, `add-entries`, //! and `sign-subtree` in order. //! -//! Per spec the mirror MUST NOT cosign on `add-checkpoint` (the success -//! body is empty); cosignatures are emitted only by `add-entries` once -//! entries catch up to the pending tree size. +//! The combined worker returns its witness cosignature on `add-checkpoint`; +//! the mirror identity signs only after `add-entries` reaches the pending size. //! //! `LOG_KEY_NAME` identifies the CA cosigner; `LOG_ORIGIN` identifies log 1. //! The embedded key must match the SPKI in `config.dev.json`. //! //! [spec]: https://c2sp.org/tlog-mirror -use ml_dsa::pkcs8::DecodePrivateKey as _; +use ed25519_dalek::{SigningKey as Ed25519SigningKey, pkcs8::DecodePrivateKey as _}; use ml_dsa::{ExpandedSigningKey, MlDsa44}; use rand::Rng as _; use rand::rng; @@ -32,7 +31,9 @@ use sha2::{Digest as _, Sha256}; use signed_note::{KeyName, Note, NoteSignature, VerifierList}; use std::collections::HashMap; use std::time::Duration; -use tlog_checkpoint::{CheckpointSigner, TreeWithTimestamp}; +use tlog_checkpoint::{ + CheckpointSigner, CheckpointText, Ed25519CheckpointSigner, TreeWithTimestamp, +}; use tlog_core::{ HASH_SIZE, Hash, HashReader, Subtree, TlogError, consistency_proof, record_hash, stored_hashes, subtree_consistency_proof, subtree_hash, tree_hash, @@ -43,8 +44,8 @@ use tlog_mirror::{ }; use tlog_tiles::{PathElem, PreloadedTlogTileReader, TileHashReader, TlogTile, TlogTileRecorder}; use tlog_witness::{ - CONTENT_TYPE_TLOG_SIZE, parse_sign_subtree_response, serialize_add_checkpoint_request, - serialize_sign_subtree_request, + CONTENT_TYPE_TLOG_SIZE, parse_add_checkpoint_response, parse_sign_subtree_response, + serialize_add_checkpoint_request, serialize_sign_subtree_request, }; // --------------------------------------------------------------------------- @@ -56,8 +57,8 @@ const LOG_KEY_NAME: &str = "oid/1.3.6.1.4.1.32473.2"; /// Numbered log origin accepted under [`LOG_KEY_NAME`]. const LOG_ORIGIN: &str = "oid/1.3.6.1.4.1.32473.2.0.1"; -const OTHER_LOG_ORIGIN: &str = "oid/1.3.6.1.4.1.32473.2.0.2"; -const R2_BUCKET: &str = "tlog-mirror-public-dev"; +const OTHER_LOG_ORIGIN: &str = "example.com/log1"; +const R2_BUCKET: &str = "mirror-worker-public-dev"; /// Dev-only ML-DSA-44 `subtree/v1` key matching `config.dev.json`. /// Do not use outside these integration tests. @@ -66,6 +67,10 @@ const LOG_SIGNING_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ ERERERER\n\ -----END PRIVATE KEY-----\n"; +const ED25519_LOG_SIGNING_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MC4CAQAwBQYDK2VwBCIEIA2VCmSeCNVJTboEACcXvVahZHSHEJDxSl94aej1Q8hQ\n\ + -----END PRIVATE KEY-----\n"; + fn log_signer() -> SubtreeV1CheckpointSigner { let sk = ExpandedSigningKey::::from_pkcs8_pem(LOG_SIGNING_KEY_PEM) .expect("parse dev log key"); @@ -74,6 +79,13 @@ fn log_signer() -> SubtreeV1CheckpointSigner { SubtreeV1CheckpointSigner::new(name, sk) } +fn ed25519_log_signer() -> Ed25519CheckpointSigner { + let key = Ed25519SigningKey::from_pkcs8_pem(ED25519_LOG_SIGNING_KEY_PEM) + .expect("parse dev Ed25519 log key"); + let name = KeyName::new(OTHER_LOG_ORIGIN.to_owned()).expect("Ed25519 log key name"); + Ed25519CheckpointSigner::new(name, key) +} + /// Generate a fresh ML-DSA-44 log signer with a random key, under the /// given cosigner name. Used by the untrusted-key (403) and /// unknown-origin (404) steps. @@ -470,9 +482,10 @@ async fn post_sign_subtree(body: &[u8]) -> AddCheckpointResult { /// configured name, used to verify `sign-subtree` responses. fn mirror_verifier(meta: &MetadataResponse) -> SubtreeV1NoteVerifier { use pkcs8::DecodePublicKey; - let mirror_vk = ml_dsa::VerifyingKey::::from_public_key_der(&meta.mirror_public_key) + let identity = meta.mirror.as_ref().expect("mirror metadata"); + let mirror_vk = ml_dsa::VerifyingKey::::from_public_key_der(&identity.public_key) .expect("mirror SPKI must parse as ML-DSA-44"); - let name = KeyName::new(meta.mirror_name.clone()).expect("KeyName for mirror"); + let name = KeyName::new(identity.name.clone()).expect("KeyName for mirror"); SubtreeV1NoteVerifier::new(name, mirror_vk) } @@ -510,28 +523,38 @@ async fn advance_pending(log: &ToyLog, signer: &SubtreeV1CheckpointSigner, old_s #[serde_as] #[derive(Deserialize, Debug)] struct MetadataResponse { - mirror_name: String, - #[allow(dead_code)] - description: Option, - #[serde_as(as = "Base64")] - mirror_public_key: Vec, - mirror_algorithm: String, + mode: String, + mirror: Option, submission_prefix: String, - #[allow(dead_code)] monitoring_prefix: String, logs: Vec, } +#[serde_as] +#[derive(Deserialize, Debug)] +struct IdentityMetadata { + name: String, + #[serde_as(as = "Base64")] + public_key: Vec, + algorithm: String, + supports_sign_subtree: bool, +} + #[serde_as] #[derive(Deserialize, Debug)] struct LogMetadata { - #[allow(dead_code)] description: Option, - log_key_name: String, - min_log_number: u64, - max_log_number: u64, - #[serde_as(as = "Vec")] - log_public_keys: Vec>, + origin: String, + checkpoint_signers: Vec, +} + +#[serde_as] +#[derive(Deserialize, Debug)] +struct CheckpointSignerMetadata { + name: String, + algorithm: String, + #[serde_as(as = "Base64")] + public_key: Vec, } async fn fetch_metadata() -> MetadataResponse { @@ -569,31 +592,63 @@ async fn tlog_mirror_end_to_end() { // --- GET /metadata --- let meta = fetch_metadata().await; - assert_eq!(meta.mirror_name, "dev.mirror.example"); - assert!(!meta.mirror_public_key.is_empty()); + assert_eq!(meta.mode, "witness-and-mirror"); + let mirror = meta.mirror.as_ref().expect("mirror identity metadata"); + assert_eq!(mirror.name, "dev.mirror.example"); + assert!(!mirror.public_key.is_empty()); assert_eq!( - meta.mirror_algorithm, "subtree/v1", + mirror.algorithm, "subtree/v1", "dev mirror loads ML-DSA-44 from .dev.vars; algorithm must surface as subtree/v1", ); + assert!(mirror.supports_sign_subtree); assert!(meta.submission_prefix.starts_with("http")); + assert!(meta.monitoring_prefix.starts_with("http")); let log_meta = meta .logs .iter() - .find(|l| l.log_key_name == LOG_KEY_NAME) - .unwrap_or_else(|| panic!("metadata does not list the {LOG_KEY_NAME} cosigner")); - assert_eq!(log_meta.log_public_keys.len(), 1); - // LOG_ORIGIN is LOG_KEY_NAME + ".0.1"; its log number (1) must fall - // within the published [min_log_number, max_log_number] window. - assert!( - log_meta.min_log_number <= 1 && 1 <= log_meta.max_log_number, - "published window [{}, {}] must cover log number 1 ({LOG_ORIGIN})", - log_meta.min_log_number, - log_meta.max_log_number, - ); + .find(|l| l.origin == LOG_ORIGIN) + .unwrap_or_else(|| panic!("metadata does not list {LOG_ORIGIN}")); + assert!(log_meta.description.is_some()); + assert_eq!(log_meta.checkpoint_signers.len(), 1); + assert_eq!(log_meta.checkpoint_signers[0].name, LOG_KEY_NAME); + assert_eq!(log_meta.checkpoint_signers[0].algorithm, "subtree/v1"); + assert!(!log_meta.checkpoint_signers[0].public_key.is_empty()); + let ed_log_meta = meta + .logs + .iter() + .find(|l| l.origin == OTHER_LOG_ORIGIN) + .unwrap_or_else(|| panic!("metadata does not list {OTHER_LOG_ORIGIN}")); + assert_eq!(ed_log_meta.checkpoint_signers[0].name, OTHER_LOG_ORIGIN); + assert_eq!(ed_log_meta.checkpoint_signers[0].algorithm, "ed25519"); let signer = log_signer(); let mut log = ToyLog::new(); + // A size-zero checkpoint is state, rather than the absence of state. + { + let cp = log.sign_checkpoint(&signer); + let note = Note::from_bytes(&cp).unwrap(); + let body = serialize_add_checkpoint_request(0, &[], ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!(r.status, 200, "empty checkpoint submission"); + + let body = build_add_entries_header(0, 0, Vec::new()); + let r = post_add_entries(&body).await; + assert_eq!( + r.status, + 200, + "empty checkpoint mirror: {:?}", + String::from_utf8_lossy(&r.body) + ); + assert_eq!(parse_add_checkpoint_response(&r.body).unwrap().len(), 1); + + let served = require_local_r2_object("checkpoint").await; + let served = Note::from_bytes(&served).expect("served empty checkpoint note"); + let checkpoint = CheckpointText::from_bytes(served.text()).unwrap(); + assert_eq!(checkpoint.size(), 0); + assert_eq!(*checkpoint.hash(), tlog_core::EMPTY_HASH); + } + // A configured origin needs a pending checkpoint before accepting // entries. { @@ -607,10 +662,18 @@ async fn tlog_mirror_end_to_end() { ); } + let ed25519_signer = ed25519_log_signer(); let mut other_log = ToyLog::new(); other_log.push(b"other leaf 0"); { - let cp = other_log.sign_checkpoint_for(OTHER_LOG_ORIGIN, &signer); + let tree = TreeWithTimestamp::new( + other_log.size(), + other_log.root(other_log.size()), + now_millis(), + ); + let cp = tree + .sign(OTHER_LOG_ORIGIN, &[], &[&ed25519_signer], &mut rng()) + .expect("sign Ed25519 checkpoint"); let note = Note::from_bytes(&cp).expect("other-origin checkpoint"); let body = serialize_add_checkpoint_request(0, &[], ¬e).unwrap(); let r = post_add_checkpoint(&body).await; @@ -635,11 +698,9 @@ async fn tlog_mirror_end_to_end() { "first submission: body={:?}", String::from_utf8_lossy(&r.body) ); - // Mirror MUST NOT cosign on add-checkpoint; the response body is - // empty. (Spec: "responding with an empty response body".) assert!( - r.body.is_empty(), - "mirror response body must be empty, got: {:?}", + !r.body.is_empty(), + "combined response must contain the witness signature, got: {:?}", String::from_utf8_lossy(&r.body) ); } @@ -659,7 +720,10 @@ async fn tlog_mirror_end_to_end() { "second submission: body={:?}", String::from_utf8_lossy(&r.body) ); - assert!(r.body.is_empty(), "200 response body must be empty"); + assert!( + !r.body.is_empty(), + "combined response must contain a witness signature" + ); } // --- Stale old_size -> 409 --- diff --git a/crates/integration_tests/tests/tlog_witness.rs b/crates/integration_tests/tests/tlog_witness.rs index 2e6bddf2..355d7291 100644 --- a/crates/integration_tests/tests/tlog_witness.rs +++ b/crates/integration_tests/tests/tlog_witness.rs @@ -1,12 +1,12 @@ // Copyright (c) 2025 Cloudflare, Inc. // Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause -//! End-to-end integration tests for the [`witness_worker`] implementation of +//! End-to-end integration tests for the witness role in [`mirror_worker`]. //! [`c2sp.org/tlog-witness`]. //! -//! These tests require a running `wrangler dev` instance of `witness_worker` +//! These tests require a running combined `wrangler dev` instance of `mirror_worker` //! on `localhost:8787` (or `BASE_URL`), backed by **fresh** persistent state. -//! Delete `crates/witness_worker/.wrangler/state/` between runs to reset the +//! Delete `crates/mirror_worker/.wrangler/state/` between runs to reset the //! per-origin `(size, hash)` the witness has cosigned. CI does this //! automatically. //! @@ -46,9 +46,9 @@ //! per the dev `WITNESS_SIGNING_KEY` in `.dev.vars`. The log signing //! key remains Ed25519. Both PEMs are duplicated in the witness crate's //! unit tests so a rotation breaks closed; see -//! `crates/witness_worker/src/lib.rs::dev_config_tests`. +//! `crates/mirror_worker/src/lib.rs::dev_config_tests`. //! -//! [`witness_worker`]: ../../../crates/witness_worker +//! [`mirror_worker`]: ../../../crates/mirror_worker //! [`c2sp.org/tlog-witness`]: https://c2sp.org/tlog-witness use ed25519_dalek::{SigningKey as Ed25519SigningKey, pkcs8::DecodePrivateKey}; @@ -74,12 +74,11 @@ use tlog_witness::{ // --------------------------------------------------------------------------- /// Origin the witness is configured to accept checkpoints for (see -/// `crates/witness_worker/config.dev.json`). -const LOG_ORIGIN: &str = "example.com/log1"; +/// `crates/mirror_worker/config.dev.json`). +const LOG_ORIGIN: &str = "example.com/witness-log"; /// PKCS#8 PEM for the Ed25519 log key. The corresponding SPKI is committed -/// in `crates/witness_worker/config.dev.json` as the only entry of -/// `log_public_keys`. DEV-ONLY — this keypair is published in the repo and +/// in `crates/mirror_worker/config.dev.json`. This keypair is dev-only and /// MUST NOT be used for anything other than these integration tests. const LOG_SIGNING_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ MC4CAQAwBQYDK2VwBCIEIA2VCmSeCNVJTboEACcXvVahZHSHEJDxSl94aej1Q8hQ\n\ @@ -193,13 +192,9 @@ fn base_url() -> String { #[serde_as] #[derive(Deserialize, Debug)] struct MetadataResponse { - witness_name: String, - #[allow(dead_code)] - description: Option, - #[serde_as(as = "Base64")] - witness_public_key: Vec, + mode: String, + witness: Option, submission_prefix: String, - #[allow(dead_code)] monitoring_prefix: String, logs: Vec, } @@ -207,11 +202,26 @@ struct MetadataResponse { #[serde_as] #[derive(Deserialize, Debug)] struct LogMetadata { - #[allow(dead_code)] description: Option, origin: String, - #[serde_as(as = "Vec")] - log_public_keys: Vec>, + checkpoint_signers: Vec, +} + +#[serde_as] +#[derive(Deserialize, Debug)] +struct IdentityMetadata { + name: String, + #[serde_as(as = "Base64")] + public_key: Vec, +} + +#[serde_as] +#[derive(Deserialize, Debug)] +struct CheckpointSignerMetadata { + name: String, + algorithm: String, + #[serde_as(as = "Base64")] + public_key: Vec, } async fn fetch_metadata() -> MetadataResponse { @@ -297,9 +307,10 @@ async fn wait_for_witness() { /// `sign-subtree` responses. fn witness_verifier(meta: &MetadataResponse) -> SubtreeV1NoteVerifier { use pkcs8::DecodePublicKey; - let witness_vk = ml_dsa::VerifyingKey::::from_public_key_der(&meta.witness_public_key) + let identity = meta.witness.as_ref().expect("witness metadata"); + let witness_vk = ml_dsa::VerifyingKey::::from_public_key_der(&identity.public_key) .expect("witness SPKI must parse as ML-DSA-44"); - let name = KeyName::new(meta.witness_name.clone()).expect("KeyName for witness"); + let name = KeyName::new(identity.name.clone()).expect("KeyName for witness"); SubtreeV1NoteVerifier::new(name, witness_vk) } @@ -328,15 +339,22 @@ async fn tlog_witness_end_to_end() { // ----------------------- (1) /metadata ----------------------- let meta = fetch_metadata().await; - assert_eq!(meta.witness_name, "dev.witness.example"); - assert!(!meta.witness_public_key.is_empty()); + assert_eq!(meta.mode, "witness-and-mirror"); + let witness = meta.witness.as_ref().expect("witness identity metadata"); + assert_eq!(witness.name, "dev.witness.example"); + assert!(!witness.public_key.is_empty()); assert!(meta.submission_prefix.starts_with("http")); + assert!(meta.monitoring_prefix.starts_with("http")); let log = meta .logs .iter() .find(|l| l.origin == LOG_ORIGIN) .unwrap_or_else(|| panic!("metadata does not list the {LOG_ORIGIN} origin")); - assert_eq!(log.log_public_keys.len(), 1); + assert!(log.description.is_some()); + assert_eq!(log.checkpoint_signers.len(), 1); + assert_eq!(log.checkpoint_signers[0].name, LOG_ORIGIN); + assert_eq!(log.checkpoint_signers[0].algorithm, "ed25519"); + assert!(!log.checkpoint_signers[0].public_key.is_empty()); let signer = log_signer(); let mut log = ToyLog::new(); diff --git a/crates/mirror_worker/.dev.vars b/crates/mirror_worker/.dev.vars index 2150b9d6..d70ffd22 100644 --- a/crates/mirror_worker/.dev.vars +++ b/crates/mirror_worker/.dev.vars @@ -1,2 +1,3 @@ MIRROR_SIGNING_KEY="-----BEGIN PRIVATE KEY-----\nMDQCAQAwCwYJYIZIAWUDBAMRBCKAIEJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJC\nQkJCQkJC\n-----END PRIVATE KEY-----\n" MIRROR_TICKET_KEY="Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc=" +WITNESS_SIGNING_KEY="-----BEGIN PRIVATE KEY-----\nMDQCAQAwCwYJYIZIAWUDBAMRBCKAIENDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0ND\nQ0NDQ0ND\n-----END PRIVATE KEY-----\n" diff --git a/crates/witness_worker/.gitignore b/crates/mirror_worker/.gitignore similarity index 100% rename from crates/witness_worker/.gitignore rename to crates/mirror_worker/.gitignore diff --git a/crates/mirror_worker/Cargo.toml b/crates/mirror_worker/Cargo.toml index dc19e6ab..86470406 100644 --- a/crates/mirror_worker/Cargo.toml +++ b/crates/mirror_worker/Cargo.toml @@ -11,7 +11,7 @@ license.workspace = true readme.workspace = true homepage.workspace = true repository.workspace = true -description = "A transparency-log mirror (c2sp.org/tlog-mirror) on Cloudflare Workers" +description = "A transparency-log witness and mirror on Cloudflare Workers" categories = ["cryptography"] keywords = ["transparency", "mirror", "crypto", "pki"] @@ -33,8 +33,7 @@ worker_build_config.workspace = true axum.workspace = true base64.workspace = true config = { path = "./config", package = "mirror_worker_config" } -console_error_panic_hook.workspace = true -console_log.workspace = true +ed25519-dalek.workspace = true flate2.workspace = true futures-util.workspace = true generic_log_worker.workspace = true @@ -61,9 +60,6 @@ tower-service.workspace = true worker = { workspace = true, features = ["http", "axum"] } [dev-dependencies] -# Ed25519 is used only by unit tests, to check that a non-ML-DSA-44 -# MIRROR_SIGNING_KEY is rejected. The shipped worker is ML-DSA-44 only. -ed25519-dalek.workspace = true tokio = { workspace = true, features = ["macros", "rt"] } [lints.rust] diff --git a/crates/mirror_worker/README.md b/crates/mirror_worker/README.md new file mode 100644 index 00000000..7e80eb02 --- /dev/null +++ b/crates/mirror_worker/README.md @@ -0,0 +1,43 @@ +# Transparency Log Mirror Worker + +A configurable Cloudflare Worker implementing +[`c2sp.org/tlog-witness`](https://c2sp.org/tlog-witness), +[`c2sp.org/tlog-mirror`](https://c2sp.org/tlog-mirror), or both protocols +with one per-origin `MirrorState` Durable Object. + +## Configuration + +`mode` is one of `witness`, `mirror`, or `witness-and-mirror`. The matching +`witness` and `mirror` identity sections are required only when that role is +enabled. `logs` is keyed by exact checkpoint origin and supports structured +Ed25519 and `subtree/v1` checkpoint signers. + +Role keys remain separate secrets: + +- `WITNESS_SIGNING_KEY` signs successful `add-checkpoint` responses and witness subtree responses. +- `MIRROR_SIGNING_KEY` signs completed mirror checkpoints and mirror subtree responses. +- `MIRROR_TICKET_KEY` seals mirror upload tickets. + +Disabled-role secrets are not loaded. Mirror R2, ticket, and cleaner access is +confined to mirror operations. + +## Mirror State + +For each origin, `MirrorState` maintains +`committed.size <= next_entry.size <= pending.size`. + +## Development + +The dev configuration enables both roles. Run from this directory: + +```bash +npx wrangler -e=dev dev +./reset-dev.sh +``` + +Run the integration suites from the workspace root against the same worker: + +```bash +cargo test -p integration_tests --test tlog_witness +cargo test -p integration_tests --test tlog_mirror +``` diff --git a/crates/mirror_worker/build.rs b/crates/mirror_worker/build.rs index 5ab7c1d4..e81c08c1 100644 --- a/crates/mirror_worker/build.rs +++ b/crates/mirror_worker/build.rs @@ -1,7 +1,7 @@ // Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause -//! Build script to include per-environment mirror configuration. +//! Build script to include per-environment mirror worker configuration. use config::AppConfig; diff --git a/crates/mirror_worker/config.dev.json b/crates/mirror_worker/config.dev.json index e41633b3..4a5d60c5 100644 --- a/crates/mirror_worker/config.dev.json +++ b/crates/mirror_worker/config.dev.json @@ -1,18 +1,47 @@ { "logging_level": "info", - "mirror_name": "dev.mirror.example", - "description": "Local-dev mirror for smoke testing", + "mode": "witness-and-mirror", "submission_prefix": "http://localhost:8787/", "monitoring_prefix": "http://localhost:8787/", - "clean_interval_secs": 5, - "commit_packages": 2, + "witness": { + "name": "dev.witness.example", + "description": "Local-dev witness for smoke testing" + }, + "mirror": { + "name": "dev.mirror.example", + "description": "Local-dev mirror for smoke testing", + "clean_interval_secs": 5, + "commit_packages": 2 + }, "logs": { - "oid/1.3.6.1.4.1.32473.2": { - "description": "Dev-only MTC CA cosigner. Key name is the CA ID; the mirror serves log numbers 1-6 as origins oid/1.3.6.1.4.1.32473.2.0.. log_public_keys holds a dev-only ML-DSA-44 SPKI.", - "min_log_number": 1, - "max_log_number": 6, - "log_public_keys": [ - "MIIFMjALBglghkgBZQMEAxEDggUhAMUP9YKtqA5Sb9kyZi1dJLMWSNEae05DELoJsDcc9tCh/dmDLuE4GPlVMYk9LkspRWBVHVLBrrlAeQjxth+7tyL42ecaETWNo2bVNTEWkMPY9OdLXHbYXTOHOCu7moIYutMK5MBKE3JvaqGJO+wXmeOINWHDGYHC1uNNc2zB5LTTLLqjrewynvFtggXpuVnZnIHZespFVFnoHIx5kL843uNedlUk5VBTGXYyZJsb1gHyFs11IaTguPiBLQpxhclDAMEfSdZfzC1rdirvO+3RhTVbRCEFd8bz724ExTEcubEY/x8e7kzs1E4X+Rnw8GBXgzN8A9NaoZOd3Mo8ID84Z3/UeoZyg3Y7gkmiNn40Y4S7IdCFwLcohJQWzrt/nLKTxtJUh7TbwyUIhmXjqYJkcRD5M+WxZbG+oH5Xm47XVYHTRmfDq5JJCz5K033kcoNxx8IhQJlK7hbmsDw3J6GAoAK7iu4Spbb/gjpYWKusnVYsjGRcDSr3Slp3r4z0+7WwZtWaQsNY/dg0kWIRuyprnBeacaw/GMfI2AeKIFcCe6tpYCWpY7IUC2+qvVg1L+YbwEiOIoqTQ466Mbp9tJOBWfro3dVNu2j3IwEa7pBUCeqtZFwVWiQqRKQ2wnpltTsEFaEGyOMxJTVvt2vROYftncrsoMHq3m1Yd8UN9UBFNtx18LNYEHFa0Mfsh1WcuYy333dSQ+0P/xRfSoFabNhJJ7KPJHeHuFGS+fHTv2VVsi2IiS8NTmd/8Q6DiSxA1s3wcp4oRZz5qA6o0ryXj2yXj+f64xOf+OqhFbmLsExwGWbBwnLZv3cd2AEydlD8Gzqy0x8XjCRt0/NNb8gXdaYCpe+42VQO8rcQK05qzVnxJ+XPaB2cY1DupWLtyqnUCEU9+Ua1oOds1HVCLiCVBCNCZz1hqfcqs1qGWedfg82i5SEKEn0h3mSRgLCMu/bGwlHny9p7GIXxZbj6GTOWf6EFevigS5l5q5j+KQgb1ujkTJCC6B1EdPHMwCbF9RKImvwZioWPwlHUcvfKxvJSDqryUbPVlJp5HuW4LljW0SmocMYPxumRQYrdxRHSMPEKqEtFOC5eenuiGnIpRvarKkHLF2vePK3j4HHE9yxpr8nXbWAyiJIo8NMnXjC+03QYdB7mobWVWfraLZhCHE60X31xb+p/VGsCweYV6FWkyJBm6z616savZr4M4BVBG/1PULRUNstdNSS/N/4ljzF3u6S3/lZj2ox2biN84cHKra80RLj5CNfnHko/kM8TPFHOAYm+KVFnoypdrYcOMzHW5hdBBemx7isREdqzzVkt3AUKCKLuXHITsOIJu3BtsvN1YX5VaFkQ/RxKAqZv4QXOvRJafW1hJ5zbYlh5KWKoGG8kRNVych7WiwY/+zi0SSw3vJznszqkQMUKkwptrLAZzpFHUztXxkBAqPkXGJ/8XuThA8VCT0IfbmfaHABnuR/KM+M4AdZUq+cJ/BJcaeXGl2vueFtaQrQiuEh3x0H4iHlOu3LIbCDRuUyMIUdJ7uwTtkB+WJFUKDiIYAfUKsWmYEtCBiR5dD5WRwCVlEnJmfYLTWuxAq6cE8V3pkOggotP/U6UKArfLn8JOt+i0CR96C2zMY1aG365ULSXT9QQ/P3u03ogg2y01cUoolz3Ges2D0nhwv0h7x4lBp+d10kFxPSoL7bXujHTQNSqn5zzpWNgijUi3xXAFCh14C0frEW4jRLst3v0mIUNIYi9/LDy41mhkSA=" + "oid/1.3.6.1.4.1.32473.2.0.1": { + "description": "Dev-only MTC issuance log. Its ML-DSA-44 signer name is the CA ID rather than the checkpoint origin.", + "checkpoint_signers": [ + { + "name": "oid/1.3.6.1.4.1.32473.2", + "algorithm": "subtree/v1", + "public_key": "MIIFMjALBglghkgBZQMEAxEDggUhAMUP9YKtqA5Sb9kyZi1dJLMWSNEae05DELoJsDcc9tCh/dmDLuE4GPlVMYk9LkspRWBVHVLBrrlAeQjxth+7tyL42ecaETWNo2bVNTEWkMPY9OdLXHbYXTOHOCu7moIYutMK5MBKE3JvaqGJO+wXmeOINWHDGYHC1uNNc2zB5LTTLLqjrewynvFtggXpuVnZnIHZespFVFnoHIx5kL843uNedlUk5VBTGXYyZJsb1gHyFs11IaTguPiBLQpxhclDAMEfSdZfzC1rdirvO+3RhTVbRCEFd8bz724ExTEcubEY/x8e7kzs1E4X+Rnw8GBXgzN8A9NaoZOd3Mo8ID84Z3/UeoZyg3Y7gkmiNn40Y4S7IdCFwLcohJQWzrt/nLKTxtJUh7TbwyUIhmXjqYJkcRD5M+WxZbG+oH5Xm47XVYHTRmfDq5JJCz5K033kcoNxx8IhQJlK7hbmsDw3J6GAoAK7iu4Spbb/gjpYWKusnVYsjGRcDSr3Slp3r4z0+7WwZtWaQsNY/dg0kWIRuyprnBeacaw/GMfI2AeKIFcCe6tpYCWpY7IUC2+qvVg1L+YbwEiOIoqTQ466Mbp9tJOBWfro3dVNu2j3IwEa7pBUCeqtZFwVWiQqRKQ2wnpltTsEFaEGyOMxJTVvt2vROYftncrsoMHq3m1Yd8UN9UBFNtx18LNYEHFa0Mfsh1WcuYy333dSQ+0P/xRfSoFabNhJJ7KPJHeHuFGS+fHTv2VVsi2IiS8NTmd/8Q6DiSxA1s3wcp4oRZz5qA6o0ryXj2yXj+f64xOf+OqhFbmLsExwGWbBwnLZv3cd2AEydlD8Gzqy0x8XjCRt0/NNb8gXdaYCpe+42VQO8rcQK05qzVnxJ+XPaB2cY1DupWLtyqnUCEU9+Ua1oOds1HVCLiCVBCNCZz1hqfcqs1qGWedfg82i5SEKEn0h3mSRgLCMu/bGwlHny9p7GIXxZbj6GTOWf6EFevigS5l5q5j+KQgb1ujkTJCC6B1EdPHMwCbF9RKImvwZioWPwlHUcvfKxvJSDqryUbPVlJp5HuW4LljW0SmocMYPxumRQYrdxRHSMPEKqEtFOC5eenuiGnIpRvarKkHLF2vePK3j4HHE9yxpr8nXbWAyiJIo8NMnXjC+03QYdB7mobWVWfraLZhCHE60X31xb+p/VGsCweYV6FWkyJBm6z616savZr4M4BVBG/1PULRUNstdNSS/N/4ljzF3u6S3/lZj2ox2biN84cHKra80RLj5CNfnHko/kM8TPFHOAYm+KVFnoypdrYcOMzHW5hdBBemx7isREdqzzVkt3AUKCKLuXHITsOIJu3BtsvN1YX5VaFkQ/RxKAqZv4QXOvRJafW1hJ5zbYlh5KWKoGG8kRNVych7WiwY/+zi0SSw3vJznszqkQMUKkwptrLAZzpFHUztXxkBAqPkXGJ/8XuThA8VCT0IfbmfaHABnuR/KM+M4AdZUq+cJ/BJcaeXGl2vueFtaQrQiuEh3x0H4iHlOu3LIbCDRuUyMIUdJ7uwTtkB+WJFUKDiIYAfUKsWmYEtCBiR5dD5WRwCVlEnJmfYLTWuxAq6cE8V3pkOggotP/U6UKArfLn8JOt+i0CR96C2zMY1aG365ULSXT9QQ/P3u03ogg2y01cUoolz3Ges2D0nhwv0h7x4lBp+d10kFxPSoL7bXujHTQNSqn5zzpWNgijUi3xXAFCh14C0frEW4jRLst3v0mIUNIYi9/LDy41mhkSA=" + } + ] + }, + "example.com/log1": { + "description": "Dev-only ordinary Ed25519 transparency log.", + "checkpoint_signers": [ + { + "name": "example.com/log1", + "algorithm": "ed25519", + "public_key": "MCowBQYDK2VwAyEAzdnT3CQc3ag2fmKnO1ntodIm0wfsymDkK89IOrHKLWc=" + } + ] + }, + "example.com/witness-log": { + "description": "Dev-only source used by the witness integration suite.", + "checkpoint_signers": [ + { + "name": "example.com/witness-log", + "algorithm": "ed25519", + "public_key": "MCowBQYDK2VwAyEAzdnT3CQc3ag2fmKnO1ntodIm0wfsymDkK89IOrHKLWc=" + } ] } } diff --git a/crates/mirror_worker/config.mirror.json b/crates/mirror_worker/config.mirror.json new file mode 100644 index 00000000..88978e36 --- /dev/null +++ b/crates/mirror_worker/config.mirror.json @@ -0,0 +1,23 @@ +{ + "logging_level": "info", + "mode": "mirror", + "submission_prefix": "http://localhost:8789/", + "monitoring_prefix": "http://localhost:8789/", + "mirror": { + "name": "dev.mirror.example", + "description": "Standalone local-dev mirror", + "clean_interval_secs": 5, + "commit_packages": 2 + }, + "logs": { + "example.com/log1": { + "checkpoint_signers": [ + { + "name": "example.com/log1", + "algorithm": "ed25519", + "public_key": "MCowBQYDK2VwAyEAzdnT3CQc3ag2fmKnO1ntodIm0wfsymDkK89IOrHKLWc=" + } + ] + } + } +} diff --git a/crates/mirror_worker/config.schema.json b/crates/mirror_worker/config.schema.json index 8eecfe47..5094cbb1 100644 --- a/crates/mirror_worker/config.schema.json +++ b/crates/mirror_worker/config.schema.json @@ -1,82 +1,78 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "required": ["mirror_name", "submission_prefix", "logs"], + "required": ["mode", "submission_prefix", "logs"], "additionalProperties": false, "properties": { - "logging_level": { - "type": "string", - "enum": ["trace", "debug", "info", "warn", "error"] - }, - "mirror_name": { - "type": "string", - "description": "The mirror's identity, as it appears in cosignature lines and on /metadata. Per c2sp.org/signed-note, this MUST NOT contain '+', whitespace, or control characters.", - "pattern": "^[^+\\s]+$" - }, - "description": { - "type": "string" - }, - "submission_prefix": { - "type": "string", - "description": "URL prefix for write APIs (e.g. https://mirror.example/)." - }, - "monitoring_prefix": { - "type": "string", - "description": "URL prefix for read APIs (the tlog-tiles read interface served at //...)." - }, - "clean_interval_secs": { - "type": "integer", - "minimum": 1, - "default": 3600, - "description": "How often (in seconds) the per-origin partial-tile cleaner wakes to clean orphaned partial tiles from object storage. Defaults to 3600 (one hour) when omitted." - }, - "commit_packages": { - "type": "integer", - "minimum": 1, - "maximum": 1024, - "default": 32, - "description": "How many entry packages add-entries verifies before flushing them to storage and advancing the persisted-entry frontier. Bounds in-memory buffering and gives durable mid-request progress on large uploads. Defaults to 32 (the recommended per-request package budget) when omitted; capped at 1024 to bound worst-case buffering." - }, - "max_chunk_bytes": { - "type": "integer", - "minimum": 1, - "default": 16777216, - "description": "Byte ceiling on the entries buffered between flushes. add-entries flushes early once buffered entry bytes reach this many, even if fewer than commit_packages packages have accumulated. Bounds peak memory independent of package sizes, since a single package can hold up to ~16 MiB. Defaults to 16777216 (16 MiB) when omitted." - }, + "mode": {"type": "string", "enum": ["witness", "mirror", "witness-and-mirror"]}, + "logging_level": {"type": "string", "enum": ["trace", "debug", "info", "warn", "error"]}, + "submission_prefix": {"type": "string"}, + "monitoring_prefix": {"type": "string"}, + "witness": {"$ref": "#/definitions/identity"}, + "mirror": {"$ref": "#/definitions/mirror"}, "logs": { "type": "object", - "description": "CAs this mirror mirrors, keyed by log_key_name: the CA cosigner's note-signature name (the CA ID) on the checkpoints it ingests. Used as a signed-note key name at runtime, so per c2sp.org/signed-note it MUST NOT contain '+', whitespace, or control characters.", - "propertyNames": { - "pattern": "^[^+\\s]+$" - }, + "description": "Source logs keyed by exact checkpoint origin.", + "propertyNames": {"pattern": "^[^+\\s]+$"}, "additionalProperties": { "type": "object", - "required": ["log_public_keys", "min_log_number", "max_log_number"], + "required": ["checkpoint_signers"], "additionalProperties": false, "properties": { "description": {"type": "string"}, - "log_public_keys": { + "checkpoint_signers": { "type": "array", "minItems": 1, "items": { - "type": "string", - "description": "A DER-encoded ML-DSA-44 SubjectPublicKeyInfo, base64-encoded. The mirror accepts subtree/v1 checkpoint signatures from any of these keys and ignores signatures from others." + "type": "object", + "required": ["name", "algorithm", "public_key"], + "additionalProperties": false, + "properties": { + "name": {"type": "string", "pattern": "^[^+\\s]+$"}, + "algorithm": {"type": "string", "enum": ["ed25519", "subtree/v1"]}, + "public_key": {"type": "string", "description": "Base64 DER SubjectPublicKeyInfo."} + } } - }, - "min_log_number": { - "type": "integer", - "minimum": 1, - "maximum": 65535, - "description": "Lowest accepted MTC log number. MTC log numbers start at 1. The mirror serves a log at each origin .0. for N in [min_log_number, max_log_number]." - }, - "max_log_number": { - "type": "integer", - "minimum": 1, - "maximum": 65535, - "description": "Highest accepted MTC log number (inclusive). Must be >= min_log_number." } } } } + }, + "allOf": [ + { + "if": {"properties": {"mode": {"const": "witness"}}}, + "then": {"required": ["witness"], "not": {"required": ["mirror"]}} + }, + { + "if": {"properties": {"mode": {"const": "mirror"}}}, + "then": {"required": ["mirror"], "not": {"required": ["witness"]}} + }, + { + "if": {"properties": {"mode": {"const": "witness-and-mirror"}}}, + "then": {"required": ["witness", "mirror"]} + } + ], + "definitions": { + "identity": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": {"type": "string", "pattern": "^[^+\\s]+$"}, + "description": {"type": "string"} + } + }, + "mirror": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": {"type": "string", "pattern": "^[^+\\s]+$"}, + "description": {"type": "string"}, + "clean_interval_secs": {"type": "integer", "minimum": 1, "default": 3600}, + "commit_packages": {"type": "integer", "minimum": 1, "maximum": 1024, "default": 32}, + "max_chunk_bytes": {"type": "integer", "minimum": 1, "default": 16777216} + } + } } } diff --git a/crates/mirror_worker/config.witness.json b/crates/mirror_worker/config.witness.json new file mode 100644 index 00000000..7cd4d2f7 --- /dev/null +++ b/crates/mirror_worker/config.witness.json @@ -0,0 +1,20 @@ +{ + "logging_level": "info", + "mode": "witness", + "submission_prefix": "http://localhost:8788/", + "witness": { + "name": "dev.witness.example", + "description": "Standalone local-dev witness" + }, + "logs": { + "example.com/witness-log": { + "checkpoint_signers": [ + { + "name": "example.com/witness-log", + "algorithm": "ed25519", + "public_key": "MCowBQYDK2VwAyEAzdnT3CQc3ag2fmKnO1ntodIm0wfsymDkK89IOrHKLWc=" + } + ] + } + } +} diff --git a/crates/mirror_worker/config/Cargo.toml b/crates/mirror_worker/config/Cargo.toml index 3113af80..25df4fb7 100644 --- a/crates/mirror_worker/config/Cargo.toml +++ b/crates/mirror_worker/config/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true description = "Configuration for mirror_worker" [dependencies] +ed25519-dalek.workspace = true ml-dsa.workspace = true serde.workspace = true serde_with.workspace = true diff --git a/crates/mirror_worker/config/src/lib.rs b/crates/mirror_worker/config/src/lib.rs index f578ba46..f8aa9f47 100644 --- a/crates/mirror_worker/config/src/lib.rs +++ b/crates/mirror_worker/config/src/lib.rs @@ -1,97 +1,92 @@ // Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause -//! Configuration for [`mirror_worker`](../mirror_worker/). -//! -//! This mirror implements [`c2sp.org/tlog-mirror`][mirror] specialized for -//! [Merkle Tree Certificate][mtc] issuance logs: it mirrors the tiled logs -//! a CA publishes under a single CA cosigner key. It is configured with -//! the CAs it mirrors, keyed by `log_key_name`: the CA cosigner's -//! note-signature name, which is the CA ID (e.g. `oid/1.3.6.1.4.1.32473.2`) -//! and appears on every checkpoint the mirror ingests via -//! [`add-checkpoint`][add-cp]. Each entry gives one or more ML-DSA-44 SPKI -//! public keys the mirror accepts `subtree/v1` checkpoint signatures from. -//! -//! A CA cosigner name is distinct from the log origin: one CA cosigner key -//! covers a whole series of issuance logs, so each entry carries a required -//! `min_log_number`/`max_log_number` window. The mirror serves each log -//! number `N` in that window as its own origin `.0.` (per -//! [mtc-tlog][mtc], the `.0.` arc is fixed), all verified with this entry's -//! key(s). -//! -//! [mtc]: https://c2sp.org/mtc-tlog -//! -//! The mirror's own ML-DSA-44 signing key (used to produce its `subtree/v1` -//! mirror cosignature) is supplied out-of-band as a secret named -//! `MIRROR_SIGNING_KEY` (see the worker's `.dev.vars` in dev mode; use -//! `wrangler secret put MIRROR_SIGNING_KEY` for real deployments). -//! -//! [mirror]: https://c2sp.org/tlog-mirror -//! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint - -use ml_dsa::pkcs8::DecodePublicKey as _; +//! Configuration for the configurable transparency-log worker. + +use ed25519_dalek::pkcs8::DecodePublicKey as _; use ml_dsa::{MlDsa44, VerifyingKey as MlDsaVerifyingKey}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_with::{base64::Base64, serde_as}; -use signed_note::{KeyName, NoteVerifier}; +use signed_note::{Ed25519NoteVerifier, KeyName, NoteVerifier}; use std::collections::{BTreeSet, HashMap}; use tlog_cosignature::SubtreeV1NoteVerifier; -/// Top-level worker configuration, deserialized from `config..json`. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Mode { + Witness, + Mirror, + WitnessAndMirror, +} + +impl Mode { + #[must_use] + pub const fn witness_enabled(self) -> bool { + matches!(self, Self::Witness | Self::WitnessAndMirror) + } + + #[must_use] + pub const fn mirror_enabled(self) -> bool { + matches!(self, Self::Mirror | Self::WitnessAndMirror) + } + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Witness => "witness", + Self::Mirror => "mirror", + Self::WitnessAndMirror => "witness-and-mirror", + } + } +} + #[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] pub struct AppConfig { + pub mode: Mode, pub logging_level: Option, - /// The mirror's own identity. The name appears in every cosignature - /// line the mirror produces (and, on `/metadata`, the published - /// mirror identity). - pub mirror_name: String, - /// Human-readable description for operator dashboards. - pub description: Option, - /// URL prefix for write APIs (`add-checkpoint`, `add-entries`). - /// Published in the `/metadata` response so clients know where to - /// send requests. pub submission_prefix: String, - /// URL prefix for read APIs (the [tlog-tiles][tiles] read interface - /// served at `//...`). `None` - /// means "same as `submission_prefix`". - /// - /// [tiles]: https://c2sp.org/tlog-tiles pub monitoring_prefix: Option, - /// How often (in seconds) the per-origin partial-tile cleaner wakes - /// to clean orphaned partial tiles from object storage. `None` falls - /// back to a one-hour default (see [`Self::clean_interval_secs`]). - /// Consumed by [`mirror_worker`](../mirror_worker/)'s `cleaner_do`. + pub witness: Option, + pub mirror: Option, + #[serde(deserialize_with = "deserialize_logs")] + pub logs: HashMap, +} + +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct IdentityConfig { + pub name: String, + pub description: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct MirrorConfig { + pub name: String, + pub description: Option, pub clean_interval_secs: Option, - /// How many entry packages the `add-entries` handler verifies before - /// flushing them to storage and advancing the persisted-entry - /// frontier. Bounds in-memory buffering and gives durable mid-request - /// progress on large uploads. `None` falls back to a default of 32 - /// (see [`Self::commit_packages`]). Consumed by - /// [`mirror_worker`](../mirror_worker/)'s `add_entries`. pub commit_packages: Option, - /// Byte ceiling on the entries buffered between flushes. `add-entries` - /// flushes early once the buffered entry bytes reach this many, even if - /// fewer than `commit_packages` packages have accumulated. This bounds - /// peak memory independent of package sizes: `commit_packages` alone - /// caps only the package *count*, and a single package can hold up to - /// 256 entries of 65535 bytes (~16 MiB), so a count-only bound can - /// exceed the isolate's memory ceiling. `None` falls back to a default - /// (see [`Self::max_chunk_bytes`]). Consumed by - /// [`mirror_worker`](../mirror_worker/)'s `add_entries`. pub max_chunk_bytes: Option, - /// CAs this mirror mirrors, keyed by `log_key_name`: the CA - /// cosigner's note-signature name (the CA ID) carried by the - /// checkpoints it ingests. - #[serde(deserialize_with = "deserialize_logs")] - pub logs: HashMap, } -/// Deserialize [`AppConfig::logs`], rejecting duplicate `log_key_name` -/// keys. `serde_json` collapses a repeated object key onto one entry, -/// silently keeping the last value; for this config that would drop a -/// trusted CA (its keys and log-number window) without any error, so we -/// fail closed. The config is expected to be machine-generated, where a -/// generator or merge bug is a plausible source of duplicates. +impl MirrorConfig { + #[must_use] + pub fn clean_interval_secs(&self) -> u64 { + self.clean_interval_secs.unwrap_or(3600) + } + + #[must_use] + pub fn commit_packages(&self) -> u64 { + self.commit_packages.unwrap_or(32) + } + + #[must_use] + pub fn max_chunk_bytes(&self) -> u64 { + self.max_chunk_bytes.unwrap_or(16 * 1024 * 1024) + } +} + fn deserialize_logs<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -102,7 +97,7 @@ where type Value = HashMap; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("a map of log_key_name to log parameters") + f.write_str("a map of checkpoint origin to log parameters") } fn visit_map(self, mut access: A) -> Result @@ -110,13 +105,13 @@ where A: serde::de::MapAccess<'de>, { let mut logs = HashMap::with_capacity(access.size_hint().unwrap_or(0)); - while let Some((name, params)) = access.next_entry::()? { - if logs.contains_key(&name) { + while let Some((origin, params)) = access.next_entry::()? { + if logs.contains_key(&origin) { return Err(serde::de::Error::custom(format!( - "duplicate log_key_name {name:?} in logs" + "duplicate checkpoint origin {origin:?} in logs" ))); } - logs.insert(name, params); + logs.insert(origin, params); } Ok(logs) } @@ -126,236 +121,146 @@ where } impl AppConfig { - /// The partial-tile cleaner's wake interval, in seconds, falling back - /// to a one-hour default when the `clean_interval_secs` field is - /// unset. An hour bounds how long an orphaned partial tile lingers - /// without making the cleaner's periodic R2 listing a meaningful cost. #[must_use] - pub fn clean_interval_secs(&self) -> u64 { - self.clean_interval_secs.unwrap_or(3600) + pub const fn witness_enabled(&self) -> bool { + self.mode.witness_enabled() } - /// How many entry packages `add-entries` commits per flush, falling - /// back to 32 when `commit_packages` is unset. 32 matches the - /// per-request package budget clients are recommended to stay within - /// (tlog-mirror "Implementation Considerations"), so a compliant - /// single-request upload still commits once, while larger uploads - /// flush every 32 packages instead of buffering the whole body. #[must_use] - pub fn commit_packages(&self) -> u64 { - self.commit_packages.unwrap_or(32) + pub const fn mirror_enabled(&self) -> bool { + self.mode.mirror_enabled() } - /// Byte ceiling on buffered entries before `add-entries` flushes early, - /// falling back to 16 MiB when `max_chunk_bytes` is unset. This bounds - /// peak in-memory buffering regardless of how large individual packages - /// are, complementing the `commit_packages` count cap. 16 MiB matches - /// the worst-case size of a single spec-maximal package (256 entries * - /// 65535 bytes), so the default never flushes mid-package for a - /// compliant upload yet still caps a pathological one. #[must_use] - pub fn max_chunk_bytes(&self) -> u64 { - self.max_chunk_bytes.unwrap_or(16 * 1024 * 1024) - } - - /// Validate the configuration beyond what `serde` and the JSON schema - /// can express. - /// - /// Specifically, this checks: - /// - /// 1. `mirror_name` is a valid signed-note key name (per - /// [c2sp.org/signed-note][note]: non-empty, no whitespace, no `+`). - /// 2. Every `log_key_name` (i.e. every key in [`Self::logs`]) is a - /// valid signed-note key name. - /// 3. Every entry in `log_public_keys` is a parseable ML-DSA-44 SPKI. - /// 4. Within a single log entry, no two `log_public_keys` collide on - /// `(name, key_id)`. A `key_id` is a 32-bit hash, so a collision is - /// cosmically unlikely, but if one occurred the mirror could not - /// disambiguate signatures and every checkpoint for that log would - /// fail to verify. - /// 5. `min_log_number` and `max_log_number` both lie in - /// `1..=`[`MAX_MTC_LOG_NUMBER`], `max_log_number >= min_log_number`, - /// and the window spans at most [`MAX_LOG_NUMBER_WINDOW`] log - /// numbers. - /// 6. The longest origin from [`LogParams::origins`] still fits the - /// signed-note key name length cap, since each origin is itself - /// used as a checkpoint origin. + /// Return the mirror settings for a validated mirror-enabled config. /// - /// Simple single-field bounds (e.g. `commit_packages` and the - /// log-number ranges) are expressed in `config.schema.json` and - /// enforced by the build script, so they are not re-checked here. + /// # Panics /// - /// `log_key_name` uniqueness across log entries is not checked here; - /// it is enforced earlier, during deserialization (see - /// [`deserialize_logs`]). A plain `serde_json` object silently keeps - /// only the last value for a repeated key, so without that check a - /// duplicate `log_key_name` would drop a trusted CA (its keys and - /// log-number window) with no error. + /// Panics if mirror configuration is absent. + pub fn mirror_config(&self) -> &MirrorConfig { + self.mirror + .as_ref() + .expect("validated mirror mode must have mirror config") + } + + /// Validate mode-specific identities, signed-note names, algorithms, and keys. /// /// # Errors /// - /// Returns a human-readable error string identifying the failing - /// field and reason. The error is intended for operator consumption - /// (build-script panic messages, deployment-time logging) and is not - /// machine-parseable. - /// - /// [note]: https://c2sp.org/signed-note + /// Returns an operator-readable description of the invalid field. pub fn validate(&self) -> Result<(), String> { - KeyName::new(self.mirror_name.clone()).map_err(|e| { - format!( - "mirror_name {:?} is not a valid signed-note key name: {e:?}", - self.mirror_name, - ) - })?; - - for (log_key_name, log) in &self.logs { - log.validate(log_key_name)?; + if self.witness_enabled() != self.witness.is_some() { + return Err(format!( + "mode {} requires witness configuration iff witness is enabled", + self.mode.as_str() + )); + } + if self.mirror_enabled() != self.mirror.is_some() { + return Err(format!( + "mode {} requires mirror configuration iff mirror is enabled", + self.mode.as_str() + )); + } + if let Some(identity) = &self.witness { + validate_identity_name("witness.name", &identity.name)?; + } + if let Some(identity) = &self.mirror { + validate_identity_name("mirror.name", &identity.name)?; + } + if let (Some(witness), Some(mirror)) = (&self.witness, &self.mirror) + && witness.name == mirror.name + { + return Err("witness.name and mirror.name must be distinct".to_owned()); + } + for (origin, log) in &self.logs { + log.validate(origin)?; } - Ok(()) } } -/// Upper bound on the number of MTC log numbers a single `logs` entry's -/// `[min_log_number, max_log_number]` window may span. -/// -/// Each log number expands into its own checkpoint origin, Merkle tree, -/// storage prefix, and Durable Object instance, so an accidentally huge -/// window (e.g. a mistyped `max_log_number`) would balloon the origin set -/// the worker tracks. Real deployments use a small window, so this -/// generous cap only ever trips on operator error. -pub const MAX_LOG_NUMBER_WINDOW: u64 = 1024; - -/// The largest valid MTC log number. -/// -/// Per [Merkle Tree Certificates, Section 5.2][mtc], a CA's issuance logs -/// are numbered consecutively from 1 to at most 65535 (2^16 - 1), so valid -/// log numbers are `1..=MAX_MTC_LOG_NUMBER`. -/// -/// [mtc]: https://www.ietf.org/archive/id/draft-ietf-plants-merkle-tree-certs-05.html#section-5.2 -pub const MAX_MTC_LOG_NUMBER: u64 = 65535; - -/// Per-CA parameters: the public keys the mirror accepts checkpoint -/// signatures from, plus the MTC log-number window this CA cosigner -/// covers. The `log_key_name` (the CA cosigner's note-signature name) is -/// the key in the parent [`AppConfig::logs`] map and is not stored here. -#[serde_as] +fn validate_identity_name(field: &str, name: &str) -> Result<(), String> { + KeyName::new(name.to_owned()) + .map(|_| ()) + .map_err(|e| format!("{field} {name:?} is not a valid signed-note key name: {e:?}")) +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum CheckpointAlgorithm { + #[serde(rename = "ed25519")] + Ed25519, + #[serde(rename = "subtree/v1")] + SubtreeV1, +} + +impl CheckpointAlgorithm { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Ed25519 => "ed25519", + Self::SubtreeV1 => "subtree/v1", + } + } +} + #[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] pub struct LogParams { - /// Optional free-text description. pub description: Option, - /// One or more DER-encoded `SubjectPublicKeyInfo` blobs for the - /// ML-DSA-44 keys that may sign checkpoints for this log. The mirror - /// verifies incoming pending checkpoints against this list (as - /// `subtree/v1` cosignatures) and ignores signatures from other keys. - /// Typically one entry, but multiple are permitted for key rotation. - #[serde_as(as = "Vec")] - pub log_public_keys: Vec>, - /// Lowest accepted MTC log number. The mirror serves a distinct log at - /// each origin `.0.` for `N` in `[min_log_number, - /// max_log_number]`, all verified with this entry's key(s). - /// - /// This MUST be `>= 1`; log number 0 does not exist (see - /// [`MAX_MTC_LOG_NUMBER`]). - pub min_log_number: u64, - /// Highest accepted MTC log number (inclusive). See - /// [`Self::min_log_number`]; this MUST be `>= min_log_number` and - /// `<= `[`MAX_MTC_LOG_NUMBER`]. - pub max_log_number: u64, + pub checkpoint_signers: Vec, } -impl LogParams { - /// The concrete checkpoint origins this entry serves: - /// `.0.` for each log number `N` in the inclusive - /// `[min_log_number, max_log_number]` window. - /// - /// Assumes the entry has passed [`Self::validate`], so the window is - /// well-formed. - #[must_use] - pub fn origins(&self, log_key_name: &str) -> Vec { - (self.min_log_number..=self.max_log_number) - .map(|n| format!("{log_key_name}.0.{n}")) - .collect() - } +#[serde_as] +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct CheckpointSigner { + pub name: String, + pub algorithm: CheckpointAlgorithm, + #[serde_as(as = "Base64")] + pub public_key: Vec, } impl LogParams { - /// Validate this log's `log_key_name`, `log_public_keys`, and - /// log-number window. Called by [`AppConfig::validate`] for each - /// entry; takes the `log_key_name` (which lives in the parent map) as - /// an argument. - /// - /// # Errors - /// - /// Returns a human-readable error string. See [`AppConfig::validate`] - /// for the list of conditions checked. - pub fn validate(&self, log_key_name: &str) -> Result<(), String> { - let key_name = KeyName::new(log_key_name.to_owned()).map_err(|e| { - format!("log {log_key_name:?}: log_key_name is not a valid signed-note key name: {e:?}") - })?; - - // The log-number window must be in range and ordered. MTC log - // numbers are numbered consecutively from 1 to MAX_MTC_LOG_NUMBER; - // log number 0 does not exist. - let (min, max) = (self.min_log_number, self.max_log_number); - if min < 1 { + fn validate(&self, origin: &str) -> Result<(), String> { + validate_identity_name(&format!("log {origin:?} origin"), origin)?; + if self.checkpoint_signers.is_empty() { return Err(format!( - "log {log_key_name:?}: min_log_number is 0, but MTC log numbers start at 1", + "log {origin:?}: checkpoint_signers must not be empty" )); } - if max > MAX_MTC_LOG_NUMBER { - return Err(format!( - "log {log_key_name:?}: max_log_number ({max}) exceeds the maximum MTC log \ - number ({MAX_MTC_LOG_NUMBER})", - )); - } - if min > max { - return Err(format!( - "log {log_key_name:?}: min_log_number ({min}) must be <= max_log_number ({max})", - )); - } - // `max - min` cannot overflow: `min <= max` was just checked. - if max - min >= MAX_LOG_NUMBER_WINDOW { - return Err(format!( - "log {log_key_name:?}: log-number window [{min}, {max}] spans more than \ - {MAX_LOG_NUMBER_WINDOW} log numbers; refusing to expand it into that many \ - origins (likely a typo)", - )); - } - - // Each origin from `origins()` is itself used as a signed-note key - // name (the checkpoint origin), so it must fit KeyName's length - // cap even though log_key_name alone already passed. The longest - // origin appends ".0.". - let longest_origin = format!("{log_key_name}.0.{max}"); - if longest_origin.len() > KeyName::MAX_LEN { - return Err(format!( - "log {log_key_name:?}: its longest origin {longest_origin:?} is \ - {} bytes, over the {}-byte signed-note key name limit; shorten log_key_name", - longest_origin.len(), - KeyName::MAX_LEN, - )); - } - - // Every log_public_keys entry must be a parseable ML-DSA-44 SPKI, - // and the (name, key_id) pairs derived from them must be unique - // within this log. - let mut seen_ids: BTreeSet = BTreeSet::new(); - for (i, spki) in self.log_public_keys.iter().enumerate() { - let vk = MlDsaVerifyingKey::::from_public_key_der(spki).map_err(|e| { + let mut seen = BTreeSet::new(); + for (i, signer) in self.checkpoint_signers.iter().enumerate() { + let name = KeyName::new(signer.name.clone()).map_err(|e| { format!( - "log {log_key_name:?}: log_public_keys[{i}] is not a valid ML-DSA-44 SPKI: {e}" + "log {origin:?}: checkpoint_signers[{i}].name is not a valid signed-note key name: {e:?}" ) })?; - let v = SubtreeV1NoteVerifier::new(key_name.clone(), vk); - if !seen_ids.insert(v.key_id()) { + let key_id = match signer.algorithm { + CheckpointAlgorithm::Ed25519 => { + let key = ed25519_dalek::VerifyingKey::from_public_key_der(&signer.public_key) + .map_err(|e| { + format!( + "log {origin:?}: checkpoint_signers[{i}].public_key is not an Ed25519 SPKI: {e}" + ) + })?; + Ed25519NoteVerifier::new(name.clone(), key).key_id() + } + CheckpointAlgorithm::SubtreeV1 => { + let key = MlDsaVerifyingKey::::from_public_key_der(&signer.public_key) + .map_err(|e| { + format!( + "log {origin:?}: checkpoint_signers[{i}].public_key is not an ML-DSA-44 SPKI: {e}" + ) + })?; + SubtreeV1NoteVerifier::new(name.clone(), key).key_id() + } + }; + if !seen.insert((name, key_id)) { return Err(format!( - "log {log_key_name:?}: log_public_keys[{i}] shares a (name, key_id) pair with \ - an earlier key; mirror would be unable to disambiguate signatures from it", + "log {origin:?}: checkpoint_signers[{i}] duplicates an earlier (name, key_id)" )); } } - Ok(()) } } @@ -363,304 +268,172 @@ impl LogParams { #[cfg(test)] mod tests { use super::*; - - /// Generate a real ML-DSA-44 SPKI deterministically from a seed byte. - fn spki_for(seed: u8) -> Vec { - use ml_dsa::pkcs8::EncodePublicKey as _; - use ml_dsa::{Keypair as _, SigningKey}; - let sk = SigningKey::::from_seed(&ml_dsa::B32::from([seed; 32])); - sk.verifying_key().to_public_key_der().unwrap().to_vec() - } - - fn good_app_config() -> AppConfig { + use ed25519_dalek::pkcs8::EncodePublicKey as _; + use ml_dsa::{Keypair as _, SigningKey}; + + fn config(mode: Mode, witness: bool, mirror: bool) -> AppConfig { + let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]) + .verifying_key() + .to_public_key_der() + .unwrap() + .to_vec(); AppConfig { + mode, logging_level: None, - mirror_name: "mirror.example/m".to_owned(), - description: None, - submission_prefix: "https://mirror.example/".to_owned(), - monitoring_prefix: None, - clean_interval_secs: None, - commit_packages: None, - max_chunk_bytes: None, + submission_prefix: "https://submit.example/".to_owned(), + monitoring_prefix: Some("https://monitor.example/".to_owned()), + witness: witness.then(|| IdentityConfig { + name: "witness.example".to_owned(), + description: None, + }), + mirror: mirror.then(|| MirrorConfig { + name: "mirror.example".to_owned(), + description: None, + clean_interval_secs: None, + commit_packages: None, + max_chunk_bytes: None, + }), logs: HashMap::from([( - "example.com/log1".to_owned(), + "log.example".to_owned(), LogParams { description: None, - log_public_keys: vec![spki_for(1)], - min_log_number: 1, - max_log_number: 1, + checkpoint_signers: vec![CheckpointSigner { + name: "log.example".to_owned(), + algorithm: CheckpointAlgorithm::Ed25519, + public_key: key, + }], }, )]), } } - /// Helper: construct a fresh single-log config and let the caller - /// mutate. - fn with_log(f: F) -> AppConfig { - let mut cfg = good_app_config(); - let log = cfg.logs.values_mut().next().unwrap(); - f(log); - cfg - } - #[test] - fn validate_accepts_minimal_good_config() { - good_app_config() + fn accepts_all_three_modes() { + config(Mode::Witness, true, false).validate().unwrap(); + config(Mode::Mirror, false, true).validate().unwrap(); + config(Mode::WitnessAndMirror, true, true) .validate() - .expect("known-good config validates"); - } - - #[test] - fn validate_rejects_empty_mirror_name() { - let mut cfg = good_app_config(); - cfg.mirror_name = String::new(); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("mirror_name"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_mirror_name_with_plus() { - let mut cfg = good_app_config(); - cfg.mirror_name = "mirror+example".to_owned(); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("mirror_name"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_mirror_name_with_ascii_whitespace() { - let mut cfg = good_app_config(); - cfg.mirror_name = "mirror example".to_owned(); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("mirror_name"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_mirror_name_with_unicode_whitespace() { - let mut cfg = good_app_config(); - cfg.mirror_name = "mirror\u{00a0}name".to_owned(); // U+00A0 NBSP - let err = cfg.validate().unwrap_err(); - assert!(err.contains("mirror_name"), "unexpected error: {err}"); + .unwrap(); } #[test] - fn validate_rejects_empty_log_key_name() { - let mut cfg = good_app_config(); - let log = cfg.logs.drain().next().unwrap().1; - cfg.logs.insert(String::new(), log); - let err = cfg.validate().unwrap_err(); + fn rejects_missing_or_disabled_identity_sections() { + assert!(config(Mode::Witness, false, false).validate().is_err()); + assert!(config(Mode::Witness, true, true).validate().is_err()); + assert!(config(Mode::Mirror, false, false).validate().is_err()); + assert!(config(Mode::Mirror, true, true).validate().is_err()); assert!( - err.contains("log_key_name") && err.contains("\"\""), - "unexpected error: {err}", + config(Mode::WitnessAndMirror, true, false) + .validate() + .is_err() ); } #[test] - fn validate_rejects_log_key_name_with_plus() { - let mut cfg = good_app_config(); - let log = cfg.logs.drain().next().unwrap().1; - cfg.logs.insert("example.com/with+plus".to_owned(), log); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("log_key_name"), "unexpected error: {err}"); + fn rejects_unknown_mode() { + let error = serde_json::from_str::(r#""witness-mirror""#).unwrap_err(); + assert!(error.to_string().contains("unknown variant")); } #[test] - fn deserialize_rejects_duplicate_log_key_name() { - let json = r#"{ - "mirror_name": "mirror.example/m", - "submission_prefix": "https://mirror.example/", - "logs": { - "example.com/log1": {"log_public_keys": [], "min_log_number": 1, "max_log_number": 1}, - "example.com/log1": {"log_public_keys": [], "min_log_number": 2, "max_log_number": 2} - } - }"#; - let err = serde_json::from_str::(json) - .unwrap_err() - .to_string(); - assert!( - err.contains("duplicate log_key_name"), - "unexpected error: {err}", + fn mode_serde_spelling_is_stable() { + assert_eq!( + serde_json::to_string(&Mode::Witness).unwrap(), + r#""witness""# ); - } - - #[test] - fn deserialize_accepts_distinct_log_key_names() { - let json = r#"{ - "mirror_name": "mirror.example/m", - "submission_prefix": "https://mirror.example/", - "logs": { - "example.com/log1": {"log_public_keys": [], "min_log_number": 1, "max_log_number": 1}, - "example.com/log2": {"log_public_keys": [], "min_log_number": 2, "max_log_number": 2} - } - }"#; - let cfg = serde_json::from_str::(json).expect("distinct keys deserialize"); - assert_eq!(cfg.logs.len(), 2); - } - - #[test] - fn validate_accepts_log_number_window() { - let cfg = with_log(|log| { - log.min_log_number = 40; - log.max_log_number = 45; - }); - cfg.validate() - .expect("a valid log-number window is accepted"); - } - - #[test] - fn commit_packages_defaults_to_32() { - let mut cfg = good_app_config(); - assert_eq!(cfg.commit_packages(), 32); - cfg.commit_packages = Some(8); - assert_eq!(cfg.commit_packages(), 8); - } - - #[test] - fn max_chunk_bytes_defaults_to_16_mib() { - let mut cfg = good_app_config(); - assert_eq!(cfg.max_chunk_bytes(), 16 * 1024 * 1024); - cfg.max_chunk_bytes = Some(1024); - assert_eq!(cfg.max_chunk_bytes(), 1024); - } - - #[test] - fn validate_rejects_inverted_window() { - let cfg = with_log(|log| { - log.min_log_number = 45; - log.max_log_number = 40; - }); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("must be <="), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_log_number_zero() { - // MTC log numbers start at 1; log number 0 does not exist. - let cfg = with_log(|log| { - log.min_log_number = 0; - log.max_log_number = 5; - }); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("start at 1"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_log_number_above_maximum() { - let cfg = with_log(|log| { - log.min_log_number = 1; - log.max_log_number = MAX_MTC_LOG_NUMBER + 1; - }); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("exceeds the maximum MTC log number"), - "unexpected error: {err}", + assert_eq!(serde_json::to_string(&Mode::Mirror).unwrap(), r#""mirror""#); + assert_eq!( + serde_json::to_string(&Mode::WitnessAndMirror).unwrap(), + r#""witness-and-mirror""# ); } - #[test] - fn validate_accepts_window_at_the_cap() { - // Exactly MAX_LOG_NUMBER_WINDOW log numbers ([1, CAP]) is allowed. - let cfg = with_log(|log| { - log.min_log_number = 1; - log.max_log_number = MAX_LOG_NUMBER_WINDOW; - }); - cfg.validate() - .expect("a window spanning exactly the cap is accepted"); - } - - #[test] - fn validate_rejects_oversized_window() { - // One past the cap ([1, CAP+1], i.e. CAP+1 numbers) is rejected. - let cfg = with_log(|log| { - log.min_log_number = 1; - log.max_log_number = MAX_LOG_NUMBER_WINDOW + 1; - }); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("spans more than"), "unexpected error: {err}"); + fn ml_dsa_spki(seed: u8) -> Vec { + SigningKey::::from_seed(&ml_dsa::B32::from([seed; 32])) + .verifying_key() + .to_public_key_der() + .unwrap() + .to_vec() } #[test] - fn validate_rejects_origin_exceeding_key_name_limit() { - // A log_key_name that is a valid KeyName on its own (<= 255 bytes), - // but whose ".0." origin pushes past the limit. The - // window stays within the span cap while keeping a 5-digit max. - let cfg = AppConfig { - logging_level: None, - mirror_name: "mirror.example/m".to_owned(), - description: None, - submission_prefix: "https://mirror.example/".to_owned(), - monitoring_prefix: None, - clean_interval_secs: None, - commit_packages: None, - max_chunk_bytes: None, - logs: HashMap::from([( - "a".repeat(250), - LogParams { - description: None, - log_public_keys: vec![spki_for(1)], - min_log_number: 64512, - max_log_number: 65535, - }, - )]), - }; - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("longest origin") && err.contains("limit"), - "unexpected error: {err}", - ); + fn accepts_ml_dsa_spki_and_mixed_algorithms() { + let mut config = config(Mode::Witness, true, false); + config + .logs + .get_mut("log.example") + .unwrap() + .checkpoint_signers + .push(CheckpointSigner { + name: "log.example/post-quantum".to_owned(), + algorithm: CheckpointAlgorithm::SubtreeV1, + public_key: ml_dsa_spki(9), + }); + config.validate().unwrap(); } #[test] - fn origins_with_window_expands_to_mtc_log_ids() { - let log = LogParams { - description: None, - log_public_keys: vec![spki_for(1)], - min_log_number: 40, - max_log_number: 42, - }; - assert_eq!( - log.origins("oid/1.3.6.1.4.1.32473.2"), - vec![ - "oid/1.3.6.1.4.1.32473.2.0.40", - "oid/1.3.6.1.4.1.32473.2.0.41", - "oid/1.3.6.1.4.1.32473.2.0.42", - ], - ); + fn rejects_algorithm_key_mismatch() { + let mut wrong_ml = config(Mode::Witness, true, false); + let signer = &mut wrong_ml + .logs + .get_mut("log.example") + .unwrap() + .checkpoint_signers[0]; + signer.algorithm = CheckpointAlgorithm::SubtreeV1; + assert!(wrong_ml.validate().unwrap_err().contains("ML-DSA-44 SPKI")); + + let mut wrong_ed = config(Mode::Witness, true, false); + let signer = &mut wrong_ed + .logs + .get_mut("log.example") + .unwrap() + .checkpoint_signers[0]; + signer.public_key = ml_dsa_spki(10); + assert!(wrong_ed.validate().unwrap_err().contains("Ed25519 SPKI")); } #[test] - fn validate_rejects_invalid_spki() { - let cfg = with_log(|log| log.log_public_keys = vec![b"not-der".to_vec()]); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("log_public_keys[0]") && err.contains("ML-DSA-44 SPKI"), - "unexpected error: {err}", - ); + fn rejects_malformed_spki() { + let mut config = config(Mode::Witness, true, false); + config + .logs + .get_mut("log.example") + .unwrap() + .checkpoint_signers[0] + .public_key = b"not DER".to_vec(); + assert!(config.validate().is_err()); } #[test] - fn validate_accepts_multiple_distinct_keys() { - let cfg = with_log(|log| log.log_public_keys = vec![spki_for(1), spki_for(2)]); - cfg.validate() - .expect("two distinct ML-DSA-44 keys are valid"); + fn rejects_duplicate_signer_id() { + let mut config = config(Mode::Witness, true, false); + let log = config.logs.get_mut("log.example").unwrap(); + log.checkpoint_signers.push(CheckpointSigner { + name: log.checkpoint_signers[0].name.clone(), + algorithm: log.checkpoint_signers[0].algorithm, + public_key: log.checkpoint_signers[0].public_key.clone(), + }); + assert!(config.validate().unwrap_err().contains("duplicates")); } #[test] - fn validate_rejects_duplicate_keys_within_log() { - let cfg = with_log(|log| log.log_public_keys = vec![spki_for(1), spki_for(1)]); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("log_public_keys[1]") && err.contains("(name, key_id)"), - "unexpected error: {err}", - ); + fn combined_mode_requires_distinct_identity_names() { + let mut config = config(Mode::WitnessAndMirror, true, true); + config.mirror.as_mut().unwrap().name = config.witness.as_ref().unwrap().name.clone(); + assert!(config.validate().unwrap_err().contains("must be distinct")); } #[test] - fn validate_includes_log_key_name_in_per_log_errors() { - let cfg = with_log(|log| log.log_public_keys = vec![b"junk".to_vec()]); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("example.com/log1"), - "error should reference the failing log_key_name: {err}", - ); + fn standalone_config_fixtures_validate() { + for fixture in [ + include_str!("../../config.witness.json"), + include_str!("../../config.mirror.json"), + ] { + serde_json::from_str::(fixture) + .unwrap() + .validate() + .unwrap(); + } } } diff --git a/crates/mirror_worker/src/add_entries.rs b/crates/mirror_worker/src/add_entries.rs index ba735fd0..43b935d8 100644 --- a/crates/mirror_worker/src/add_entries.rs +++ b/crates/mirror_worker/src/add_entries.rs @@ -108,7 +108,7 @@ pub(crate) async fn add_entries( // add-checkpoint (tlog-mirror "Processing"). Empty pending signed-note bytes // reliably mean pristine state; once accepted, the DO retains the // latest pending forever. - if snapshot.pending.signed_note_bytes.is_empty() { + if snapshot.pending.is_none() { log::info!( "add-entries: no pending checkpoint for origin {:?}; returning 422", header.log_origin, @@ -126,8 +126,11 @@ pub(crate) async fn add_entries( (origin={origin:?}, upload_end={ue}, pending_size={ps}, committed_size={cs})", origin = header.log_origin, ue = header.upload_end, - ps = snapshot.pending.size, - cs = snapshot.committed.size, + ps = snapshot.pending.as_ref().map_or(0, |pending| pending.size), + cs = snapshot + .committed + .as_ref() + .map_or(0, |committed| committed.size), ); return Ok(mirror_info_409(&env, &snapshot, &header.log_origin)); } @@ -393,12 +396,14 @@ where { // config.schema.json caps commit_packages (max 1024), enforced by the // build script, so this always fits usize; the fallback is unreachable. - let commit_packages = usize::try_from(crate::CONFIG.commit_packages()).unwrap_or(usize::MAX); + let commit_packages = + usize::try_from(crate::CONFIG.mirror_config().commit_packages()).unwrap_or(usize::MAX); // Byte ceiling on buffered entries; flush early when reached so peak // memory is bounded regardless of package sizes. Saturating to // usize::MAX on a 32-bit target just means "never trip the byte cap", // leaving the package-count cap in force. - let max_chunk_bytes = usize::try_from(crate::CONFIG.max_chunk_bytes()).unwrap_or(usize::MAX); + let max_chunk_bytes = + usize::try_from(crate::CONFIG.mirror_config().max_chunk_bytes()).unwrap_or(usize::MAX); // Entries below the request-start frontier are already persisted; new // persistence begins at this fixed boundary. @@ -585,24 +590,14 @@ async fn cosign_and_serve( let cosig_body = tlog_witness::serialize_add_checkpoint_response(std::slice::from_ref(¬e_sig)); - // The served checkpoint is the log's signed note with the mirror's - // cosignature appended. Build it once so both the early-return and the - // `/commit` paths use the same bytes. + // The served object includes the mirror cosignature returned below. let checkpoint_obj = [target.signed_note_bytes.as_slice(), &cosig_body].concat(); - // When the upload only reaches the mirror checkpoint's current size, - // the checkpoint at that size is already committed and served. There - // is nothing to advance, and re-committing would redundantly rewrite - // R2 and append a duplicate cosignature line to the served note, so - // return a fresh cosignature without dispatching `/commit`. (Committed - // is monotonic, so a stale-low snapshot only skips this optimization, - // never the other way.) - // - // Note: this also means a previously-failed R2 checkpoint write won't - // self-heal at the same size; it heals on the next larger commit. That - // is acceptable because the durable committed state advanced before the - // R2 write, and duplicate cosignatures are worse than a lagging object. - if header.upload_end <= snapshot.committed.size { + if snapshot + .committed + .as_ref() + .is_some_and(|committed| header.upload_end <= committed.size) + { return Ok(( StatusCode::OK, [(CONTENT_TYPE, "text/plain; charset=utf-8")], @@ -611,8 +606,6 @@ async fn cosign_and_serve( .into_response()); } - // The DO writes the cosigned checkpoint to R2 while advancing the - // durable checkpoint under its commit lock. let committed = dispatch_commit( env, &header.log_origin, @@ -624,11 +617,6 @@ async fn cosign_and_serve( ) .await?; - debug_assert_eq!( - committed.signed_note_bytes, checkpoint_obj, - "DO returned checkpoint bytes that do not match the cosigned note we sent" - ); - if committed.size != header.upload_end { // The DO refused to rewind: a concurrent commit already advanced // the mirror checkpoint past upload_end, so ours was skipped. @@ -653,6 +641,11 @@ async fn cosign_and_serve( return Ok(mirror_info_409(env, &fresh_snapshot, &header.log_origin)); } + debug_assert_eq!( + committed.signed_note_bytes, checkpoint_obj, + "DO returned different bytes for the requested checkpoint" + ); + Ok(( StatusCode::OK, [(CONTENT_TYPE, "text/plain; charset=utf-8")], @@ -1014,27 +1007,36 @@ fn resolve_target_pending( snapshot: &MirrorStateSnapshot, verifiers: &signed_note::VerifierList, ) -> std::result::Result { - if header.upload_end == snapshot.pending.size { + let Some(pending) = snapshot.pending.as_ref() else { + return Err("no pending checkpoint"); + }; + if header.upload_end == pending.size { // Per spec: `upload_end` must be at-or-above the mirror's // committed checkpoint. The DO state guarantees pending >= // committed (the `/commit` RPC enforces it on the write // side), so checking against pending is sufficient. - if header.upload_end < snapshot.committed.size { + if snapshot + .committed + .as_ref() + .is_some_and(|committed| header.upload_end < committed.size) + { return Err("upload_end below committed checkpoint"); } - return Ok(snapshot.pending.clone()); + return Ok(pending.clone()); } // Spec: the mirror MUST also accept `upload_end` equal to the mirror // checkpoint's tree size, whatever the ticket says. Everything up to - // it is already committed and served, so the committed checkpoint is - // the target and nothing new is persisted; `cosign_and_serve` returns - // a fresh cosignature without re-advancing. - if snapshot.committed.size > 0 && header.upload_end == snapshot.committed.size { + // it is already committed and served, so nothing new is persisted. + if let Some(committed) = snapshot + .committed + .as_ref() + .filter(|committed| header.upload_end == committed.size) + { return Ok(PendingCheckpoint { - size: snapshot.committed.size, - hash: snapshot.committed.hash, - signed_note_bytes: snapshot.committed.signed_note_bytes.clone(), + size: committed.size, + hash: committed.hash, + signed_note_bytes: committed.signed_note_bytes.clone(), }); } @@ -1085,7 +1087,11 @@ fn resolve_target_pending( if cp_text.size() != header.upload_end { return Err("ticket-bound checkpoint size != upload_end"); } - if cp_text.size() < snapshot.committed.size { + if snapshot + .committed + .as_ref() + .is_some_and(|committed| cp_text.size() < committed.size) + { return Err("ticket-bound checkpoint size < committed checkpoint size"); } Ok(PendingCheckpoint { @@ -1204,21 +1210,21 @@ fn mirror_info_response( status: StatusCode, next_entry: u64, ) -> axum::response::Response { - let ticket = if snapshot.pending.signed_note_bytes.is_empty() { - Vec::new() - } else { + let ticket = if let Some(pending) = &snapshot.pending { match load_ticket_sealer(env) { // Bind the log origin as associated data so the ticket can // only be reopened for the same log. - Ok(m) => m.seal(&snapshot.pending.signed_note_bytes, origin.as_bytes()), + Ok(m) => m.seal(&pending.signed_note_bytes, origin.as_bytes()), Err(e) => { log::error!("add-entries: cannot seal ticket: {e:?}"); Vec::new() } } + } else { + Vec::new() }; let info = MirrorInfo { - tree_size: snapshot.pending.size, + tree_size: snapshot.pending.as_ref().map_or(0, |pending| pending.size), next_entry, ticket, }; diff --git a/crates/mirror_worker/src/cleaner_do.rs b/crates/mirror_worker/src/cleaner_do.rs index a1d80931..422d6e1e 100644 --- a/crates/mirror_worker/src/cleaner_do.rs +++ b/crates/mirror_worker/src/cleaner_do.rs @@ -201,7 +201,9 @@ impl MirrorCleaner { } // Reschedule first so the loop continues even if cleaning fails. self.storage() - .set_alarm(Duration::from_secs(CONFIG.clean_interval_secs())) + .set_alarm(Duration::from_secs( + CONFIG.mirror_config().clean_interval_secs(), + )) .await?; if let Err(e) = self.clean(served).await { log::warn!("mirror cleaner [{}]: clean failed: {e}", served.origin); @@ -226,7 +228,9 @@ impl MirrorCleaner { async fn initialize(&self) -> Result<()> { // OK if an alarm is already set; this guarantees one exists. self.storage() - .set_alarm(Duration::from_secs(CONFIG.clean_interval_secs())) + .set_alarm(Duration::from_secs( + CONFIG.mirror_config().clean_interval_secs(), + )) .await?; if let Some(cleaned) = self.storage().get::(CLEANED_SIZE_KEY).await? { *self.cleaned_size.borrow_mut() = cleaned; @@ -356,7 +360,7 @@ impl MirrorCleaner { return Err(format!("state DO /get-state returned {}", resp.status_code()).into()); } let snapshot: MirrorStateSnapshot = resp.json().await?; - Ok(snapshot.committed.size) + Ok(snapshot.committed.map_or(0, |committed| committed.size)) } /// Add `n` to the subrequest tally, erroring if it would exceed the diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 982206c0..7f83835f 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -1,34 +1,10 @@ // Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause -//! HTTP entry point + handlers for the mirror worker. -//! -//! Routes: -//! -//! - `POST /add-checkpoint`: [c2sp.org/tlog-mirror#add-checkpoint][add-cp]. -//! Updates the pending checkpoint for an origin. Wire format and -//! response semantics are identical to the witness's `add-checkpoint`, -//! with one spec-mandated exception: the mirror MUST NOT cosign in -//! this process. Successful responses have an empty body and HTTP -//! status 200. -//! - `POST /sign-subtree`: [c2sp.org/tlog-witness#sign-subtree][signsub]. -//! Countersigns a subtree of a checkpoint this mirror has previously -//! cosigned. OPTIONAL in the spec but always available here, since the -//! mirror's cosigner is ML-DSA-44 / `subtree/v1`. Success returns the -//! `subtree/v1` cosignature line(s) as `text/plain`. -//! - `GET /metadata`: mirror identity, ML-DSA-44 SPKI, -//! `mirror_algorithm`, prefixes, and the per-log configuration. -//! - `GET /`: root status string. -//! -//! The mirror's per-origin persistent state lives in a [`MirrorState`] -//! Durable Object; see [`crate::mirror_state_do`] for details. -//! -//! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint -//! [signsub]: https://c2sp.org/tlog-witness#sign-subtree -//! [`MirrorState`]: crate::mirror_state_do +//! HTTP entry point and protocol handlers. use crate::{ - CONFIG, load_mirror_public_key_der, load_mirror_signer, log_verifiers, + CONFIG, IdentitySigner, enabled_roles, load_mirror_signer, load_witness_signer, log_verifiers, mirror_state_do::{PendingCheckpoint, UpdatePendingRequest, state_stub}, }; use axum::{ @@ -39,103 +15,103 @@ use axum::{ response::IntoResponse, routing::{get, post}, }; +use generic_log_worker::{ + frontend::request_metrics, + init_logging, + obs::{Wshim, metrics}, + util::now_millis, +}; use serde::Serialize; use serde_with::{base64::Base64 as Base64As, serde_as}; -use signed_note::{NoteError, NoteVerifier, VerifierList}; -use tlog_checkpoint::{CheckpointSigner as _, CheckpointText}; -use tlog_core::{Subtree, verify_subtree_consistency_proof}; +use signed_note::{NoteSignature, VerifierList}; +use tlog_checkpoint::CheckpointSigner as _; use tlog_witness::{ - AddCheckpointRequest, CONTENT_TYPE_TLOG_SIZE, MAX_REQUEST_BODY_SIZE, SignSubtreeRequest, - parse_add_checkpoint_request, parse_sign_subtree_request, serialize_sign_subtree_response, + CONTENT_TYPE_TLOG_SIZE, MAX_REQUEST_BODY_SIZE, TrustedSignatureError, + serialize_add_checkpoint_response, serialize_sign_subtree_response, + validate_add_checkpoint_request, validate_sign_subtree_proof, validate_sign_subtree_request, + verify_trusted_checkpoint_signature, }; use tower_service::Service as _; #[allow(clippy::wildcard_imports)] use worker::*; -/// Entry point: initialize logging. +const MAX_ADD_CHECKPOINT_BODY_SIZE: usize = 1_024 * 1_024 + 16 * 1_024; + #[event(start)] fn start() { - let level = match CONFIG.logging_level.as_deref().unwrap_or("info") { - "trace" => log::Level::Trace, - "debug" => log::Level::Debug, - "warn" => log::Level::Warn, - "error" => log::Level::Error, - _ => log::Level::Info, - }; - console_error_panic_hook::set_once(); - let _ = console_log::init_with_level(level); + init_logging(CONFIG.logging_level.as_deref()); } -/// Middleware that adds `Accept-Encoding: gzip` to every response. -/// -/// [c2sp.org/tlog-mirror][spec] says mirrors SHOULD advertise supported -/// compression algorithms in responses so clients can compress future -/// `add-entries` request bodies. -/// -/// [spec]: https://c2sp.org/tlog-mirror#add-entries async fn add_accept_encoding( mut response: axum::http::Response, ) -> axum::http::Response { - response - .headers_mut() - .insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")); + if enabled_roles(CONFIG.mode).mirror() { + response + .headers_mut() + .insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")); + } response } -/// Top-level `#[event(fetch)]` handler. Delegates to the axum router; -/// unmatched routes return 404. #[event(fetch, respond_with_errors)] async fn fetch( req: HttpRequest, env: Env, - _ctx: Context, + ctx: Context, ) -> Result> { crate::init_sentry(&env); - // Wrap the router in the sentry catch/flush guard so a panic in any - // handler is captured and shipped before the WASM isolate is torn - // down. `Router`'s `Service::Error` is `Infallible`; the `?` below - // performs the trivial conversion into `worker::Error`. - // - // `/add-entries` streams a potentially large (optionally gzip) body, - // so it uses the raw `Request` extractor with no `DefaultBodyLimit`; - // the buffered endpoints cap their bodies via the layer. + crate::validate_identity_keys(&env)?; + let wshim = Wshim::from_env(&env); + let registry = metrics::registry(); let response = generic_log_worker::obs::sentry::catch_unwind_and_flush(async { - Router::new() + let mut router = Router::new() .route( "/add-checkpoint", post(add_checkpoint).layer(DefaultBodyLimit::max(MAX_ADD_CHECKPOINT_BODY_SIZE)), ) - .route("/add-entries", post(crate::add_entries::add_entries)) .route( "/sign-subtree", post(sign_subtree).layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_SIZE)), ) .route("/metadata", get(metadata)) - .route("/", get(root)) + .route("/", get(root)); + if enabled_roles(CONFIG.mode).mirror() { + router = router.route("/add-entries", post(crate::add_entries::add_entries)); + } + router .layer(axum::middleware::map_response(add_accept_encoding)) + .layer(axum::middleware::from_fn_with_state( + (env.clone(), metrics::FrontendWorkerMetrics::new(®istry)), + request_metrics, + )) .with_state(env) .call(req) .await }) .await?; generic_log_worker::obs::sentry::flush().await; + if let Ok(wshim) = wshim { + ctx.wait_until(async move { + wshim.flush(&generic_log_worker::obs::logs::LOGGER).await; + wshim.flush(®istry).await; + }); + } Ok(response) } -/// `GET /` -- mirror identity string. Convenience only; not part of the -/// spec. async fn root() -> impl IntoResponse { + let roles = match CONFIG.mode { + config::Mode::Witness => "witness", + config::Mode::Mirror => "mirror", + config::Mode::WitnessAndMirror => "witness and mirror", + }; ( StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - format!("{} - c2sp.org/tlog-mirror mirror\n", CONFIG.mirror_name), + format!("mirror worker: {roles}\n"), ) } -/// Error type for the mirror's axum handlers, mapped to an HTTP status by -/// [`IntoResponse`]. Success and special-body responses (the 200 -/// cosignature, the 409/202 `text/x.tlog.mirror-info` and `text/x.tlog.size` -/// bodies) are built as axum responses directly, not via this enum. pub(crate) enum AppError { InternalServerError(String), BadRequest(String), @@ -143,10 +119,9 @@ pub(crate) enum AppError { UnprocessableEntity(String), UnknownLogOrigin, NoValidSignatures, - ReferenceCheckpointNotCosignedByThisMirror, + ReferenceCheckpointNotCosigned, } -/// Result type for the mirror's axum handlers. pub(crate) type ApiResult = std::result::Result; impl From for AppError { @@ -158,9 +133,10 @@ impl From for AppError { impl From for AppError { fn from(err: crate::body::BodyError) -> Self { match err { - // Malformed/truncated gzip is a client fault. crate::body::BodyError::Decode(msg) => Self::BadRequest(msg), - crate::body::BodyError::Transport(e) => Self::InternalServerError(e.to_string()), + crate::body::BodyError::Transport(error) => { + Self::InternalServerError(error.to_string()) + } } } } @@ -168,394 +144,308 @@ impl From for AppError { impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { - AppError::InternalServerError(error) => { + Self::InternalServerError(error) => { log::error!("unhandled error: {error}"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } - AppError::BadRequest(e) => { - (StatusCode::BAD_REQUEST, format!("Bad request: {e}")).into_response() + Self::BadRequest(error) => { + (StatusCode::BAD_REQUEST, format!("Bad request: {error}")).into_response() } - AppError::UnsupportedMediaType(e) => { - (StatusCode::UNSUPPORTED_MEDIA_TYPE, e).into_response() + Self::UnsupportedMediaType(error) => { + (StatusCode::UNSUPPORTED_MEDIA_TYPE, error).into_response() } - AppError::UnprocessableEntity(e) => ( + Self::UnprocessableEntity(error) => ( StatusCode::UNPROCESSABLE_ENTITY, - format!("Unprocessable Entity: {e}"), + format!("Unprocessable Entity: {error}"), ) .into_response(), - AppError::UnknownLogOrigin => { - (StatusCode::NOT_FOUND, "Unknown log origin").into_response() - } - AppError::NoValidSignatures => ( + Self::UnknownLogOrigin => (StatusCode::NOT_FOUND, "Unknown log origin").into_response(), + Self::NoValidSignatures => ( StatusCode::FORBIDDEN, "No valid signatures from trusted log keys", ) .into_response(), - AppError::ReferenceCheckpointNotCosignedByThisMirror => ( + Self::ReferenceCheckpointNotCosigned => ( StatusCode::FORBIDDEN, - "Reference checkpoint not cosigned by this mirror", + "Reference checkpoint not cosigned by an enabled identity", ) .into_response(), } } } -/// Response body for the `/metadata` endpoint: the mirror's identity, -/// URL prefixes, and per-log configuration. The `mirror_algorithm` field -/// tells clients whether to expect `cosignature/v1` or `subtree/v1` -/// cosignatures. #[serde_as] #[derive(Serialize)] struct MetadataResponse<'a> { - mirror_name: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option<&'a str>, - /// DER-encoded `SubjectPublicKeyInfo` for the mirror's verifying - /// key. Algorithm is identified by `mirror_algorithm`. - #[serde_as(as = "Base64As")] - mirror_public_key: &'a [u8], - /// Always `"subtree/v1"` (ML-DSA-44); the mirror's cosigner is an MTC - /// cosigner. See - /// [c2sp.org/tlog-cosignature](https://c2sp.org/tlog-cosignature). - mirror_algorithm: &'a str, + mode: &'a str, submission_prefix: &'a str, monitoring_prefix: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + witness: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + mirror: Option>, logs: Vec>, } #[serde_as] +#[derive(Serialize)] +struct IdentityMetadata<'a> { + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, + #[serde_as(as = "Base64As")] + public_key: &'a [u8], + algorithm: &'a str, + supports_sign_subtree: bool, +} + #[derive(Serialize)] struct LogMetadata<'a> { #[serde(skip_serializing_if = "Option::is_none")] description: Option<&'a str>, - /// The note-signature name the log's trusted checkpoints carry (for - /// MTC, the CA cosigner ID). The concrete origins served for this - /// key are `.0.` for each `N` in - /// `[min_log_number, max_log_number]`, each addressable at - /// `//`. - log_key_name: &'a str, - /// Lowest MTC log number served for this key (inclusive). For a plain - /// (non-MTC) log this equals `max_log_number`. - min_log_number: u64, - /// Highest MTC log number served for this key (inclusive). - max_log_number: u64, - /// DER-encoded `SubjectPublicKeyInfo` blobs for the log's trusted - /// keys. - #[serde_as(as = "Vec")] - log_public_keys: Vec<&'a [u8]>, + origin: &'a str, + checkpoint_signers: Vec>, +} + +#[serde_as] +#[derive(Serialize)] +struct CheckpointSignerMetadata<'a> { + name: &'a str, + algorithm: &'a str, + #[serde_as(as = "Base64As")] + public_key: &'a [u8], } -/// Build the per-log metadata entries, one per configured key, sorted by -/// `log_key_name` so the response is deterministic regardless of -/// `HashMap` iteration order (it may be diffed or hashed by monitors). -/// -/// MTC log-number windows are published as `[min_log_number, -/// max_log_number]` bounds; a client derives the concrete origins as -/// `.0.`. -fn metadata_logs( - logs: &std::collections::HashMap, -) -> Vec> { - let mut out: Vec = logs +fn metadata_logs() -> Vec> { + let mut logs = CONFIG + .logs .iter() - .map(|(log_key_name, p)| LogMetadata { - description: p.description.as_deref(), - log_key_name, - min_log_number: p.min_log_number, - max_log_number: p.max_log_number, - log_public_keys: p.log_public_keys.iter().map(Vec::as_slice).collect(), + .map(|(origin, log)| LogMetadata { + description: log.description.as_deref(), + origin, + checkpoint_signers: log + .checkpoint_signers + .iter() + .map(|signer| CheckpointSignerMetadata { + name: &signer.name, + algorithm: signer.algorithm.as_str(), + public_key: &signer.public_key, + }) + .collect(), }) - .collect(); - out.sort_by(|a, b| a.log_key_name.cmp(b.log_key_name)); - out + .collect::>(); + logs.sort_by(|a, b| a.origin.cmp(b.origin)); + logs } -/// `GET /metadata` handler. #[worker::send] async fn metadata(State(env): State) -> ApiResult { - let mirror_public_key = load_mirror_public_key_der(&env)?; - let mirror_algorithm = load_mirror_signer(&env)?.algorithm(); - let logs = metadata_logs(&CONFIG.logs); - let body = MetadataResponse { - mirror_name: &CONFIG.mirror_name, - description: CONFIG.description.as_deref(), - mirror_public_key, - mirror_algorithm, - submission_prefix: &CONFIG.submission_prefix, - monitoring_prefix: CONFIG - .monitoring_prefix - .as_deref() - .unwrap_or(&CONFIG.submission_prefix), - logs, + let roles = enabled_roles(CONFIG.mode); + let witness = if roles.witness() { + let identity = CONFIG + .witness + .as_ref() + .expect("validated witness mode has witness config"); + let signer = load_witness_signer(&env)?; + Some(identity_metadata(identity, signer)) + } else { + None }; - Ok((StatusCode::OK, Json(body))) + let mirror = if roles.mirror() { + let identity = CONFIG + .mirror + .as_ref() + .expect("validated mirror mode has mirror config"); + let signer = load_mirror_signer(&env)?; + Some(IdentityMetadata { + name: &identity.name, + description: identity.description.as_deref(), + public_key: signer.public_key_der(), + algorithm: signer.algorithm(), + supports_sign_subtree: signer.supports_sign_subtree(), + }) + } else { + None + }; + Ok(( + StatusCode::OK, + Json(MetadataResponse { + mode: CONFIG.mode.as_str(), + submission_prefix: &CONFIG.submission_prefix, + monitoring_prefix: CONFIG + .monitoring_prefix + .as_deref() + .unwrap_or(&CONFIG.submission_prefix), + witness, + mirror, + logs: metadata_logs(), + }), + )) +} + +fn identity_metadata<'a>( + identity: &'a config::IdentityConfig, + signer: &'a IdentitySigner, +) -> IdentityMetadata<'a> { + IdentityMetadata { + name: &identity.name, + description: identity.description.as_deref(), + public_key: signer.public_key_der(), + algorithm: signer.algorithm(), + supports_sign_subtree: signer.supports_sign_subtree(), + } } -/// Handle `POST /add-checkpoint`, updating the pending checkpoint for a -/// log. Handled like a witness's `add-checkpoint`, but the mirror does -/// not cosign and returns an empty body ([spec][add-cp]). -/// -/// After validating the request (parse, log lookup, signature, and size -/// range), the check-proof-and-update against persisted pending state is -/// delegated to the per-origin [`MirrorState`] DO so it happens -/// atomically; see [`dispatch_update_pending`] for the status-code -/// mapping. -/// -/// [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint -/// [`MirrorState`]: crate::mirror_state_do #[worker::send] async fn add_checkpoint( State(env): State, body: Bytes, ) -> ApiResult { - // The body size is capped by the `DefaultBodyLimit` layer on this - // route, which rejects oversized payloads (413) before they are fully - // buffered, so by here `body` is already within bounds. - let AddCheckpointRequest { - old_size, - consistency_proof, - checkpoint, - } = match parse_add_checkpoint_request(&body) { - Ok(r) => r, - Err(e) => { - log::warn!("add-checkpoint: malformed request: {e}"); - return Err(AppError::BadRequest(e.to_string())); - } - }; - - // Parse the checkpoint and look up the log by its origin. Using the - // validated `CheckpointText` origin (not a looser re-parse) keeps the - // lookup consistent with the size/hash used below. - let cp_text = match CheckpointText::from_bytes(checkpoint.text()) { - Ok(t) => t, - Err(e) => { - log::warn!("add-checkpoint: malformed checkpoint text: {e:?}"); - return Err(AppError::BadRequest(e.to_string())); - } - }; - let origin = cp_text.origin(); + let validated = validate_add_checkpoint_request(&body).map_err(|error| { + log::warn!("add-checkpoint: malformed request: {error}"); + AppError::BadRequest(error.to_string()) + })?; + let (old_size, consistency_proof, checkpoint, checkpoint_text) = validated.into_parts(); + let origin = checkpoint_text.origin(); let Some(verifiers) = log_verifiers(origin) else { return Err(AppError::UnknownLogOrigin); }; + verify_source_checkpoint(&checkpoint, &verifiers, "add-checkpoint")?; + + let witness_signature = if enabled_roles(CONFIG.mode).witness() { + Some( + load_witness_signer(&env)? + .as_checkpoint_signer() + .sign(now_millis(), &checkpoint_text) + .map_err(|error| Error::from(format!("witness signing: {error:?}")))?, + ) + } else { + None + }; - // Accept the checkpoint if at least one trusted log key signed it; - // unknown-key signatures are ignored (witness semantics). No valid - // trusted signature -> 403; a malformed signature line -> 400. - if let Err(e) = checkpoint.verify(&verifiers) { - match e { - NoteError::UnverifiedNote | NoteError::InvalidSignature { .. } => { - log::info!("add-checkpoint: rejecting note: {e:?}"); - return Err(AppError::NoValidSignatures); - } - _ => { - log::warn!("add-checkpoint: verify failed: {e:?}"); - return Err(AppError::BadRequest(e.to_string())); - } - } - } - - if old_size > cp_text.size() { - return Err(AppError::BadRequest(format!( - "old_size {old_size} > checkpoint size {}", - cp_text.size() - ))); - } - - // Atomic check-proof-and-update in the per-origin DO. Pass the whole - // signed note (via `to_bytes()`) so the DO retains the log's - // signature alongside size+hash, per spec. let update = UpdatePendingRequest { old_size, - new_size: cp_text.size(), - new_hash: *cp_text.hash(), + new_size: checkpoint_text.size(), + new_hash: *checkpoint_text.hash(), proof: consistency_proof, signed_note_bytes: checkpoint.to_bytes(), }; - if let Some(resp) = dispatch_update_pending(&env, origin, &update).await? { - return Ok(resp); + if let Some(response) = dispatch_update_pending(&env, origin, &update).await? { + return Ok(response); } - // Empty body; the mirror cosigner MUST NOT sign here. - Ok(StatusCode::OK.into_response()) + let Some(signature) = witness_signature else { + return Ok(StatusCode::OK.into_response()); + }; + Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + serialize_add_checkpoint_response(std::slice::from_ref(&signature)), + ) + .into_response()) } -/// `POST /sign-subtree` handler. -/// -/// OPTIONAL endpoint per [c2sp.org/tlog-witness#sign-subtree][spec], which -/// the mirror inherits (the same cosigner emitted by `add-entries` signs -/// the subtree). The mirror's cosigner is always ML-DSA-44 / `subtree/v1`, -/// so this endpoint is always available. -/// -/// Verification of the reference checkpoint is stateless: the submitted -/// checkpoint MUST carry one of the mirror's own past `subtree/v1` -/// cosignatures (the whole-tree cosignature it emits on a successful -/// `add-entries`). This is safe for the same reason as in the witness: the -/// mirror only cosigns a checkpoint after fully ingesting and verifying -/// every entry up to that size, so a checkpoint bearing the mirror's -/// cosignature proves the mirror holds that tree. `/sign-subtree` therefore -/// inherits the trust window of `/add-entries`. -/// -/// [spec]: https://c2sp.org/tlog-witness#sign-subtree -#[allow(clippy::too_many_lines)] -#[worker::send] -async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult { - let subtree_signer = load_mirror_signer(&env)?.as_subtree_signer(); - - let SignSubtreeRequest { - subtree_start, - subtree_end, - subtree_hash, - subtree_cosignatures: _, - consistency_proof, - checkpoint, - } = match parse_sign_subtree_request(&body) { - Ok(r) => r, - Err(e) => { - log::warn!("sign-subtree: malformed request: {e}"); - return Err(AppError::BadRequest(e.to_string())); +fn verify_source_checkpoint( + checkpoint: &signed_note::Note, + verifiers: &VerifierList, + handler: &str, +) -> ApiResult<()> { + verify_trusted_checkpoint_signature(checkpoint, verifiers).map_err(|error| match error { + TrustedSignatureError::NoValidSignature(error) => { + log::info!("{handler}: rejecting note: {error:?}"); + AppError::NoValidSignatures } - }; - - // Parse the reference checkpoint and bound-check the subtree. - // `Subtree::new` enforces `start < end` and the power-of-two - // alignment; `subtree_end <= size` is checked explicitly. Empty - // subtrees (`start == end`) are rejected for now; MTC draft-06 will - // permit them, at which point the mirror should cosign them too. - let cp_text = match CheckpointText::from_bytes(checkpoint.text()) { - Ok(t) => t, - Err(e) => { - log::warn!("sign-subtree: malformed checkpoint text: {e:?}"); - return Err(AppError::BadRequest(e.to_string())); + TrustedSignatureError::VerifierInvariant(error) => { + AppError::InternalServerError(format!("{handler} verifier invariant: {error:?}")) } - }; - if subtree_end > cp_text.size() { - log::info!( - "sign-subtree: subtree end {subtree_end} exceeds checkpoint size {}", - cp_text.size() - ); - return Err(AppError::BadRequest(format!( - "subtree end {subtree_end} > checkpoint size {}", - cp_text.size() - ))); + }) +} + +#[worker::send] +async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult { + let mut signers = Vec::with_capacity(2); + let roles = enabled_roles(CONFIG.mode); + if roles.witness() + && let Some(signer) = load_witness_signer(&env)?.as_subtree_signer() + { + signers.push(signer); + } + if roles.mirror() + && let Some(signer) = load_mirror_signer(&env)?.as_subtree_signer() + { + signers.push(signer); + } + if signers.is_empty() { + return Ok(StatusCode::NOT_FOUND.into_response()); } - let subtree = match Subtree::new(subtree_start, subtree_end) { - Ok(s) => s, - Err(e) => { - log::info!("sign-subtree: invalid subtree [{subtree_start}, {subtree_end}): {e:?}"); - return Err(AppError::BadRequest(format!("invalid subtree: {e:?}"))); - } - }; - // Look up the log by its origin. Subtree DoS-protection cosignatures - // in the request are ignored: this implementation applies no - // pre-screening policy, as the spec leaves their use to the operator. - let origin = cp_text.origin(); + let validated = validate_sign_subtree_request(&body).map_err(|error| { + log::warn!("sign-subtree: malformed request: {error}"); + AppError::BadRequest(error.to_string()) + })?; + let origin = validated.checkpoint_text().origin(); if log_verifiers(origin).is_none() { - log::info!("sign-subtree: unknown log origin {origin:?}"); return Err(AppError::UnknownLogOrigin); } - - // Stateless verification: the checkpoint MUST carry one of this - // mirror's own past `subtree/v1` cosignatures. The verifier - // reconstructs the cosigned message from the checkpoint's - // origin/size/hash with start = 0, end = size and rejects anything - // else. - let mirror_verifier: Box = subtree_signer.verifier(); - if let Err(e) = checkpoint.verify(&VerifierList::new(vec![mirror_verifier])) { - match e { - NoteError::UnverifiedNote | NoteError::InvalidSignature { .. } => { - log::info!("sign-subtree: reference checkpoint not cosigned by this mirror: {e:?}"); - return Err(AppError::ReferenceCheckpointNotCosignedByThisMirror); - } - // `MismatchedVerifier`/`AmbiguousKey` mean the verifier list - // we built is malformed, not that the client sent a bad - // request; over a one-element list they are unreachable - // today. Surface them as 500 rather than blaming the client. - _ => { - log::error!("sign-subtree: checkpoint verify failed unexpectedly: {e:?}"); - return Err(AppError::InternalServerError(e.to_string())); + validate_sign_subtree_proof(&validated).map_err(|_| { + AppError::UnprocessableEntity("subtree consistency proof failed".to_owned()) + })?; + + let mut signatures: Vec = Vec::with_capacity(signers.len()); + for signer in signers { + let verifiers = VerifierList::new(vec![signer.verifier()]); + match verify_trusted_checkpoint_signature(validated.checkpoint(), &verifiers) { + Ok(()) => signatures.push(signer.sign_subtree( + 0, + origin, + validated.subtree(), + validated.subtree_hash(), + )), + Err(TrustedSignatureError::NoValidSignature(_)) => {} + Err(TrustedSignatureError::VerifierInvariant(error)) => { + return Err(AppError::InternalServerError(format!( + "sign-subtree verifier invariant: {error:?}" + ))); } } } - - // Verify the subtree consistency proof against the reference - // checkpoint root. - if verify_subtree_consistency_proof( - &consistency_proof, - cp_text.size(), - *cp_text.hash(), - &subtree, - subtree_hash, - ) - .is_err() - { - log::info!( - "sign-subtree: consistency proof failed for subtree [{subtree_start}, {subtree_end}) \ - against checkpoint size {}", - cp_text.size() - ); - return Err(AppError::UnprocessableEntity( - "subtree consistency proof failed".to_owned(), - )); + if signatures.is_empty() { + return Err(AppError::ReferenceCheckpointNotCosigned); } - - // Timestamp must be zero for a non-zero-start subtree; allowed for - // start = 0 but we use zero uniformly. - let note_sig = subtree_signer.sign_subtree(0, origin, &subtree, &subtree_hash); Ok(( StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - serialize_sign_subtree_response(std::slice::from_ref(¬e_sig)), + serialize_sign_subtree_response(&signatures), ) .into_response()) } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Maximum `add-checkpoint` request body, enforced by the route's -/// [`DefaultBodyLimit`] layer. A well-formed request is an `old ` -/// line, up to 63 base64 hash lines, and a checkpoint note of up to -/// `signed_note::MAX_NOTE_SIZE` (1 MiB); 1 MiB plus 16 KiB of headroom -/// covers that. -const MAX_ADD_CHECKPOINT_BODY_SIZE: usize = 1_024 * 1_024 + 16 * 1_024; - -/// POST the [`UpdatePendingRequest`] to the per-origin DO, translating -/// the DO's status code into either: -/// -/// * `Ok(None)`: success (200); the caller should return an empty -/// `Response`. -/// * `Ok(Some(resp))`: the DO responded with a non-200 status that -/// maps directly to the `add-checkpoint` HTTP response (409 with -/// `text/x.tlog.size` body, 422, or a forwarded 400). -/// * `Err(_)`: transport-level failure. async fn dispatch_update_pending( env: &Env, origin: &str, update: &UpdatePendingRequest, ) -> Result> { let stub = state_stub(env, origin)?; - let mut resp = stub + let mut response = stub .fetch_with_request(Request::new_with_init( "http://do/update-pending", &RequestInit { method: Method::Post, body: Some(serde_json::to_string(update)?.into()), headers: { - let h = Headers::new(); - h.set("content-type", "application/json")?; - h + let headers = Headers::new(); + headers.set("content-type", "application/json")?; + headers }, ..Default::default() }, )?) .await?; - match resp.status_code() { - // Drop `resp`; its destructor releases the body (no explicit read). + match response.status_code() { 200 => Ok(None), 409 => { - let current: PendingCheckpoint = resp.json().await?; - Ok(Some(tlog_size_conflict(¤t))) + let current: PendingCheckpoint = response.json().await?; + Ok(Some(tlog_size_conflict(current.size))) } 422 => Ok(Some( ( @@ -565,10 +455,11 @@ async fn dispatch_update_pending( .into_response(), )), 400 => { - // Forward the DO's message verbatim; it already describes the - // specific violation (e.g. non-empty proof for a first pending). - let msg = resp.text().await.unwrap_or_else(|_| "Bad request".into()); - Ok(Some((StatusCode::BAD_REQUEST, msg).into_response())) + let message = response + .text() + .await + .unwrap_or_else(|_| "Bad request".into()); + Ok(Some((StatusCode::BAD_REQUEST, message).into_response())) } status => Ok(Some( ( @@ -580,15 +471,11 @@ async fn dispatch_update_pending( } } -/// Build the 409 response body per the witness/mirror spec: -/// `text/x.tlog.size` content type, decimal latest size followed by a -/// newline. -fn tlog_size_conflict(current: &PendingCheckpoint) -> axum::response::Response { - let body = format!("{}\n", current.size); +fn tlog_size_conflict(size: u64) -> axum::response::Response { ( StatusCode::CONFLICT, [(header::CONTENT_TYPE, CONTENT_TYPE_TLOG_SIZE)], - body, + format!("{size}\n"), ) .into_response() } @@ -599,12 +486,9 @@ mod tests { use axum::http::header::ACCEPT_ENCODING; #[tokio::test] - async fn accept_encoding_middleware_adds_gzip() { + async fn combined_mode_advertises_gzip() { let response = axum::http::Response::new(axum::body::Body::empty()); let response = add_accept_encoding(response).await; - assert_eq!( - response.headers().get(ACCEPT_ENCODING).unwrap().as_bytes(), - b"gzip" - ); + assert_eq!(response.headers()[ACCEPT_ENCODING], "gzip"); } } diff --git a/crates/mirror_worker/src/lib.rs b/crates/mirror_worker/src/lib.rs index 195b21d7..f3b9e41b 100644 --- a/crates/mirror_worker/src/lib.rs +++ b/crates/mirror_worker/src/lib.rs @@ -1,13 +1,13 @@ // Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause -//! A transparency-log mirror implementing [c2sp.org/tlog-mirror][mirror] on -//! Cloudflare Workers, specialized for MTC issuance logs. +//! A configurable transparency-log witness and mirror on +//! Cloudflare Workers. //! //! This worker handles the [`add-checkpoint`][add-cp] and //! [`add-entries`][add-e] submission endpoints, the OPTIONAL -//! [`sign-subtree`][signsub] endpoint, and publishes the mirror's identity -//! and per-log configuration at `/metadata`. The [tlog-tiles][tiles] read +//! [`sign-subtree`][signsub] endpoint, and publishes enabled identities and +//! per-log configuration at `/metadata`. The [tlog-tiles][tiles] read //! interface is served directly from object storage (see the `storage` //! module). //! @@ -23,18 +23,32 @@ //! [tiles]: https://c2sp.org/tlog-tiles use base64::Engine as _; -use config::AppConfig; -use ml_dsa::pkcs8::{DecodePrivateKey as _, EncodePublicKey as _}; +use config::{AppConfig, CheckpointAlgorithm}; +use ed25519_dalek::{ + SigningKey as Ed25519SigningKey, + pkcs8::{DecodePrivateKey as _, DecodePublicKey as _, EncodePublicKey as _}, +}; use ml_dsa::{MlDsa44, VerifyingKey as MlDsaVerifyingKey}; -use pkcs8::{PrivateKeyInfoRef, SecretDocument, der::oid::db::fips204::ID_ML_DSA_44}; -use signed_note::{KeyName, NoteVerifier, VerifierList}; +use pkcs8::{ + PrivateKeyInfoRef, SecretDocument, + der::oid::db::{fips204::ID_ML_DSA_44, rfc8410::ID_ED_25519}, +}; +use signed_note::{Ed25519NoteVerifier, KeyName, NoteVerifier, VerifierList}; use std::collections::HashMap; -use std::sync::{Arc, LazyLock, OnceLock}; -use tlog_cosignature::{SubtreeV1CheckpointSigner, SubtreeV1NoteVerifier}; +use std::sync::{LazyLock, OnceLock}; +use tlog_cosignature::{ + CosignatureV1CheckpointSigner, SubtreeV1CheckpointSigner, SubtreeV1NoteVerifier, +}; use tlog_mirror::TicketSealer; #[allow(clippy::wildcard_imports)] use worker::*; +/// Initialize Sentry from the `SENTRY_DSN` environment variable. +/// +/// Does nothing when the variable is absent or empty, allowing +/// deployments without Sentry support. Called at the top of the frontend +/// `fetch` handler and in each Durable Object's `new`, so a panic anywhere +/// in the worker is captured and flushed. pub(crate) use generic_log_worker::obs::sentry::init_from_env as init_sentry; mod add_entries; @@ -46,12 +60,38 @@ mod mirror_state_do; mod storage; mod stream_buffer; -/// The binding name used in `wrangler.jsonc` for the `MirrorState` DO. pub(crate) const MIRROR_STATE_BINDING: &str = "MIRROR_STATE"; /// The binding name used in `wrangler.jsonc` for the `MirrorCleaner` DO. pub(crate) const MIRROR_CLEANER_BINDING: &str = "MIRROR_CLEANER"; +#[derive(Clone, Copy)] +pub(crate) struct EnabledRoles { + witness: bool, + mirror: bool, +} + +pub(crate) const fn enabled_roles(mode: config::Mode) -> EnabledRoles { + EnabledRoles { + witness: mode.witness_enabled(), + mirror: mode.mirror_enabled(), + } +} + +impl EnabledRoles { + pub(crate) const fn witness(self) -> bool { + self.witness + } + + pub(crate) const fn mirror(self) -> bool { + self.mirror + } + + pub(crate) const fn combined(self) -> bool { + self.witness && self.mirror + } +} + /// The compile-time-embedded worker configuration. /// /// `build.rs` validates `config..json` against the schema and @@ -62,64 +102,62 @@ pub(crate) static CONFIG: LazyLock = LazyLock::new(|| { .expect("config.json must be valid at build time") }); -/// Per-origin cache of the parsed trusted log keys, keyed by the -/// concrete checkpoint origin. An MTC log-number window expands to one -/// entry per accepted log number (origin `.0.`), so the -/// map keys are the full set of origins this mirror serves. -/// -/// All log numbers of a CA share the same key(s), and a parsed -/// ML-DSA-44 [`LogKey`] is ~25 KiB (it precomputes the expanded NTT -/// matrix), so the value is an [`Arc`]: every origin in a window points -/// at one shared allocation rather than cloning the key per log number. -/// -/// Values are [`LogKey`] pairs rather than a pre-built `VerifierList`, +/// Per-origin cache of parsed trusted checkpoint signers. +/// Values are parsed keys rather than a pre-built `VerifierList`, /// because `Box` is not `Sync` and so cannot live -/// inside a `LazyLock`. Building the `VerifierList` per request is cheap -/// (`SubtreeV1NoteVerifier::new` is just a key-ID hash). -pub(crate) static LOG_KEYS: LazyLock>>> = LazyLock::new(|| { - let mut map: HashMap>> = HashMap::new(); - for (log_key_name, log) in &CONFIG.logs { - let keys = Arc::new(parse_log_keys(log_key_name, log)); - for origin in log.origins(log_key_name) { - map.insert(origin, Arc::clone(&keys)); - } - } - map +/// inside a `LazyLock`. +pub(crate) static LOG_KEYS: LazyLock>> = LazyLock::new(|| { + CONFIG + .logs + .iter() + .map(|(origin, log)| (origin.clone(), parse_log_keys(log))) + .collect() }); -/// A parsed trusted log key: the note-signature `name` (the -/// `log_key_name`, constant across an MTC CA's log-number window) and the -/// ML-DSA-44 verifying key. +/// A parsed trusted source checkpoint signer. #[derive(Clone)] -pub(crate) struct LogKey { - pub name: KeyName, - pub verifying_key: MlDsaVerifyingKey, +pub(crate) enum LogKey { + Ed25519 { + name: KeyName, + verifying_key: ed25519_dalek::VerifyingKey, + }, + SubtreeV1 { + name: KeyName, + verifying_key: MlDsaVerifyingKey, + }, } /// Build the parsed keys for a single configured log. /// -/// `build.rs` runs [`config::AppConfig::validate`] and refuses to compile -/// a malformed config, so the `log_key_name` and each SPKI are known to -/// parse; the `expect`s below would only fire if `validate` drifted out -/// of sync with this function. -fn parse_log_keys(log_key_name: &str, log: &config::LogParams) -> Vec { - use ml_dsa::pkcs8::DecodePublicKey as _; - let name = KeyName::new(log_key_name.to_owned()) - .expect("log_key_name validated as a signed-note KeyName by AppConfig::validate"); - log.log_public_keys +/// `build.rs` validates each signer name, algorithm, and SPKI before this +/// function runs. +fn parse_log_keys(log: &config::LogParams) -> Vec { + log.checkpoint_signers .iter() - .map(|spki| { - let verifying_key = MlDsaVerifyingKey::::from_public_key_der(spki) - .expect("SPKI validated as ML-DSA-44 by AppConfig::validate"); - LogKey { - name: name.clone(), - verifying_key, + .map(|signer| { + let name = KeyName::new(signer.name.clone()) + .expect("checkpoint signer name validated by AppConfig::validate"); + match signer.algorithm { + CheckpointAlgorithm::Ed25519 => LogKey::Ed25519 { + name, + verifying_key: ed25519_dalek::VerifyingKey::from_public_key_der( + &signer.public_key, + ) + .expect("SPKI validated as Ed25519 by AppConfig::validate"), + }, + CheckpointAlgorithm::SubtreeV1 => LogKey::SubtreeV1 { + name, + verifying_key: MlDsaVerifyingKey::::from_public_key_der( + &signer.public_key, + ) + .expect("SPKI validated as ML-DSA-44 by AppConfig::validate"), + }, } }) .collect() } -/// Every concrete origin this mirror serves, as `'static` string slices. +/// Every configured source-log origin, as `'static` string slices. /// /// This is the set of Durable Object names for the per-origin /// `MirrorState`/`MirrorCleaner` instances; the DOs recover their own @@ -132,61 +170,68 @@ pub(crate) fn log_origins() -> impl Iterator { /// `None` if no log is configured at that origin. pub(crate) fn log_verifiers(origin: &str) -> Option { let keys = LOG_KEYS.get(origin)?; + Some(log_verifiers_for_keys(keys)) +} + +fn log_verifiers_for_keys(keys: &[LogKey]) -> VerifierList { let verifiers: Vec> = keys .iter() - .map(|k| { - Box::new(SubtreeV1NoteVerifier::new( - k.name.clone(), - k.verifying_key.clone(), - )) as Box + .map(|key| match key { + LogKey::Ed25519 { + name, + verifying_key, + } => Box::new(Ed25519NoteVerifier::new(name.clone(), *verifying_key)) + as Box, + LogKey::SubtreeV1 { + name, + verifying_key, + } => Box::new(SubtreeV1NoteVerifier::new( + name.clone(), + verifying_key.clone(), + )) as Box, }) .collect(); - Some(VerifierList::new(verifiers)) + VerifierList::new(verifiers) } -// --------------------------------------------------------------------------- -// Mirror cosigner key -// --------------------------------------------------------------------------- - -/// The mirror's signing material. -/// -/// The mirror is an MTC cosigner, which per [c2sp.org/mtc-tlog][mtc] MUST -/// use an ML-DSA-44 key and produce [`subtree/v1`][cosig] messages, so -/// this worker supports only that algorithm. Holds the signer plus the -/// DER-encoded `SubjectPublicKeyInfo` computed once at load and served by -/// `/metadata`. The signer is boxed because the expanded ML-DSA-44 key is -/// large (~64 KiB). -/// -/// [mtc]: https://c2sp.org/mtc-tlog -/// [cosig]: https://c2sp.org/tlog-cosignature -pub(crate) struct MirrorSigner { - signer: Box, - public_key_der: Vec, +/// Identity signing material selected by the PKCS#8 algorithm OID. +pub(crate) enum IdentitySigner { + CosignatureV1 { + signer: Box, + public_key_der: Vec, + }, + SubtreeV1 { + signer: Box, + public_key_der: Vec, + }, } -impl MirrorSigner { - /// DER-encoded `SubjectPublicKeyInfo` for the mirror's ML-DSA-44 - /// verifying key. +impl IdentitySigner { + /// DER-encoded `SubjectPublicKeyInfo` for the mirror's verifying key. pub(crate) fn public_key_der(&self) -> &[u8] { - &self.public_key_der + match self { + Self::CosignatureV1 { public_key_der, .. } | Self::SubtreeV1 { public_key_der, .. } => { + public_key_der + } + } } - /// Stable string identifying the cosignature algorithm, published in - /// `/metadata`. Always `"subtree/v1"` (the only algorithm an MTC - /// mirror cosigner may use). - #[allow(clippy::unused_self)] pub(crate) fn algorithm(&self) -> &'static str { - "subtree/v1" + match self { + Self::CosignatureV1 { .. } => "cosignature/v1", + Self::SubtreeV1 { .. } => "subtree/v1", + } } - /// The concrete [`SubtreeV1CheckpointSigner`], used by `sign-subtree`, - /// which needs [`SubtreeV1CheckpointSigner::sign_subtree`] and the - /// matching verifier, neither reachable through the algorithm-agnostic - /// [`CheckpointSigner`] trait object. - /// - /// [`CheckpointSigner`]: tlog_checkpoint::CheckpointSigner - pub(crate) fn as_subtree_signer(&self) -> &SubtreeV1CheckpointSigner { - &self.signer + pub(crate) fn supports_sign_subtree(&self) -> bool { + matches!(self, Self::SubtreeV1 { .. }) + } + + pub(crate) fn as_subtree_signer(&self) -> Option<&SubtreeV1CheckpointSigner> { + match self { + Self::CosignatureV1 { .. } => None, + Self::SubtreeV1 { signer, .. } => Some(signer), + } } /// The inner [`CheckpointSigner`] trait object, used by the @@ -195,83 +240,118 @@ impl MirrorSigner { /// /// [`CheckpointSigner`]: tlog_checkpoint::CheckpointSigner pub(crate) fn as_checkpoint_signer(&self) -> &dyn tlog_checkpoint::CheckpointSigner { - &*self.signer + match self { + Self::CosignatureV1 { signer, .. } => &**signer, + Self::SubtreeV1 { signer, .. } => &**signer, + } } } /// Cached mirror signer, so the PKCS#8 parse happens at most once per /// worker instance. -static MIRROR_SIGNER: OnceLock = OnceLock::new(); +static MIRROR_SIGNER: OnceLock = OnceLock::new(); +static WITNESS_SIGNER: OnceLock = OnceLock::new(); /// Load (or return the already-cached) mirror signer. /// -/// The `MIRROR_SIGNING_KEY` PKCS#8 PEM secret MUST carry an ML-DSA-44 key -/// (`id-ml-dsa-44`); the mirror cosigns with `subtree/v1`. +/// The algorithm is derived from the PKCS#8 OID. /// /// # Errors /// /// Returns an error if the `MIRROR_SIGNING_KEY` secret is missing, the PEM -/// is malformed, or the key is not ML-DSA-44. -pub(crate) fn load_mirror_signer(env: &Env) -> Result<&'static MirrorSigner> { +/// is malformed, or the key is neither Ed25519 nor ML-DSA-44. +pub(crate) fn load_mirror_signer(env: &Env) -> Result<&'static IdentitySigner> { + if !enabled_roles(CONFIG.mode).mirror() { + return Err(Error::from("mirror identity is disabled")); + } if let Some(s) = MIRROR_SIGNER.get() { return Ok(s); } let pem = env.secret("MIRROR_SIGNING_KEY")?.to_string(); - let signer = build_mirror_signer(&pem)?; + let signer = build_identity_signer(&CONFIG.mirror_config().name, "MIRROR_SIGNING_KEY", &pem)?; Ok(MIRROR_SIGNER.get_or_init(|| signer)) } -/// Build a [`MirrorSigner`] from a PKCS#8 PEM string. +/// Build identity signing material from a PKCS#8 PEM string. /// /// Split out from [`load_mirror_signer`] so unit tests can exercise the -/// parse/validation without a `worker::Env`. The key MUST be ML-DSA-44; -/// any other algorithm is rejected (the mirror's cosigner must be an MTC -/// cosigner, see [`MirrorSigner`]). -fn build_mirror_signer(pem: &str) -> Result { - let name = KeyName::new(CONFIG.mirror_name.clone()) - .map_err(|e| Error::from(format!("invalid mirror_name: {e:?}")))?; +/// parse and algorithm dispatch without a `worker::Env`. +fn build_identity_signer( + identity_name: &str, + secret_name: &str, + pem: &str, +) -> Result { + let name = KeyName::new(identity_name.to_owned()) + .map_err(|e| Error::from(format!("invalid identity name: {e:?}")))?; let (_label, doc) = SecretDocument::from_pem(pem).map_err(|e| Error::from(format!("PEM parse: {e}")))?; let pk_info = PrivateKeyInfoRef::try_from(doc.as_bytes()) .map_err(|e| Error::from(format!("PrivateKeyInfo parse: {e}")))?; match pk_info.algorithm.oid { - ID_ML_DSA_44 => { - // ml-dsa's PKCS#8 stores only the 32-byte seed; `from_pkcs8_der` - // expands it on the way in. The expanded key never leaves this - // worker. - let expanded = ml_dsa::ExpandedSigningKey::::from_pkcs8_der(doc.as_bytes()) - .map_err(|e| Error::from(format!("ML-DSA-44 PKCS#8 parse: {e}")))?; - let public_key_der = expanded + ID_ED_25519 => { + let key = Ed25519SigningKey::from_pkcs8_pem(pem) + .map_err(|e| Error::from(format!("Ed25519 PKCS#8 parse: {e}")))?; + let public_key_der = key .verifying_key() .to_public_key_der() - .map_err(|e| Error::from(format!("ML-DSA-44 SPKI encode: {e}")))? + .map_err(|e| Error::from(format!("Ed25519 SPKI encode: {e}")))? .to_vec(); - Ok(MirrorSigner { - signer: Box::new(SubtreeV1CheckpointSigner::new(name, expanded)), + Ok(IdentitySigner::CosignatureV1 { + signer: Box::new(CosignatureV1CheckpointSigner::new(name, key)), + public_key_der, + }) + } + ID_ML_DSA_44 => { + let (signer, public_key_der) = + SubtreeV1CheckpointSigner::from_pkcs8_pem_with_public_key(name, pem) + .map_err(Error::from)?; + Ok(IdentitySigner::SubtreeV1 { + signer: Box::new(signer), public_key_der, }) } oid => Err(Error::from(format!( - "unsupported MIRROR_SIGNING_KEY algorithm OID {oid}: expected id-ml-dsa-44 \ - ({ID_ML_DSA_44}). The mirror's cosigner must be an MTC cosigner (ML-DSA-44, \ - subtree/v1)." + "unsupported {secret_name} algorithm OID {oid}: expected id-Ed25519 \ + ({ID_ED_25519}) or id-ml-dsa-44 ({ID_ML_DSA_44})" ))), } } -/// Return the DER-encoded `SubjectPublicKeyInfo` for the mirror's own -/// verifying key. Used by the `/metadata` endpoint. -/// -/// # Errors -/// -/// Returns an error if the signing key is not available. -pub(crate) fn load_mirror_public_key_der(env: &Env) -> Result<&'static [u8]> { - Ok(load_mirror_signer(env)?.public_key_der()) +pub(crate) fn load_witness_signer(env: &Env) -> Result<&'static IdentitySigner> { + if !enabled_roles(CONFIG.mode).witness() { + return Err(Error::from("witness identity is disabled")); + } + if let Some(signer) = WITNESS_SIGNER.get() { + return Ok(signer); + } + let pem = env.secret("WITNESS_SIGNING_KEY")?.to_string(); + let identity = CONFIG + .witness + .as_ref() + .expect("validated witness mode must have witness config"); + let signer = build_identity_signer(&identity.name, "WITNESS_SIGNING_KEY", &pem)?; + Ok(WITNESS_SIGNER.get_or_init(|| signer)) } -// --------------------------------------------------------------------------- -// Ticket key -// --------------------------------------------------------------------------- +/// Load enabled identity keys and reject key reuse across roles. +pub(crate) fn validate_identity_keys(env: &Env) -> Result<()> { + if !enabled_roles(CONFIG.mode).combined() { + return Ok(()); + } + let witness = load_witness_signer(env)?; + let mirror = load_mirror_signer(env)?; + ensure_distinct_identity_keys(witness, mirror).map_err(Error::from) +} + +fn ensure_distinct_identity_keys( + witness: &IdentitySigner, + mirror: &IdentitySigner, +) -> std::result::Result<(), &'static str> { + if witness.public_key_der() != mirror.public_key_der() { + return Ok(()); + } + Err("witness and mirror identities must use distinct signing keys") +} /// Cached ticket authenticator, built lazily on first request. /// @@ -313,16 +393,19 @@ pub(crate) fn load_ticket_sealer(env: &Env) -> Result<&'static TicketSealer> { #[cfg(test)] mod signer_tests { - //! Unit tests for the mirror signer loader: only ML-DSA-44 keys are - //! accepted; other algorithms and malformed PEMs are rejected. - //! Ed25519 keys are generated only to exercise the rejection path, so - //! `ed25519-dalek` is a dev-dependency. - - use super::build_mirror_signer; - use ed25519_dalek::pkcs8::EncodePrivateKey as _; + use super::{ + IdentitySigner, build_identity_signer, enabled_roles, ensure_distinct_identity_keys, + log_verifiers_for_keys, parse_log_keys, + }; + use base64::Engine as _; + use config::{CheckpointAlgorithm, CheckpointSigner, LogParams}; + use ed25519_dalek::pkcs8::{EncodePrivateKey as _, EncodePublicKey as _}; + use ml_dsa::ExpandedSigningKey; + use signed_note::{KeyName, Note}; + use tlog_checkpoint::{CheckpointSigner as _, CheckpointText}; + use tlog_core::record_hash; + use tlog_cosignature::SubtreeV1CheckpointSigner; - /// Generate a deterministic Ed25519 PEM from a seed byte. Used only to - /// check that a non-ML-DSA-44 key is rejected. fn ed25519_pem(seed: u8) -> String { let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]); sk.to_pkcs8_pem(pkcs8::LineEnding::LF) @@ -344,32 +427,48 @@ mod signer_tests { #[test] fn ml_dsa_44_pem_loads_with_subtree_v1_algorithm() { - let signer = build_mirror_signer(&ml_dsa_44_pem(2)).expect("build ML-DSA-44 signer"); + let signer = build_identity_signer("mirror.example", "TEST_KEY", &ml_dsa_44_pem(2)) + .expect("build ML-DSA-44 signer"); + assert!(matches!(signer, IdentitySigner::SubtreeV1 { .. })); assert_eq!(signer.algorithm(), "subtree/v1"); + assert!(signer.supports_sign_subtree()); assert!( !signer.public_key_der().is_empty(), "ML-DSA-44 SPKI must be non-empty", ); } - /// The mirror cosigner MUST be ML-DSA-44 (an MTC cosigner). An Ed25519 - /// key (a valid tlog cosigner key in general, but not an MTC one) is - /// refused, with an error naming the expected algorithm. #[test] - fn ed25519_key_is_rejected() { - let Err(err) = build_mirror_signer(&ed25519_pem(3)) else { - panic!("Ed25519 MIRROR_SIGNING_KEY must be rejected") - }; - let msg = err.to_string(); - assert!( - msg.contains("unsupported") && msg.contains("id-ml-dsa-44"), - "unexpected error: {msg}", - ); + fn ed25519_pem_loads_with_cosignature_v1_algorithm() { + let signer = build_identity_signer("mirror.example", "TEST_KEY", &ed25519_pem(3)) + .expect("build Ed25519 signer"); + assert!(matches!(signer, IdentitySigner::CosignatureV1 { .. })); + assert_eq!(signer.algorithm(), "cosignature/v1"); + assert!(!signer.supports_sign_subtree()); + assert!(signer.as_subtree_signer().is_none()); + } + + #[test] + fn independently_generated_identity_keys_are_distinct() { + let witness = build_identity_signer("witness.example", "TEST_KEY", &ed25519_pem(3)) + .expect("build witness signer"); + let mirror = build_identity_signer("mirror.example", "TEST_KEY", &ed25519_pem(4)) + .expect("build mirror signer"); + ensure_distinct_identity_keys(&witness, &mirror).unwrap(); + } + + #[test] + fn reused_identity_key_is_rejected() { + let witness = build_identity_signer("witness.example", "TEST_KEY", &ed25519_pem(3)) + .expect("build witness signer"); + let mirror = build_identity_signer("mirror.example", "TEST_KEY", &ed25519_pem(3)) + .expect("build mirror signer"); + assert!(ensure_distinct_identity_keys(&witness, &mirror).is_err()); } #[test] fn malformed_pem_is_rejected() { - let Err(err) = build_mirror_signer("not-a-pem") else { + let Err(err) = build_identity_signer("mirror.example", "TEST_KEY", "not-a-pem") else { panic!("malformed PEM must not parse") }; let msg = err.to_string(); @@ -378,6 +477,64 @@ mod signer_tests { "unexpected error: {msg}", ); } + + #[test] + fn configured_mixed_log_verifiers_verify_both_algorithms() { + let ed_key = ed25519_dalek::SigningKey::from_bytes(&[12; 32]); + let ml_key = ExpandedSigningKey::::from_seed(&ml_dsa::B32::from([13; 32])); + let log = LogParams { + description: None, + checkpoint_signers: vec![ + CheckpointSigner { + name: "log.example/ed".to_owned(), + algorithm: CheckpointAlgorithm::Ed25519, + public_key: ed_key.verifying_key().to_public_key_der().unwrap().to_vec(), + }, + CheckpointSigner { + name: "log.example/ml".to_owned(), + algorithm: CheckpointAlgorithm::SubtreeV1, + public_key: ml_key.verifying_key().to_public_key_der().unwrap().to_vec(), + }, + ], + }; + let keys = parse_log_keys(&log); + let checkpoint_bytes = format!( + "log.example\n1\n{}\n", + base64::engine::general_purpose::STANDARD.encode(record_hash(b"entry").0) + ); + let checkpoint = CheckpointText::from_bytes(checkpoint_bytes.as_bytes()).unwrap(); + + let ed_signer = tlog_checkpoint::Ed25519CheckpointSigner::new( + KeyName::new("log.example/ed".to_owned()).unwrap(), + ed_key, + ); + let ed_signature = ed_signer.sign(1, &checkpoint).unwrap(); + let ed_note = Note::new(checkpoint_bytes.as_bytes(), &[ed_signature]).unwrap(); + ed_note.verify(&log_verifiers_for_keys(&keys)).unwrap(); + + let ml_signer = SubtreeV1CheckpointSigner::new( + KeyName::new("log.example/ml".to_owned()).unwrap(), + ml_key, + ); + let ml_signature = ml_signer.sign(1, &checkpoint).unwrap(); + let ml_note = Note::new(checkpoint_bytes.as_bytes(), &[ml_signature]).unwrap(); + ml_note.verify(&log_verifiers_for_keys(&keys)).unwrap(); + } + + #[test] + fn standalone_role_policy_gates_routes_metadata_and_secrets() { + let witness = enabled_roles(config::Mode::Witness); + assert!(witness.witness()); + assert!(!witness.mirror()); + + let mirror = enabled_roles(config::Mode::Mirror); + assert!(!mirror.witness()); + assert!(mirror.mirror()); + + let combined = enabled_roles(config::Mode::WitnessAndMirror); + assert!(combined.witness()); + assert!(combined.mirror()); + } } #[cfg(test)] @@ -388,8 +545,8 @@ mod dev_config_tests { //! //! The dev config models an MTC CA: the `logs` key is the CA cosigner //! ID (`oid/1.3.6.1.4.1.32473.2`) whose ML-DSA-44 keypair signs - //! `subtree/v1` checkpoints. The mirror's own cosigner key in - //! `.dev.vars` is independent and is pinned only to "parses cleanly". + //! `subtree/v1` checkpoints. The role identity keys in `.dev.vars` are + //! independent. use base64::prelude::*; use ml_dsa::pkcs8::{DecodePrivateKey as _, EncodePublicKey as _}; @@ -432,11 +589,10 @@ mod dev_config_tests { // Pull the log's first public key straight from the JSON, so the // test is robust to unrelated config-shape changes. let parsed: serde_json::Value = serde_json::from_str(DEV_CONFIG).unwrap(); - let b64 = parsed["logs"]["oid/1.3.6.1.4.1.32473.2"]["log_public_keys"][0] - .as_str() - .expect( - "config.dev.json must have logs[\"oid/1.3.6.1.4.1.32473.2\"].log_public_keys[0]", - ); + let b64 = + parsed["logs"]["oid/1.3.6.1.4.1.32473.2.0.1"]["checkpoint_signers"][0]["public_key"] + .as_str() + .expect("config.dev.json must contain the MTC checkpoint signer public key"); let config_spki = BASE64_STANDARD.decode(b64).expect("SPKI is base64"); // Derive the SPKI from the PEM and compare. @@ -451,15 +607,11 @@ mod dev_config_tests { ); } - /// `MIRROR_SIGNING_KEY` in `.dev.vars` parses cleanly through the - /// same `build_mirror_signer` code path that production uses. Pins - /// that an operator-typo in `.dev.vars` is caught at unit-test - /// time, not at the first request to a running `wrangler dev`. #[test] fn dev_vars_mirror_signing_key_parses() { let pem = dev_var("MIRROR_SIGNING_KEY"); - let signer = super::build_mirror_signer(&pem) - .expect("MIRROR_SIGNING_KEY in .dev.vars must parse via build_mirror_signer"); + let signer = super::build_identity_signer("dev.mirror.example", "MIRROR_SIGNING_KEY", &pem) + .expect("MIRROR_SIGNING_KEY in .dev.vars must parse"); assert_eq!( signer.algorithm(), "subtree/v1", @@ -467,6 +619,31 @@ mod dev_config_tests { ); } + #[test] + fn dev_vars_witness_signing_key_parses() { + let signer = super::build_identity_signer( + "dev.witness.example", + "WITNESS_SIGNING_KEY", + &dev_var("WITNESS_SIGNING_KEY"), + ) + .expect("WITNESS_SIGNING_KEY in .dev.vars must parse"); + assert_eq!(signer.algorithm(), "subtree/v1"); + } + + #[test] + fn dev_vars_witness_and_mirror_public_keys_differ() { + let witness = SigningKey::::from_pkcs8_pem(&dev_var("WITNESS_SIGNING_KEY")) + .expect("WITNESS_SIGNING_KEY in .dev.vars must parse"); + let mirror = SigningKey::::from_pkcs8_pem(&dev_var("MIRROR_SIGNING_KEY")) + .expect("MIRROR_SIGNING_KEY in .dev.vars must parse"); + + assert_ne!( + witness.verifying_key().to_public_key_der().unwrap(), + mirror.verifying_key().to_public_key_der().unwrap(), + "dev witness and mirror identities must use distinct public keys", + ); + } + /// `MIRROR_TICKET_KEY` in `.dev.vars` is base64 of exactly 32 bytes, /// the precondition for [`crate::load_ticket_sealer`]. #[test] diff --git a/crates/mirror_worker/src/mirror_state_do.rs b/crates/mirror_worker/src/mirror_state_do.rs index 54ec9f56..f9c350b5 100644 --- a/crates/mirror_worker/src/mirror_state_do.rs +++ b/crates/mirror_worker/src/mirror_state_do.rs @@ -4,8 +4,7 @@ //! [`MirrorState`] Durable Object: per-origin atomic state for the //! [c2sp.org/tlog-mirror][spec] protocol. //! -//! The DO holds three pieces of per-origin state, always ordered -//! `committed.size <= next_entry.size <= pending.size`: +//! Mirror-enabled state is ordered `committed.size <= next_entry.size <= pending.size`. //! //! - `pending`: the latest signed checkpoint accepted via //! [`add-checkpoint`][add-cp], the source of truth for the consistency @@ -27,7 +26,10 @@ use serde::{Deserialize, Serialize}; use serde_with::{base64::Base64 as Base64As, serde_as}; -use tlog_core::{Hash, verify_consistency_proof}; +use tlog_core::Hash; +use tlog_witness::{ + CheckpointState, CheckpointTransitionError, ProofRequirement, validate_checkpoint_transition, +}; use tokio::sync::Mutex; #[allow(clippy::wildcard_imports)] use worker::*; @@ -38,22 +40,19 @@ const PENDING_KEY: &str = "pending"; const COMMITTED_KEY: &str = "committed"; const NEXT_ENTRY_KEY: &str = "next_entry"; -/// The persisted *pending checkpoint* for a single log origin. +/// The persisted latest checkpoint for a single log origin. /// -/// Stores the full signed-note bytes (not just size+hash) so the mirror -/// can serve them back to `add-entries` clients and retain the log's -/// signature per spec. +/// This is the witness's latest checkpoint and the mirror's pending +/// checkpoint. The signed note is retained for mirror `add-entries`. #[serde_as] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct PendingCheckpoint { - /// Tree size, or zero if no pending checkpoint has been accepted for - /// this origin. + /// Tree size. Zero is a valid accepted checkpoint. pub size: u64, /// Root hash. All-zero if `size` is 0. #[serde(with = "generic_log_worker::hash_serde::hex")] pub hash: Hash, - /// Full signed-note bytes, empty if `size` is 0. Base64 in the - /// on-disk JSON so the state stays valid UTF-8. + /// Full signed-note bytes, encoded as base64 in persisted JSON. #[serde_as(as = "Base64As")] pub signed_note_bytes: Vec, } @@ -67,17 +66,15 @@ pub struct PendingCheckpoint { /// checkpoint at `//checkpoint` without /// looking up historic pending state. #[serde_as] -#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct CommittedCheckpoint { - /// Tree size, or zero if no entries have been committed for this - /// origin. + /// Tree size. Zero is a valid committed empty-tree checkpoint. pub size: u64, /// Root hash. All-zero if `size` is 0. #[serde(with = "generic_log_worker::hash_serde::hex")] pub hash: Hash, /// The served checkpoint bytes: the log's signed note with the /// mirror's cosignature line(s) appended, exactly as written to R2. - /// Empty if `size` is 0. #[serde_as(as = "Base64As")] pub signed_note_bytes: Vec, } @@ -105,12 +102,9 @@ pub struct NextEntry { /// 409/404/422 cases and to resume appending from the persisted frontier. #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct MirrorStateSnapshot { - pub pending: PendingCheckpoint, - pub committed: CommittedCheckpoint, - /// The persisted-entry frontier. `#[serde(default)]` so a snapshot - /// serialized by an older worker (before this field existed) still - /// deserializes to the zero frontier during a rolling deploy. - #[serde(default)] + pub pending: Option, + pub committed: Option, + /// The persisted-entry frontier. pub next_entry: NextEntry, } @@ -166,20 +160,12 @@ pub struct UpdatePendingRequest { pub signed_note_bytes: Vec, } -/// A Durable Object holding the latest pending and committed checkpoint -/// state for a single log origin. +/// Per-origin witness and mirror state. #[durable_object(fetch)] struct MirrorState { state: State, - /// Held to resolve the origin's R2 bucket when the DO writes the - /// served checkpoint on `/commit`. env: Env, - /// Serializes `/commit` so the durable-storage advance and the R2 - /// served-checkpoint write happen atomically with respect to - /// concurrent commits, even across the external R2 write's await. - /// Without it a slower commit could overwrite the served checkpoint - /// with an older one after a concurrent commit already advanced it. - /// Mirrors the sequencer's `init_mux` (see `generic_log_worker`). + /// Invariant: checkpoint commits are serialized per origin. commit_mux: Mutex<()>, } @@ -230,51 +216,47 @@ impl MirrorState { // write is durable, so a following add-entries cannot race // it. let body: UpdatePendingRequest = req.json().await?; - let current: PendingCheckpoint = self - .state - .storage() - .get(PENDING_KEY) - .await? - .unwrap_or_default(); - if current.size != body.old_size { - // Return the latest pending so the caller can build a - // 409 body. - return Response::from_json(¤t).map(|r| r.with_status(409)); - } - // Same size: hashes must match and the proof must be empty. - if body.old_size == body.new_size { - if current.hash.0 != body.new_hash.0 { - return Response::from_json(¤t).map(|r| r.with_status(409)); - } - if !body.proof.is_empty() { - return Response::error( - "consistency proof must be empty when old_size == checkpoint size", - 400, - ); - } - } else if body.old_size == 0 { - // First pending for this origin: proof must be empty. - if !body.proof.is_empty() { - return Response::error( - "consistency proof must be empty when old_size is 0 (first pending checkpoint for this origin)", + let current: Option = + self.state.storage().get(PENDING_KEY).await?; + let transition = validate_checkpoint_transition( + current.as_ref().map(|checkpoint| CheckpointState { + size: checkpoint.size, + hash: checkpoint.hash, + }), + body.old_size, + CheckpointState { + size: body.new_size, + hash: body.new_hash, + }, + &body.proof, + ); + if let Err(error) = transition { + return match error { + CheckpointTransitionError::OldSizeMismatch + | CheckpointTransitionError::HashMismatch => { + Response::from_json(¤t.unwrap_or_default()) + .map(|response| response.with_status(409)) + } + CheckpointTransitionError::ProofMustBeEmpty(ProofRequirement::SameSize) => { + Response::error( + "consistency proof must be empty when old_size == checkpoint size", + 400, + ) + } + CheckpointTransitionError::ProofMustBeEmpty(ProofRequirement::Initial) => { + Response::error( + "consistency proof must be empty when old_size is 0 (first pending checkpoint for this origin)", + 400, + ) + } + CheckpointTransitionError::ConsistencyProofFailed => { + Response::error("consistency proof failed", 422) + } + CheckpointTransitionError::InvalidEmptyTreeHash => Response::error( + "size-zero checkpoint must use the empty-tree hash", 400, - ); - } - } else { - // 0 < old_size < new_size: proof required. - // `verify_consistency_proof` takes the larger tree - // first, then the smaller. - if verify_consistency_proof( - &body.proof, - body.new_size, - body.new_hash, - body.old_size, - current.hash, - ) - .is_err() - { - return Response::error("consistency proof failed", 422); - } + ), + }; } let new_state = PendingCheckpoint { size: body.new_size, @@ -290,28 +272,7 @@ impl MirrorState { } impl MirrorState { - /// Handle `/commit`: monotonically advance the mirror checkpoint and - /// write the served checkpoint object to R2. - /// - /// `commit_mux` serializes the whole read-check-advance-write - /// sequence, including the external R2 write, so concurrent commits - /// cannot interleave and rewind the served checkpoint. Compare-and-swap - /// semantics: - /// * `size > next_entry.size`: commit beyond the persisted-entry - /// frontier (cosigning entries not yet durably written); 400. This - /// preserves `committed.size <= next_entry.size`, and since - /// `next_entry.size <= pending.size` it also rejects commits beyond - /// the accepted pending checkpoint. - /// * `size < committed.size`: a concurrent add-entries already - /// advanced past us. The spec forbids rolling back, so no-op and - /// return the current committed checkpoint (whose served object the - /// concurrent commit already wrote). - /// * otherwise: advance committed in durable storage, then write the - /// served checkpoint to R2. - /// - /// Durable storage is advanced before the R2 write so the served - /// checkpoint is never ahead of committed; a failed R2 write leaves R2 - /// lagging and is rewritten by the next commit. + /// Publish without rewinding the committed checkpoint. async fn commit(&self, body: CommitRequest) -> Result { let _guard = self.commit_mux.lock().await; @@ -325,10 +286,16 @@ impl MirrorState { 400, ); } - if body.size < snapshot.committed.size { - // Already ahead; no-op success. The concurrent commit that - // advanced past us already wrote the newer served checkpoint. - return Response::from_json(&snapshot.committed); + if let Some(committed) = snapshot.committed.as_ref() + && body.size < committed.size + { + return Response::from_json(committed); + } + if let Some(committed) = snapshot.committed.as_ref() + && body.size == committed.size + && body.hash != committed.hash + { + return Response::error("commit hash differs at the committed size", 400); } let new_committed = CommittedCheckpoint { @@ -341,11 +308,6 @@ impl MirrorState { .put(COMMITTED_KEY, &new_committed) .await?; - // Write the served checkpoint (the log's signed note plus the - // mirror's cosignature) to R2 at - // //checkpoint. The DO owns this write so - // it stays serialized with the durable advance above; the origin is - // the DO's own name. let origin = self .state .id() @@ -366,11 +328,14 @@ impl MirrorState { /// * otherwise: advance to `(size, hash)`. async fn advance_next_entry(&self, body: AdvanceNextEntryRequest) -> Result { let snapshot = self.read_snapshot().await?; - if body.size > snapshot.pending.size { + let Some(pending) = snapshot.pending.as_ref() else { + return Response::error("advance without a pending checkpoint", 400); + }; + if body.size > pending.size { return Response::error( format!( "advance beyond pending: requested size {} > pending size {}", - body.size, snapshot.pending.size + body.size, pending.size ), 400, ); @@ -387,11 +352,12 @@ impl MirrorState { } /// Read `pending`, `committed`, and `next_entry` from DO storage. - /// Missing keys default to the zero state ("no state yet"). + /// Missing pending and committed keys remain `None`; `next_entry` + /// defaults to the zero frontier. async fn read_snapshot(&self) -> Result { let storage = self.state.storage(); - let pending: PendingCheckpoint = storage.get(PENDING_KEY).await?.unwrap_or_default(); - let committed: CommittedCheckpoint = storage.get(COMMITTED_KEY).await?.unwrap_or_default(); + let pending: Option = storage.get(PENDING_KEY).await?; + let committed: Option = storage.get(COMMITTED_KEY).await?; let next_entry: NextEntry = storage.get(NEXT_ENTRY_KEY).await?.unwrap_or_default(); Ok(MirrorStateSnapshot { pending, @@ -416,12 +382,8 @@ mod tests { }; use tlog_core::{HASH_SIZE, Hash}; - /// Pin the on-disk JSON layout of `PendingCheckpoint`. Changing - /// this format would make already-deployed mirrors unable to read - /// their persisted state after a worker upgrade, so any change - /// here must be paired with a migration plan. #[test] - fn pending_checkpoint_json_format_unchanged() { + fn pending_checkpoint_json_format() { let mut bytes = [0u8; HASH_SIZE]; for (i, b) in bytes.iter_mut().enumerate() { *b = u8::try_from(i).unwrap(); @@ -439,8 +401,6 @@ mod tests { r#"{"size":42,"hash":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","signed_note_bytes":"c2lnbmVkLW5vdGUtYnl0ZXM="}"# ); - // Round-trip: an existing state blob must still parse after a - // rebuild. let decoded: PendingCheckpoint = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.size, 42); assert_eq!(decoded.hash.0, bytes); @@ -451,7 +411,7 @@ mod tests { /// and the DO are in the same worker, but a format change still /// needs both sides updated in lockstep. #[test] - fn update_pending_request_json_format_unchanged() { + fn update_pending_request_json_format() { let req = UpdatePendingRequest { old_size: 10, new_size: 20, @@ -492,9 +452,6 @@ mod tests { assert!(decoded.proof.is_empty()); } - /// The default `PendingCheckpoint` represents "never accepted a - /// pending for this origin"; the frontend relies on the zero-sized - /// default when a DO has no stored state. #[test] fn pending_checkpoint_default_is_zero() { let pc = PendingCheckpoint::default(); @@ -503,11 +460,8 @@ mod tests { assert!(pc.signed_note_bytes.is_empty()); } - /// Pin the on-disk JSON layout of `CommittedCheckpoint`. Same - /// migration considerations as `PendingCheckpoint`: deployed - /// mirrors must keep parsing this after a worker upgrade. #[test] - fn committed_checkpoint_json_format_unchanged() { + fn committed_checkpoint_json_format() { let mut bytes = [0u8; HASH_SIZE]; for (i, b) in bytes.iter_mut().enumerate() { *b = u8::try_from(i).unwrap(); @@ -528,30 +482,20 @@ mod tests { assert_eq!(decoded.signed_note_bytes, b"signed-note-bytes"); } - /// The default `CommittedCheckpoint` represents "never committed - /// any entries for this origin". - #[test] - fn committed_checkpoint_default_is_zero() { - let cc = CommittedCheckpoint::default(); - assert_eq!(cc.size, 0); - assert_eq!(cc.hash.0, [0u8; HASH_SIZE]); - assert!(cc.signed_note_bytes.is_empty()); - } - /// Pin the wire shape of the `/get-state` response. #[test] fn mirror_state_snapshot_json_format() { let snap = MirrorStateSnapshot { - pending: PendingCheckpoint { + pending: Some(PendingCheckpoint { size: 5, hash: Hash([0xaa; HASH_SIZE]), signed_note_bytes: b"p".to_vec(), - }, - committed: CommittedCheckpoint { + }), + committed: Some(CommittedCheckpoint { size: 3, hash: Hash([0xbb; HASH_SIZE]), signed_note_bytes: b"c".to_vec(), - }, + }), next_entry: NextEntry { size: 4, hash: Hash([0xcc; HASH_SIZE]), @@ -565,28 +509,53 @@ mod tests { "snapshot must include pending, committed, and next_entry: {json}" ); let decoded: MirrorStateSnapshot = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.pending.size, 5); - assert_eq!(decoded.committed.size, 3); + assert_eq!(decoded.pending.unwrap().size, 5); + assert_eq!(decoded.committed.unwrap().size, 3); assert_eq!(decoded.next_entry.size, 4); } - /// A snapshot serialized before `next_entry` existed must still - /// deserialize (to the zero frontier), so a rolling deploy that mixes - /// worker versions doesn't fail the `/get-state` round-trip. #[test] - fn mirror_state_snapshot_defaults_next_entry() { - let legacy = r#"{"pending":{"size":5,"hash":"aa","signed_note_bytes":""},"committed":{"size":3,"hash":"bb","signed_note_bytes":""}}"# - .replace("\"aa\"", &format!("\"{}\"", "aa".repeat(HASH_SIZE))) - .replace("\"bb\"", &format!("\"{}\"", "bb".repeat(HASH_SIZE))); - let decoded: MirrorStateSnapshot = serde_json::from_str(&legacy).unwrap(); - assert_eq!(decoded.next_entry.size, 0); - assert_eq!(decoded.next_entry.hash.0, [0u8; HASH_SIZE]); + fn snapshot_distinguishes_no_pending_from_size_zero() { + let no_pending = MirrorStateSnapshot::default(); + assert!(no_pending.pending.is_none()); + + let zero = MirrorStateSnapshot { + pending: Some(PendingCheckpoint { + size: 0, + hash: tlog_core::EMPTY_HASH, + signed_note_bytes: b"zero checkpoint".to_vec(), + }), + ..MirrorStateSnapshot::default() + }; + let decoded: MirrorStateSnapshot = + serde_json::from_str(&serde_json::to_string(&zero).unwrap()).unwrap(); + let pending = decoded.pending.unwrap(); + assert_eq!(pending.size, 0); + assert_eq!(pending.hash, tlog_core::EMPTY_HASH); + assert!(!pending.signed_note_bytes.is_empty()); + } + + #[test] + fn snapshot_distinguishes_no_commit_from_size_zero() { + let no_commit = MirrorStateSnapshot::default(); + assert!(no_commit.committed.is_none()); + + let zero = MirrorStateSnapshot { + committed: Some(CommittedCheckpoint { + size: 0, + hash: tlog_core::EMPTY_HASH, + signed_note_bytes: b"cosigned zero checkpoint".to_vec(), + }), + ..MirrorStateSnapshot::default() + }; + let json = serde_json::to_string(&zero).unwrap(); + assert!(json.contains(r#""committed":{"size":0"#)); + let decoded: MirrorStateSnapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.committed.unwrap().size, 0); } - /// Pin the on-disk JSON layout of `NextEntry`. Same migration - /// considerations as the other persisted checkpoint types. #[test] - fn next_entry_json_format_unchanged() { + fn next_entry_json_format() { let ne = NextEntry { size: 9, hash: Hash([0xde; HASH_SIZE]), @@ -603,7 +572,7 @@ mod tests { /// Pin the wire shape of the `/advance-next-entry` request body. #[test] - fn advance_next_entry_request_json_format_unchanged() { + fn advance_next_entry_request_json_format() { let req = AdvanceNextEntryRequest { size: 11, hash: Hash([0xef; HASH_SIZE]), @@ -620,7 +589,7 @@ mod tests { /// Pin the wire shape of the `/commit` request body. #[test] - fn commit_request_json_format_unchanged() { + fn commit_request_json_format() { let req = CommitRequest { size: 7, hash: Hash([0xcc; HASH_SIZE]), diff --git a/crates/mirror_worker/wrangler.jsonc b/crates/mirror_worker/wrangler.jsonc index 02f30b59..8e6c2ec4 100644 --- a/crates/mirror_worker/wrangler.jsonc +++ b/crates/mirror_worker/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "tlog-mirror", + "name": "mirror-worker", "main": "build/worker/shim.mjs", "compatibility_date": "2025-09-25", "workers_dev": false, @@ -44,10 +44,69 @@ // via the c2sp.org/tlog-tiles read interface. "r2_buckets": [ { - "bucket_name": "tlog-mirror-public-dev", + "bucket_name": "mirror-worker-public-dev", "binding": "PUBLIC_BUCKET" } ], + "version_metadata": { + "binding": "VERSION_METADATA" + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MirrorState", "MirrorCleaner"] + } + ] + }, + "witness": { + "build": { + "command": "cargo install -q worker-build@0.8.5 && DEPLOY_ENV=witness RUSTFLAGS='-Cllvm-args=-wasm-use-legacy-eh' worker-build --release --panic-unwind" + }, + "workers_dev": true, + "durable_objects": { + "bindings": [ + { + "name": "MIRROR_STATE", + "class_name": "MirrorState" + } + ] + }, + "version_metadata": { + "binding": "VERSION_METADATA" + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MirrorState"] + } + ] + }, + "mirror": { + "build": { + "command": "cargo install -q worker-build@0.8.5 && DEPLOY_ENV=mirror RUSTFLAGS='-Cllvm-args=-wasm-use-legacy-eh' worker-build --release --panic-unwind" + }, + "workers_dev": true, + "durable_objects": { + "bindings": [ + { + "name": "MIRROR_STATE", + "class_name": "MirrorState" + }, + { + "name": "MIRROR_CLEANER", + "class_name": "MirrorCleaner" + } + ] + }, + "r2_buckets": [ + { + "bucket_name": "mirror-worker-public-dev", + "binding": "PUBLIC_BUCKET" + } + ], + "version_metadata": { + "binding": "VERSION_METADATA" + }, "migrations": [ { "tag": "v1", diff --git a/crates/tlog_checkpoint/src/lib.rs b/crates/tlog_checkpoint/src/lib.rs index c890275e..7998380e 100644 --- a/crates/tlog_checkpoint/src/lib.rs +++ b/crates/tlog_checkpoint/src/lib.rs @@ -443,7 +443,7 @@ impl CheckpointSigner for Ed25519CheckpointSigner { /// one* of a set of trusted keys has signed — for example, a /// c2sp.org/tlog-witness witness accepting a rotated log key — should call /// [`Note::verify`] directly and implement the origin and timestamp -/// checks themselves; see `witness_worker` for an example. +/// checks themselves; see `mirror_worker` for an example. /// /// # Errors /// diff --git a/crates/witness_worker/.dev.vars b/crates/witness_worker/.dev.vars deleted file mode 100644 index 2b4f551f..00000000 --- a/crates/witness_worker/.dev.vars +++ /dev/null @@ -1 +0,0 @@ -WITNESS_SIGNING_KEY="-----BEGIN PRIVATE KEY-----\nMDQCAQAwCwYJYIZIAWUDBAMRBCKAIEJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJC\nQkJCQkJC\n-----END PRIVATE KEY-----\n" diff --git a/crates/witness_worker/Cargo.toml b/crates/witness_worker/Cargo.toml deleted file mode 100644 index 487964c1..00000000 --- a/crates/witness_worker/Cargo.toml +++ /dev/null @@ -1,58 +0,0 @@ -[package] -name = "witness_worker" -publish = false -version.workspace = true -authors.workspace = true -edition.workspace = true -license.workspace = true -homepage.workspace = true -repository.workspace = true -description = "A transparency-log witness (c2sp.org/tlog-witness) on Cloudflare Workers" -categories = ["cryptography"] -keywords = ["transparency", "witness", "crypto", "pki"] - -[package.metadata.release] -release = false - -# https://github.com/rustwasm/wasm-pack/issues/1351 -[package.metadata.wasm-pack.profile.dev.wasm-bindgen] -dwarf-debug-info = true - -[lib] -crate-type = ["cdylib"] - -[build-dependencies] -config = { path = "./config", package = "witness_worker_config" } -worker_build_config.workspace = true - -[dependencies] -axum.workspace = true -config = { path = "./config", package = "witness_worker_config" } -console_error_panic_hook.workspace = true -console_log.workspace = true -ed25519-dalek.workspace = true -generic_log_worker.workspace = true -getrandom.workspace = true -getrandom_03.workspace = true -log.workspace = true -ml-dsa.workspace = true -pkcs8.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_with.workspace = true -signed_note.workspace = true -tlog_checkpoint.workspace = true -tlog_core.workspace = true -tlog_cosignature.workspace = true -tlog_witness.workspace = true -tower-service.workspace = true -worker = { workspace = true, features = ["http", "axum"] } - -[dev-dependencies] -base64.workspace = true -p256.workspace = true - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = [ - 'cfg(wasm_bindgen_unstable_test_coverage)', -] } diff --git a/crates/witness_worker/LICENSE b/crates/witness_worker/LICENSE deleted file mode 120000 index 30cff740..00000000 --- a/crates/witness_worker/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE \ No newline at end of file diff --git a/crates/witness_worker/README.md b/crates/witness_worker/README.md deleted file mode 100644 index b613c074..00000000 --- a/crates/witness_worker/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Transparency Log Witness Worker - -A [`c2sp.org/tlog-witness`](https://c2sp.org/tlog-witness) implementation on -[Cloudflare Workers](https://workers.cloudflare.com/). - -A *witness* cosigns transparency-log checkpoints after checking them for -consistency with previously-observed state. Clients (typically the log -itself) call `POST /add-checkpoint` with a new checkpoint and a consistency -proof; the witness verifies the proof, atomically records the new latest -state, and returns a timestamped `cosignature/v1` signature. - -The wire-format parsers and serializers live in the -[`tlog_witness`](../tlog_witness/) crate (published to crates.io); this crate -is the Cloudflare Workers-specific shell that wires them up to HTTP, Durable -Object storage for per-origin state, and a secret-managed Ed25519 signing -key. - -## Known limitations - -- Cosignatures are Ed25519 only (the only algorithm for `cosignature/v1` - per c2sp.org/tlog-cosignature v1). -- Only logs that sign checkpoints with Ed25519 are accepted. - -## Configuration - -See [`config.schema.json`](config.schema.json). Each entry under `logs` gives -the origin line the witness will match against incoming checkpoints and one -or more DER-encoded `SubjectPublicKeyInfo` blobs (base64-encoded in config) -for keys that may sign checkpoints. The witness's own identity is configured -at the top level as `witness_name`; its signing key is provided out-of-band -as a `WITNESS_SIGNING_KEY` secret. - -## Development - -Requires `node` and `npm`. - -```bash -# Run locally -npx wrangler -e=dev dev - -# Reset local state between runs -./reset-dev.sh -``` - -## License - -The project is licensed under the [BSD-3-Clause License](./LICENSE). diff --git a/crates/witness_worker/build.rs b/crates/witness_worker/build.rs deleted file mode 100644 index 8c89721a..00000000 --- a/crates/witness_worker/build.rs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2025 Cloudflare, Inc. -// Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause - -// Build script to include per-environment witness configuration. - -use config::AppConfig; - -fn main() { - let loaded = worker_build_config::load::(include_str!("config.schema.json")); - - // Run the canonical validation defined in the config crate. This - // covers signed-note key-name constraints on `witness_name` and - // every log origin, plus Ed25519 SPKI parsing and `(name, key_id)` - // collision detection on `log_public_keys`. Failures surface as - // build errors with operator-readable messages. - loaded.config.validate().unwrap_or_else(|e| { - panic!("witness worker config failed validation: {e}"); - }); - loaded.stage(); -} diff --git a/crates/witness_worker/config.dev.json b/crates/witness_worker/config.dev.json deleted file mode 100644 index fda2dd4d..00000000 --- a/crates/witness_worker/config.dev.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "logging_level": "info", - "witness_name": "dev.witness.example", - "description": "Local-dev witness for smoke testing", - "submission_prefix": "http://localhost:8787/", - "monitoring_prefix": "http://localhost:8787/", - "logs": { - "example.com/log1": { - "description": "Dev-only log. SPKI matches the keypair embedded in tests/tlog_witness.rs.", - "log_public_keys": [ - "MCowBQYDK2VwAyEAzdnT3CQc3ag2fmKnO1ntodIm0wfsymDkK89IOrHKLWc=" - ] - } - } -} diff --git a/crates/witness_worker/config.schema.json b/crates/witness_worker/config.schema.json deleted file mode 100644 index a2aea210..00000000 --- a/crates/witness_worker/config.schema.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "required": ["witness_name", "submission_prefix", "logs"], - "additionalProperties": false, - "properties": { - "logging_level": { - "type": "string", - "enum": ["trace", "debug", "info", "warn", "error"] - }, - "witness_name": { - "type": "string", - "description": "The witness's identity, as it appears in cosignature lines. Per c2sp.org/signed-note, this MUST NOT contain the '+', whitespace, or control characters.", - "pattern": "^[^+\\s]+$" - }, - "description": { - "type": "string" - }, - "submission_prefix": { - "type": "string", - "description": "URL prefix for write APIs (e.g. https://witness.example/)." - }, - "monitoring_prefix": { - "type": "string", - "description": "URL prefix for read APIs. Currently unused by the tlog-witness spec but reserved for future monitor-facing endpoints." - }, - "logs": { - "type": "object", - "description": "Logs this witness will accept checkpoints for, keyed by the log's origin line (the first line of a checkpoint note). The origin is used as a signed-note key name at runtime, so per c2sp.org/signed-note it MUST NOT contain '+', whitespace, or control characters.", - "propertyNames": { - "pattern": "^[^+\\s]+$" - }, - "additionalProperties": { - "type": "object", - "required": ["log_public_keys"], - "additionalProperties": false, - "properties": { - "description": {"type": "string"}, - "log_public_keys": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "description": "A DER-encoded SubjectPublicKeyInfo, base64-encoded. The witness accepts checkpoint signatures from any of these keys and ignores signatures from others." - } - } - } - } - } - } -} diff --git a/crates/witness_worker/config/Cargo.toml b/crates/witness_worker/config/Cargo.toml deleted file mode 100644 index abd7ce7a..00000000 --- a/crates/witness_worker/config/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "witness_worker_config" -publish = false -version.workspace = true -authors.workspace = true -edition.workspace = true -license.workspace = true -readme.workspace = true -homepage.workspace = true -repository.workspace = true -description = "Configuration for witness_worker" - -[dependencies] -ed25519-dalek.workspace = true -serde.workspace = true -serde_with.workspace = true -signed_note.workspace = true diff --git a/crates/witness_worker/config/LICENSE b/crates/witness_worker/config/LICENSE deleted file mode 120000 index 5853aaea..00000000 --- a/crates/witness_worker/config/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../../LICENSE \ No newline at end of file diff --git a/crates/witness_worker/config/src/lib.rs b/crates/witness_worker/config/src/lib.rs deleted file mode 100644 index f8bc3168..00000000 --- a/crates/witness_worker/config/src/lib.rs +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) 2025 Cloudflare, Inc. -// Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause - -//! Configuration for [`witness_worker`](../witness_worker/). -//! -//! A [`c2sp.org/tlog-witness`][spec] witness is configured with a list of -//! logs it trusts. Each entry gives the log's origin (the first line of its -//! checkpoint notes) and one or more SPKI-encoded public keys that the witness -//! will accept signatures from on an incoming checkpoint. -//! -//! The witness's own signing key is supplied out-of-band as a secret named -//! `WITNESS_SIGNING_KEY` (see the worker's `.dev.vars` in dev mode; use -//! `wrangler secret put WITNESS_SIGNING_KEY` for real deployments). -//! -//! [spec]: https://c2sp.org/tlog-witness - -use ed25519_dalek::VerifyingKey as Ed25519VerifyingKey; -use ed25519_dalek::pkcs8::DecodePublicKey as _; -use serde::Deserialize; -use serde_with::{base64::Base64, serde_as}; -use signed_note::{Ed25519NoteVerifier, KeyName, NoteVerifier}; -use std::collections::{BTreeSet, HashMap}; - -/// Top-level worker configuration, deserialized from `config..json`. -#[derive(Deserialize, Debug)] -pub struct AppConfig { - pub logging_level: Option, - /// The witness's own identity. The name appears in every cosignature line - /// the witness produces. - pub witness_name: String, - /// Human-readable description for operator dashboards. - pub description: Option, - /// URL prefix for write APIs (`add-checkpoint`). Published in the - /// `/metadata` response so clients know where to send requests. - pub submission_prefix: String, - /// URL prefix for read APIs. Currently unused by the tlog-witness spec - /// but reserved for future monitor-facing endpoints. Published in - /// `/metadata` alongside `submission_prefix`; `None` means "same as - /// `submission_prefix`". - pub monitoring_prefix: Option, - /// Logs the witness is configured to cosign, keyed by the log's - /// `origin` line — the first line of a checkpoint note, and the stable - /// public identifier of the log. Using `origin` directly as the map - /// key makes uniqueness an invariant of the JSON shape itself (a - /// duplicate entry is a duplicate JSON object key, which the - /// deserializer rejects), so the worker doesn't need a runtime - /// duplicate-origin check. - pub logs: HashMap, -} - -impl AppConfig { - /// Validate the configuration beyond what `serde` and the JSON schema - /// can express. - /// - /// Specifically, this checks: - /// - /// 1. `witness_name` is a valid signed-note key name (per - /// [c2sp.org/signed-note][note]: non-empty, no whitespace, no `+`). - /// 2. Every log `origin` (i.e. every key in [`Self::logs`]) is a valid - /// signed-note key name. - /// 3. Every entry in `log_public_keys` is a parseable Ed25519 SPKI. - /// 4. Within a single log entry, no two `log_public_keys` collide on - /// `(name, key_id)` — a `key_id` is a 32-bit hash so a collision is - /// cosmically unlikely, but if one occurred the witness could not - /// disambiguate signatures and every checkpoint for that log would - /// fail to verify. - /// - /// Origin uniqueness across log entries is *not* checked here because - /// it is already enforced by the JSON object shape: duplicate keys in - /// `logs` are duplicate JSON object keys, which `serde_json` rejects - /// at deserialization time. - /// - /// # Errors - /// - /// Returns a human-readable error string identifying the failing - /// field and reason. The error is intended for operator consumption - /// (build-script panic messages, deployment-time logging) and is not - /// machine-parseable. - /// - /// [note]: https://c2sp.org/signed-note - pub fn validate(&self) -> Result<(), String> { - // (1) witness_name is a valid signed-note key name. - KeyName::new(self.witness_name.clone()).map_err(|e| { - format!( - "witness_name {:?} is not a valid signed-note key name: {e:?}", - self.witness_name, - ) - })?; - - // (2-4) per-log validation. - for (origin, log) in &self.logs { - log.validate(origin)?; - } - - Ok(()) - } -} - -/// Per-log parameters: the public keys the witness is willing to accept -/// checkpoint signatures from. The log's `origin` is the key in the parent -/// [`AppConfig::logs`] map and is not stored here. -#[serde_as] -#[derive(Deserialize, Debug)] -pub struct LogParams { - /// Optional free-text description. - pub description: Option, - /// One or more DER-encoded `SubjectPublicKeyInfo` blobs for keys that may - /// sign checkpoints for this log. The witness verifies incoming - /// checkpoints against this list and ignores signatures from other keys. - /// Typically one entry, but multiple are permitted for key rotation. - #[serde_as(as = "Vec")] - pub log_public_keys: Vec>, -} - -impl LogParams { - /// Validate this log's `origin` and `log_public_keys`. Called by - /// [`AppConfig::validate`] for each entry; takes the origin (which - /// lives in the parent map) as an argument. - /// - /// # Errors - /// - /// Returns a human-readable error string. See [`AppConfig::validate`] - /// for the list of conditions checked. - pub fn validate(&self, origin: &str) -> Result<(), String> { - // Origin must be a valid signed-note key name. - let origin_name = KeyName::new(origin.to_owned()).map_err(|e| { - format!("log {origin:?}: origin is not a valid signed-note key name: {e:?}") - })?; - - // Every log_public_keys entry must be a parseable Ed25519 SPKI, - // and the (name, key_id) pairs derived from them must be unique - // within this log. - let mut seen_ids: BTreeSet = BTreeSet::new(); - for (i, spki) in self.log_public_keys.iter().enumerate() { - let vk = Ed25519VerifyingKey::from_public_key_der(spki).map_err(|e| { - format!("log {origin:?}: log_public_keys[{i}] is not a valid Ed25519 SPKI: {e}") - })?; - let v = Ed25519NoteVerifier::new(origin_name.clone(), vk); - if !seen_ids.insert(v.key_id()) { - return Err(format!( - "log {origin:?}: log_public_keys[{i}] shares a (name, key_id) pair with an \ - earlier key; witness would be unable to disambiguate signatures from it", - )); - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Generate a real Ed25519 SPKI deterministically from a seed byte. - fn spki_for(seed: u8) -> Vec { - use ed25519_dalek::pkcs8::EncodePublicKey as _; - let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]); - sk.verifying_key().to_public_key_der().unwrap().to_vec() - } - - fn good_app_config() -> AppConfig { - AppConfig { - logging_level: None, - witness_name: "witness.example/w".to_owned(), - description: None, - submission_prefix: "https://witness.example/".to_owned(), - monitoring_prefix: None, - logs: HashMap::from([( - "example.com/log1".to_owned(), - LogParams { - description: None, - log_public_keys: vec![spki_for(1)], - }, - )]), - } - } - - /// Helper: construct a fresh single-log config and let the caller mutate. - fn with_log(f: F) -> AppConfig { - let mut cfg = good_app_config(); - let log = cfg.logs.values_mut().next().unwrap(); - f(log); - cfg - } - - #[test] - fn validate_accepts_minimal_good_config() { - good_app_config() - .validate() - .expect("known-good config validates"); - } - - #[test] - fn validate_rejects_empty_witness_name() { - let mut cfg = good_app_config(); - cfg.witness_name = String::new(); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("witness_name"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_witness_name_with_plus() { - let mut cfg = good_app_config(); - cfg.witness_name = "witness+example".to_owned(); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("witness_name"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_witness_name_with_ascii_whitespace() { - let mut cfg = good_app_config(); - cfg.witness_name = "witness example".to_owned(); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("witness_name"), "unexpected error: {err}"); - } - - /// `KeyName::new` rejects via `char::is_whitespace`, which covers - /// Unicode whitespace beyond the ASCII set that JSON Schema's `\s` - /// pattern catches. Pin this so the schema regex and the runtime - /// constructor stay in agreement. - #[test] - fn validate_rejects_witness_name_with_unicode_whitespace() { - let mut cfg = good_app_config(); - cfg.witness_name = "witness\u{00a0}name".to_owned(); // U+00A0 NBSP - let err = cfg.validate().unwrap_err(); - assert!(err.contains("witness_name"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_empty_origin() { - let mut cfg = good_app_config(); - let log = cfg.logs.drain().next().unwrap().1; - cfg.logs.insert(String::new(), log); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("origin") && err.contains("\"\""), - "unexpected error: {err}", - ); - } - - #[test] - fn validate_rejects_origin_with_plus() { - let mut cfg = good_app_config(); - let log = cfg.logs.drain().next().unwrap().1; - cfg.logs.insert("example.com/with+plus".to_owned(), log); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("origin"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_origin_with_unicode_whitespace() { - let mut cfg = good_app_config(); - let log = cfg.logs.drain().next().unwrap().1; - cfg.logs.insert("example.com\u{00a0}log1".to_owned(), log); - let err = cfg.validate().unwrap_err(); - assert!(err.contains("origin"), "unexpected error: {err}"); - } - - #[test] - fn validate_rejects_invalid_spki() { - let cfg = with_log(|log| log.log_public_keys = vec![b"not-der".to_vec()]); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("log_public_keys[0]") && err.contains("Ed25519 SPKI"), - "unexpected error: {err}", - ); - } - - #[test] - fn validate_accepts_multiple_distinct_keys() { - let cfg = with_log(|log| log.log_public_keys = vec![spki_for(1), spki_for(2)]); - cfg.validate().expect("two distinct Ed25519 keys are valid"); - } - - /// Re-using the same SPKI in `log_public_keys` produces an identical - /// `(name, key_id)` and so trips the collision check. This is a - /// realistic operator misconfiguration (paste-twice during key - /// rotation), distinct from the cosmically-rare 32-bit `key_id` hash - /// collision. - #[test] - fn validate_rejects_duplicate_keys_within_log() { - let cfg = with_log(|log| log.log_public_keys = vec![spki_for(1), spki_for(1)]); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("log_public_keys[1]") && err.contains("(name, key_id)"), - "unexpected error: {err}", - ); - } - - #[test] - fn validate_includes_origin_in_per_log_errors() { - let cfg = with_log(|log| log.log_public_keys = vec![b"junk".to_vec()]); - let err = cfg.validate().unwrap_err(); - assert!( - err.contains("example.com/log1"), - "error should reference the failing origin: {err}", - ); - } -} diff --git a/crates/witness_worker/reset-dev.sh b/crates/witness_worker/reset-dev.sh deleted file mode 100755 index db07f172..00000000 --- a/crates/witness_worker/reset-dev.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -rm -rf .wrangler/state diff --git a/crates/witness_worker/src/frontend_worker.rs b/crates/witness_worker/src/frontend_worker.rs deleted file mode 100644 index 683e9cc5..00000000 --- a/crates/witness_worker/src/frontend_worker.rs +++ /dev/null @@ -1,676 +0,0 @@ -// Copyright (c) 2025 Cloudflare, Inc. -// Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause - -//! HTTP entry point + handler for the witness worker. -//! -//! Routes: -//! -//! - `POST /add-checkpoint` — [c2sp.org/tlog-witness#add-checkpoint][add]. -//! - `POST /sign-subtree` — [c2sp.org/tlog-witness#sign-subtree][signsub]. -//! Registered only when the witness is configured with an ML-DSA-44 -//! key (`subtree/v1` cosigner). Ed25519 deployments don't expose the -//! route at all, so requests fall through to the default 404 handler. -//! -//! The witness's per-origin persistent state lives in a [`WitnessState`] -//! Durable Object; see [`crate::witness_state_do`] for details. Atomicity of -//! the "check old-size, verify proof, update latest, return cosignature" -//! sequence follows from the DO's single-threaded fetch handler. -//! -//! `/sign-subtree` does NOT touch the DO; it uses **stateless** checkpoint -//! verification (see [`sign_subtree`]) — the submitted checkpoint must -//! carry one of the witness's own past `subtree/v1` cosignatures. -//! -//! [add]: https://c2sp.org/tlog-witness#add-checkpoint -//! [signsub]: https://c2sp.org/tlog-witness#sign-subtree -//! [`WitnessState`]: crate::witness_state_do - -use generic_log_worker::{ - frontend::request_metrics, - obs::{Wshim, metrics}, - util::now_millis, -}; -use signed_note::{NoteError, NoteVerifier, VerifierList}; -use tlog_checkpoint::{CheckpointSigner, CheckpointText}; -use tlog_core::Subtree; -use tlog_witness::{ - AddCheckpointRequest, CONTENT_TYPE_TLOG_SIZE, SignSubtreeRequest, parse_add_checkpoint_request, - parse_sign_subtree_request, serialize_add_checkpoint_response, serialize_sign_subtree_response, -}; -#[allow(clippy::wildcard_imports)] -use worker::*; - -use crate::{ - CONFIG, WitnessSigner, load_witness_public_key_der, load_witness_signer, log_verifiers, - witness_state_do::{CheckAndUpdateRequest, LatestCheckpoint, state_stub}, -}; -use axum::{ - Json, Router, - body::Bytes, - extract::State, - http::{StatusCode, header}, - middleware, - response::IntoResponse, - routing::{get, post}, -}; -use serde::Serialize; -use serde_with::{base64::Base64 as Base64As, serde_as}; -use tower_service::Service; - -/// Entry point: initialize logging and dispatch to the router. -#[event(start)] -fn start() { - let level = match CONFIG.logging_level.as_deref().unwrap_or("info") { - "trace" => log::Level::Trace, - "debug" => log::Level::Debug, - "warn" => log::Level::Warn, - "error" => log::Level::Error, - _ => log::Level::Info, - }; - console_error_panic_hook::set_once(); - let _ = console_log::init_with_level(level); -} - -/// Top-level `#[event(fetch)]` handler. Delegates to the axum [`router`]. -#[event(fetch, respond_with_errors)] -async fn fetch( - req: HttpRequest, - env: Env, - ctx: Context, -) -> Result> { - crate::init_sentry(&env); - let wshim = Wshim::from_env(&env); - let registry = metrics::registry(); - let response = generic_log_worker::obs::sentry::catch_unwind_and_flush(async { - Router::new() - .route("/add-checkpoint", post(add_checkpoint)) - .route("/sign-subtree", post(sign_subtree)) - .route("/metadata", get(metadata)) - .route("/", get(root)) - .layer(middleware::from_fn_with_state( - (env.clone(), metrics::FrontendWorkerMetrics::new(®istry)), - request_metrics, - )) - .with_state(env) - .call(req) - .await - }) - .await?; - generic_log_worker::obs::sentry::flush().await; - if let Ok(wshim) = wshim { - ctx.wait_until(async move { - wshim.flush(&generic_log_worker::obs::logs::LOGGER).await; - wshim.flush(®istry).await; - }); - } - Ok(response) -} - -/// `GET /` -- witness identity string. -async fn root() -> impl IntoResponse { - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - format!("{} — c2sp.org/tlog-witness witness\n", CONFIG.witness_name), - ) -} - -/// Result type for axum handlers in this module: each handler builds a -/// [`worker::Response`] which is converted to an axum response at the -/// boundary via the `From` impl from the worker crate's -/// `axum` feature. -type ApiResult = std::result::Result; - -enum AppError { - InternalServerError(String), - BadRequest(String), - NotFound, - UnknownLogOrigin, - NoValidSignatures, - ReferenceCheckpointNotCosignedByThisWitness, - SubtreeConsistencyProofFailed, -} - -impl From for AppError { - fn from(err: worker::Error) -> Self { - Self::InternalServerError(err.to_string()) - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> axum::response::Response { - match self { - AppError::InternalServerError(error) => { - log::error!("unhandled error: {error}"); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - AppError::BadRequest(e) => { - (StatusCode::BAD_REQUEST, format!("Bad request: {e}")).into_response() - } - AppError::NotFound => (StatusCode::NOT_FOUND, "Not Found").into_response(), - AppError::UnknownLogOrigin => { - (StatusCode::NOT_FOUND, "Unknown log origin").into_response() - } - AppError::NoValidSignatures => ( - StatusCode::FORBIDDEN, - "No valid signatures from trusted log keys", - ) - .into_response(), - AppError::ReferenceCheckpointNotCosignedByThisWitness => ( - StatusCode::FORBIDDEN, - "Reference checkpoint is not cosigned by this witness", - ) - .into_response(), - AppError::SubtreeConsistencyProofFailed => ( - StatusCode::UNPROCESSABLE_ENTITY, - "Subtree consistency proof failed", - ) - .into_response(), - } - } -} - -/// Response body for the `/metadata` endpoint. -/// -/// Publishes the witness's identity and the per-log configuration so clients -/// can learn what logs this witness cosigns and what URL prefixes to use. -#[serde_as] -#[derive(Serialize)] -struct MetadataResponse<'a> { - witness_name: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option<&'a str>, - /// DER-encoded `SubjectPublicKeyInfo` for the witness's verifying - /// key. The signature algorithm matches whatever - /// `WITNESS_SIGNING_KEY` was loaded with — Ed25519 (cosignature/v1) - /// or ML-DSA-44 (subtree/v1); see [`WitnessSigner`]. - /// - /// [`WitnessSigner`]: crate::WitnessSigner - #[serde_as(as = "Base64As")] - witness_public_key: &'a [u8], - submission_prefix: &'a str, - monitoring_prefix: &'a str, - logs: Vec>, -} - -#[serde_as] -#[derive(Serialize)] -struct LogMetadata<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - description: Option<&'a str>, - origin: &'a str, - /// DER-encoded `SubjectPublicKeyInfo` blobs for the log's trusted keys. - #[serde_as(as = "Vec")] - log_public_keys: Vec<&'a [u8]>, -} - -/// `GET /metadata` route handler. -#[worker::send] -async fn metadata(State(env): State) -> ApiResult { - let witness_public_key = load_witness_public_key_der(&env)?; - let logs: Vec = CONFIG - .logs - .iter() - .map(|(origin, p)| LogMetadata { - description: p.description.as_deref(), - origin, - log_public_keys: p.log_public_keys.iter().map(Vec::as_slice).collect(), - }) - .collect(); - let body = MetadataResponse { - witness_name: &CONFIG.witness_name, - description: CONFIG.description.as_deref(), - witness_public_key, - submission_prefix: &CONFIG.submission_prefix, - monitoring_prefix: CONFIG - .monitoring_prefix - .as_deref() - .unwrap_or(&CONFIG.submission_prefix), - logs, - }; - Ok((StatusCode::OK, Json(body))) -} - -/// `POST /add-checkpoint` route handler. -/// -/// The flow mirrors the MUSTs listed in the spec, in order: -/// -/// 1. Parse the request body (malformed → 400). -/// 2. Look up the log by origin; if unknown → 404. -/// 3. Verify the checkpoint carries at least one signature from a trusted -/// log key; if none → 403. Unknown signatures are silently ignored. -/// 4. Range check: `old_size <= checkpoint.size` → else 400. -/// 5. Atomic check-and-update against persisted state (via the -/// [`WitnessState`] DO): if `old_size` doesn't match the stored size → -/// 409 with the current size in a `text/x.tlog.size` body. -/// 6. If `old_size == checkpoint.size`, the stored root hash must equal the -/// incoming root hash — otherwise → 409 (same body). -/// 7. Verify the consistency proof; on failure → 422. -/// 8. Persist the new state atomically, then produce a `cosignature/v1` -/// signature and return the signature line as the response body. -/// -/// Steps 5 and 6 are combined inside the DO's `/check-and-update` RPC so the -/// "check then write" pair is atomic per origin. -/// -/// [`WitnessState`]: crate::witness_state_do -#[worker::send] -async fn add_checkpoint(State(env): State, body: Bytes) -> ApiResult { - // (1) Parse. - // - // Cap the request body at `MAX_ADD_CHECKPOINT_BODY_SIZE` so a - // malicious or misconfigured client can't make the worker buffer - // arbitrary data in memory. A well-formed request is an `old ` - // line + up to 63 base64 hash lines + a blank line + a checkpoint - // note (capped at `signed_note::MAX_NOTE_SIZE = 1 MiB`); anything - // larger is guaranteed to be rejected downstream and we avoid the - // allocation by rejecting it here. - if body.len() > MAX_ADD_CHECKPOINT_BODY_SIZE { - return Err(AppError::BadRequest(format!( - "body exceeds {MAX_ADD_CHECKPOINT_BODY_SIZE} bytes" - ))); - } - let AddCheckpointRequest { - old_size, - consistency_proof, - checkpoint, - } = match parse_add_checkpoint_request(&body) { - Ok(r) => r, - Err(e) => { - log::warn!("add-checkpoint: malformed request: {e}"); - return Err(AppError::BadRequest(e.to_string())); - } - }; - // (2) Parse the checkpoint body and look up the log by its origin. - // - // `CheckpointText::from_bytes` validates the full checkpoint shape - // (origin, decimal size, base64 root hash, extensions) and exposes - // the parsed origin; we use that — rather than a second, looser - // parse of `checkpoint.text().lines().next()` — for the log lookup - // so the two views cannot disagree. - let cp_text = match CheckpointText::from_bytes(checkpoint.text()) { - Ok(t) => t, - Err(e) => { - log::warn!("add-checkpoint: malformed checkpoint text: {e:?}"); - return Err(AppError::BadRequest(e.to_string())); - } - }; - let origin = cp_text.origin(); - let Some(verifiers) = log_verifiers(origin) else { - return Err(AppError::UnknownLogOrigin); - }; - // (3) Verify the checkpoint signature against trusted log keys. - // - // Per c2sp.org/tlog-witness, the witness accepts the checkpoint as - // soon as at least one of the trusted log keys has signed it; - // signatures from unknown keys are silently ignored. Both - // `UnverifiedNote` (no signature line matches a trusted key at all) - // and `InvalidSignature` (a signature line matches a trusted `(name, - // id)` but the signature bytes fail to verify — a malformed note per - // c2sp.org/signed-note) are surfaced as `403 Forbidden`, matching the - // behavior of sunlight's and sigsum-go's reference witnesses. Other - // `NoteError` variants indicate a syntactically malformed signature - // line and are surfaced as `400 Bad Request`. - // - // `tlog_checkpoint::open_checkpoint` is deliberately not used here - // because it additionally requires *every* configured verifier to sign - // — the full-coverage semantics an issuer or monitor wants, but not a - // witness: a log rotating keys may have multiple trusted public keys - // of which any single one signing is sufficient. - let now = now_millis(); - if let Err(e) = checkpoint.verify(&verifiers) { - match e { - NoteError::UnverifiedNote | NoteError::InvalidSignature { .. } => { - log::info!("add-checkpoint: rejecting note: {e:?}"); - return Err(AppError::NoValidSignatures); - } - _ => { - log::warn!("add-checkpoint: verify failed: {e:?}"); - return Err(AppError::BadRequest(e.to_string())); - } - } - } - // (4) Range check. - if old_size > cp_text.size() { - return Err(AppError::BadRequest(format!( - "old_size {old_size} > checkpoint size {}", - cp_text.size() - ))); - } - // (5, 6, 7) Atomic check-proof-and-update against the per-origin DO. - // See [`dispatch_check_and_update`] for the status-code mapping. - let update = CheckAndUpdateRequest { - old_size, - new_size: cp_text.size(), - new_hash: *cp_text.hash(), - proof: consistency_proof, - }; - if let Some(resp) = dispatch_check_and_update(&env, origin, &update).await? { - return Ok(resp); - } - // (8) Produce and return the cosignature. - // - // The witness's algorithm (Ed25519 or ML-DSA-44) is determined at - // load time from the OID in the WITNESS_SIGNING_KEY PKCS#8 PEM; - // both `cosignature/v1` and `subtree/v1` implement - // `CheckpointSigner::sign` and the spec defines the - // `add-checkpoint` cosignature equivalently for both — the - // `subtree/v1` form covers the entire submitted tree - // (`start = 0, end = checkpoint.size`). - let signer = load_witness_signer(&env)?; - let note_sig = signer - .as_checkpoint_signer() - .sign(now, &cp_text) - .map_err(|e| Error::from(format!("signing: {e:?}")))?; - let body = serialize_add_checkpoint_response(std::slice::from_ref(¬e_sig)); - Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body).into_response()) -} - -/// `POST /sign-subtree` handler. -/// -/// OPTIONAL endpoint per [c2sp.org/tlog-witness#sign-subtree][spec]. -/// Wired up only when the witness is configured with an ML-DSA-44 -/// signing key — Ed25519 deployments respond 404. Verification of the -/// reference checkpoint is **stateless**: the witness checks that the -/// submitted checkpoint carries one of its own past `subtree/v1` -/// cosignatures (covering the whole tree). The two other strategies -/// the spec lists (recently-cached checkpoints and full-tree state -/// access) are not implemented; the stateless approach is sufficient -/// because the cosigner produced by `add-checkpoint` is the witness -/// itself. -/// -/// The stateless approach intentionally does NOT re-verify the -/// original log signature on the reference checkpoint. That's safe -/// because this witness only ever produces a self-cosignature inside -/// `add-checkpoint`, which itself requires a valid log signature, so -/// any checkpoint carrying this witness's `subtree/v1` line is -/// guaranteed to have been log-signed at cosign time. Per the spec's -/// threat model, `/sign-subtree` therefore inherits the trust window -/// of `/add-checkpoint`: if the witness's signing key is ever -/// compromised, both endpoints become forgeable in lockstep. -/// -/// Flow: -/// -/// 1. Parse the request body (malformed → 400). -/// 2. Bound checks: `start < end` and `end ≤ checkpoint.size` → else 400. -/// 3. Parse the reference checkpoint as a [`CheckpointText`]; look up -/// the log by origin → 404 if unknown. -/// 4. Stateless verification: the submitted checkpoint MUST carry at -/// least one valid `subtree/v1` cosignature from this witness's own -/// key; otherwise 403. -/// 5. Verify the subtree consistency proof from the subtree to the -/// reference checkpoint root → 422 on failure. -/// 6. Sign the subtree (`timestamp = 0`, per the spec) and return -/// the resulting `subtree/v1` signature line. -/// -/// [spec]: https://c2sp.org/tlog-witness#sign-subtree -#[allow(clippy::too_many_lines)] -// single-handler pattern; mirrors add-checkpoint. -// Owned `env`/`body` keep this signature symmetric with the async -// `add_checkpoint_inner`; being synchronous it would otherwise trip -// `needless_pass_by_value` for values it only reads. -#[allow(clippy::needless_pass_by_value)] -#[worker::send] -async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult { - // The handler exists in the route table for both algorithms, but the - // spec marks the endpoint as OPTIONAL: an Ed25519 witness simply - // doesn't support it. Surface that as 404, matching the spec's - // "unknown URL" treatment. - let signer = load_witness_signer(&env)?; - let WitnessSigner::SubtreeV1 { - signer: subtree_signer, - .. - } = signer - else { - return Err(AppError::NotFound); - }; - - // (1) Parse the body. - if body.len() > MAX_SIGN_SUBTREE_BODY_SIZE { - return Err(AppError::BadRequest(format!( - "body exceeds {MAX_SIGN_SUBTREE_BODY_SIZE} bytes" - ))); - } - let SignSubtreeRequest { - subtree_start, - subtree_end, - subtree_hash, - subtree_cosignatures: _, - consistency_proof, - checkpoint, - } = match parse_sign_subtree_request(&body) { - Ok(r) => r, - Err(e) => { - log::warn!("sign-subtree: malformed request: {e}"); - return Err(AppError::BadRequest(e.to_string())); - } - }; - - // (2) Bound checks. Subtree validity (start < end, alignment) is - // checked together with end ≤ size below by `Subtree::new` plus the - // explicit `end > size` test. The wire-format parser already - // enforces no leading zeros in the decimal encoding. - let cp_text = match CheckpointText::from_bytes(checkpoint.text()) { - Ok(t) => t, - Err(e) => { - log::warn!("sign-subtree: malformed checkpoint text: {e:?}"); - return Err(AppError::BadRequest(e.to_string())); - } - }; - if subtree_end > cp_text.size() { - return Err(AppError::BadRequest(format!( - "subtree end {subtree_end} > checkpoint size {}", - cp_text.size(), - ))); - } - let subtree = match Subtree::new(subtree_start, subtree_end) { - Ok(s) => s, - Err(e) => { - return Err(AppError::BadRequest(format!("invalid subtree: {e:?}"))); - } - }; - - // (3) Look up the log by its origin. Subtree cosignatures from - // other known witnesses (the optional DoS-protection cosigs in the - // request) are intentionally ignored: this implementation does not - // pre-screen requests on them and the spec leaves their use up to - // the witness operator. - let origin = cp_text.origin(); - if log_verifiers(origin).is_none() { - return Err(AppError::UnknownLogOrigin); - } - - // (4) Stateless verification: the submitted checkpoint MUST carry - // one of this witness's own past `subtree/v1` cosignatures. The - // verifier from the witness signer reconstructs the cosigned - // message from the checkpoint's origin/size/hash with start = 0 - // and end = size and rejects anything else. - let witness_verifier: Box = subtree_signer.verifier(); - if let Err(e) = checkpoint.verify(&VerifierList::new(vec![witness_verifier])) { - match e { - NoteError::UnverifiedNote | NoteError::InvalidSignature { .. } => { - log::info!("sign-subtree: rejecting note: {e:?}"); - return Err(AppError::ReferenceCheckpointNotCosignedByThisWitness); - } - _ => { - log::warn!("sign-subtree: verify failed: {e:?}"); - return Err(AppError::BadRequest(e.to_string())); - } - } - } - - // (5) Verify the subtree consistency proof. - if tlog_core::verify_subtree_consistency_proof( - &consistency_proof, - cp_text.size(), - *cp_text.hash(), - &subtree, - subtree_hash, - ) - .is_err() - { - return Err(AppError::SubtreeConsistencyProofFailed); - } - - // (6) Sign the subtree. Per the spec the timestamp on a non-zero- - // start cosignature MUST be zero; for the start = 0 (whole-tree) - // case the spec allows non-zero but we use zero uniformly here so - // there's no "is this the checkpoint case?" branch in the handler. - // Note that the witness has just verified one of its own past - // cosignatures on the checkpoint, so producing this subtree - // signature is bound by the same verification window — the request - // is meaningful even with a zero timestamp. - let note_sig = subtree_signer.sign_subtree(0, origin, &subtree, &subtree_hash); - Ok(( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - serialize_sign_subtree_response(std::slice::from_ref(¬e_sig)), - )) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// Both `MAX_*_BODY_SIZE` constants below are the per-handler cap -// inside the worker. The Workers runtime imposes its own platform -// cap (100 MB on Free / Standard / Workers Paid; configurable on -// Enterprise) before any of our code runs, so peak buffered memory -// is bounded by the platform first; these constants tighten that -// to the worst-case well-formed request shape so a malicious or -// misconfigured client can't push the parser into base64-decoding -// tens of megabytes of garbage. - -/// Maximum size we are willing to buffer from an incoming `add-checkpoint` -/// request body. A well-formed request is an `old ` line + up to -/// [`tlog_witness::MAX_CONSISTENCY_PROOF_LINES`] (63) base64 hash lines + -/// a blank line + a checkpoint note of up to `signed_note::MAX_NOTE_SIZE` -/// (1 MiB); 1 MiB + 16 KiB of envelope headroom comfortably covers that -/// and rejects anything obviously too large before it is allocated. -const MAX_ADD_CHECKPOINT_BODY_SIZE: usize = 1_024 * 1_024 + 16 * 1_024; - -/// Maximum size we are willing to buffer from an incoming `sign-subtree` -/// request body. The header section can include up to 8 ML-DSA-44 -/// subtree-cosignature lines (~3.3 KiB each on the wire after base64), -/// up to 63 base64 hash lines, plus a checkpoint note of up to -/// `signed_note::MAX_NOTE_SIZE` (1 MiB) which itself can carry up to 8 -/// ML-DSA-44 checkpoint signatures. 1 MiB + 64 KiB of envelope headroom -/// covers the worst case and rejects anything obviously too large -/// before it is allocated. -const MAX_SIGN_SUBTREE_BODY_SIZE: usize = 1_024 * 1_024 + 64 * 1_024; - -/// POST the `CheckAndUpdateRequest` to the per-origin DO, translating the -/// DO's status code into either: -/// * `Ok(None)` — success (200); the caller should proceed to cosign. -/// * `Ok(Some(resp))` — the DO responded with a non-200 status that maps -/// directly to the `add-checkpoint` HTTP response (409 with -/// `text/x.tlog.size` body, 422, or a forwarded 400). -/// * `Err(_)` — transport-level failure. -async fn dispatch_check_and_update( - env: &Env, - origin: &str, - update: &CheckAndUpdateRequest, -) -> Result> { - let stub = state_stub(env, origin)?; - let mut resp = stub - .fetch_with_request(Request::new_with_init( - "http://do/check-and-update", - &RequestInit { - method: Method::Post, - body: Some(serde_json::to_string(update)?.into()), - headers: { - let h = Headers::new(); - h.set("content-type", "application/json")?; - h - }, - ..Default::default() - }, - )?) - .await?; - match resp.status_code() { - 200 => { - // Drain the body so we can drop the response. - let _ = resp.bytes().await?; - Ok(None) - } - 409 => { - let current: LatestCheckpoint = resp.json().await?; - Ok(Some(tlog_size_conflict(¤t))) - } - 422 => Ok(Some( - ( - StatusCode::UNPROCESSABLE_ENTITY, - "Unprocessable Entity: consistency proof failed", - ) - .into_response(), - )), - 400 => { - let msg = resp.text().await.unwrap_or_else(|_| "Bad request".into()); - Ok(Some(AppError::BadRequest(msg).into_response())) - } - status => Ok(Some( - AppError::InternalServerError(format!("Internal error: DO returned {status}")) - .into_response(), - )), - } -} - -/// Build the 409 response body per the spec: -/// `text/x.tlog.size` content type, decimal latest size followed by a newline. -fn tlog_size_conflict(current: &LatestCheckpoint) -> axum::response::Response { - let body = format!("{}\n", current.size); - ( - StatusCode::CONFLICT, - [(header::CONTENT_TYPE, CONTENT_TYPE_TLOG_SIZE)], - body, - ) - .into_response() -} - -#[cfg(test)] -mod tests { - use super::{LogMetadata, MetadataResponse}; - - /// `description` is optional in the `/metadata` response. When absent - /// it MUST be omitted from the JSON body (not serialized as `null`) - /// so the wire shape matches the c2sp.org/tlog-witness expectation. - #[test] - fn metadata_description_omitted_when_none() { - let log = LogMetadata { - description: None, - origin: "example.com/log", - log_public_keys: vec![b"spki".as_slice()], - }; - let body = MetadataResponse { - witness_name: "example.com/witness", - description: None, - witness_public_key: b"witness-spki", - submission_prefix: "https://witness.example.com/", - monitoring_prefix: "https://witness.example.com/", - logs: vec![log], - }; - let json = serde_json::to_string(&body).unwrap(); - assert!( - !json.contains("\"description\""), - "description should be omitted when None, got: {json}" - ); - } - - #[test] - fn metadata_description_present_when_some() { - let log = LogMetadata { - description: Some("a log"), - origin: "example.com/log", - log_public_keys: vec![b"spki".as_slice()], - }; - let body = MetadataResponse { - witness_name: "example.com/witness", - description: Some("a witness"), - witness_public_key: b"witness-spki", - submission_prefix: "https://witness.example.com/", - monitoring_prefix: "https://witness.example.com/", - logs: vec![log], - }; - let json = serde_json::to_string(&body).unwrap(); - assert!(json.contains("\"description\":\"a witness\""), "{json}"); - assert!(json.contains("\"description\":\"a log\""), "{json}"); - } -} diff --git a/crates/witness_worker/src/lib.rs b/crates/witness_worker/src/lib.rs deleted file mode 100644 index 71ab1dc0..00000000 --- a/crates/witness_worker/src/lib.rs +++ /dev/null @@ -1,501 +0,0 @@ -// Copyright (c) 2025 Cloudflare, Inc. -// Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause - -//! A transparency-log witness implementing [c2sp.org/tlog-witness][spec] on -//! Cloudflare Workers. -//! -//! The witness exposes two endpoints today: -//! -//! - [`add-checkpoint`][add]: a client (typically the log itself) submits -//! a new checkpoint along with a consistency proof from the witness's -//! latest recorded state, and the witness returns a timestamped -//! cosignature over the checkpoint. -//! - [`sign-subtree`][signsub] (OPTIONAL, only when the witness is -//! configured with an ML-DSA-44 key): a client asks the witness to -//! cosign a subtree `[start, end)` of a tree the witness has previously -//! cosigned, supplying a consistency proof from the subtree to a -//! reference checkpoint. The witness returns a `subtree/v1` cosignature. -//! -//! Per-log state (the latest cosigned tree size and root hash) is persisted -//! in a [`WitnessState`] Durable Object, one per log origin. The DO's -//! single-threaded execution model provides the atomic "check-old-size, -//! update-latest, return-cosignature" sequence that the spec requires. -//! -//! # Cosignature algorithm -//! -//! A given witness deployment runs with a single signing algorithm, -//! selected by the OID embedded in the `WITNESS_SIGNING_KEY` PKCS#8 -//! secret: -//! -//! - `id-Ed25519` (`1.3.101.112`): the witness produces -//! [`cosignature/v1`][cosig] signatures and exposes only -//! `add-checkpoint`. Requests to `sign-subtree` return 404. -//! - `id-ml-dsa-44` (`2.16.840.1.101.3.4.3.17`): the witness produces -//! [`subtree/v1`][cosig] signatures and exposes both endpoints. The -//! `add-checkpoint` response is a `subtree/v1` cosignature over the -//! whole tree (start = 0, end = checkpoint size), per the -//! [tlog-witness] spec. -//! -//! Operators choose the algorithm by which key they generate and load. -//! There is no separate config field — the OID is the source of truth. -//! -//! [spec]: https://c2sp.org/tlog-witness -//! [add]: https://c2sp.org/tlog-witness#add-checkpoint -//! [signsub]: https://c2sp.org/tlog-witness#sign-subtree -//! [cosig]: https://c2sp.org/tlog-cosignature -//! [tlog-witness]: https://c2sp.org/tlog-witness -//! [`WitnessState`]: witness_state_do::WitnessState - -use config::AppConfig; -use ed25519_dalek::{ - SigningKey as Ed25519SigningKey, - pkcs8::{DecodePrivateKey as _, DecodePublicKey as _, EncodePublicKey as _}, -}; -use ml_dsa::MlDsa44; -use pkcs8::{ - PrivateKeyInfoRef, SecretDocument, - der::oid::db::{fips204::ID_ML_DSA_44, rfc8410::ID_ED_25519}, -}; -use signed_note::{Ed25519NoteVerifier, KeyName, NoteVerifier, VerifierList}; -use std::collections::HashMap; -use std::sync::{LazyLock, OnceLock}; -use tlog_cosignature::{CosignatureV1CheckpointSigner, SubtreeV1CheckpointSigner}; -#[allow(clippy::wildcard_imports)] -use worker::*; - -pub(crate) use generic_log_worker::obs::sentry::init_from_env as init_sentry; - -mod frontend_worker; -mod witness_state_do; - -/// The binding name used in `wrangler.jsonc` for the [`WitnessState`] DO. -/// -/// [`WitnessState`]: witness_state_do::WitnessState -pub(crate) const WITNESS_STATE_BINDING: &str = "WITNESS_STATE"; - -/// The compile-time-embedded worker configuration. -pub(crate) static CONFIG: LazyLock = LazyLock::new(|| { - serde_json::from_str(include_str!(concat!(env!("OUT_DIR"), "/config.json"))) - .expect("config.json must be valid at build time") -}); - -/// Per-origin cache of the parsed trusted log keys. -/// -/// `build.rs` calls [`AppConfig::validate`] and refuses to compile a -/// witness with a malformed config, so by the time this static is built -/// every origin and SPKI is known to parse cleanly. The `expect` calls -/// below treat parse failures as `unreachable!` rather than as recoverable -/// errors. -/// -/// Values are plain `(KeyName, VerifyingKey)` pairs rather than a -/// pre-built `VerifierList`, because `Box` is not -/// `Sync` and so cannot live inside a `LazyLock`. Building the -/// `VerifierList` per request from these cached keys is cheap -/// (`Ed25519NoteVerifier::new` is just field assignment). -pub(crate) static LOG_KEYS: LazyLock>> = LazyLock::new(|| { - CONFIG - .logs - .iter() - .map(|(origin, log)| (origin.clone(), parse_log_keys(origin, log))) - .collect() -}); - -/// A parsed trusted log key. -#[derive(Clone)] -pub(crate) struct LogKey { - pub origin: KeyName, - pub verifying_key: ed25519_dalek::VerifyingKey, -} - -/// Build a list of parsed keys for a single configured log. -/// -/// Both fields (origin as a [`KeyName`], each SPKI as an Ed25519 -/// `VerifyingKey`) are validated up front by -/// [`config::AppConfig::validate`] in `build.rs`, so the parse calls -/// below are guarded by `expect` rather than recoverable error -/// propagation. A panic here would indicate `validate` and this function -/// have drifted out of sync. -fn parse_log_keys(origin: &str, log: &config::LogParams) -> Vec { - let origin_name = KeyName::new(origin.to_owned()) - .expect("origin validated as a signed-note KeyName by AppConfig::validate"); - log.log_public_keys - .iter() - .map(|spki| { - let verifying_key = ed25519_dalek::VerifyingKey::from_public_key_der(spki) - .expect("SPKI validated as Ed25519 by AppConfig::validate"); - LogKey { - origin: origin_name.clone(), - verifying_key, - } - }) - .collect() -} - -/// Build a [`VerifierList`] for a given origin from the cached keys, or -/// `None` if no log is configured at that origin. -pub(crate) fn log_verifiers(origin: &str) -> Option { - let keys = LOG_KEYS.get(origin)?; - let verifiers: Vec> = keys - .iter() - .map(|k| { - Box::new(Ed25519NoteVerifier::new(k.origin.clone(), k.verifying_key)) - as Box - }) - .collect(); - Some(VerifierList::new(verifiers)) -} - -/// The witness's signing material plus the cosignature algorithm derived -/// from the OID embedded in the loaded PKCS#8 PEM. -/// -/// One variant per supported cosignature format. The handler dispatches -/// on this enum: `add-checkpoint` works for both, and `sign-subtree` -/// is wired up only for [`Self::SubtreeV1`]. -/// -/// Each variant carries the signer plus the DER-encoded -/// `SubjectPublicKeyInfo` for the matching verifying key. The SPKI is -/// computed once at construction and reused by `/metadata` so we don't -/// re-encode the verifying key on every request, and so the signer -/// types themselves don't need to expose their internal verifying keys. -/// -/// Both signer fields are boxed so the enum has a small, balanced -/// stack footprint — the expanded ML-DSA-44 key is ~64 KiB and even -/// the Ed25519 path's signer is ~470 bytes, both large enough that -/// indirection is worth the single allocation at load time. -pub(crate) enum WitnessSigner { - /// Ed25519 / [`cosignature/v1`][spec]. - /// - /// [spec]: https://c2sp.org/tlog-cosignature - CosignatureV1 { - signer: Box, - public_key_der: Vec, - }, - /// ML-DSA-44 / [`subtree/v1`][spec]. Supports both the checkpoint - /// case (via `add-checkpoint`) and arbitrary subtrees (via - /// `sign-subtree`). - /// - /// [spec]: https://c2sp.org/tlog-cosignature - SubtreeV1 { - signer: Box, - public_key_der: Vec, - }, -} - -impl WitnessSigner { - /// Return the DER-encoded `SubjectPublicKeyInfo` for the witness's - /// verifying key, in whatever algorithm this signer was loaded with. - pub(crate) fn public_key_der(&self) -> &[u8] { - match self { - Self::CosignatureV1 { public_key_der, .. } | Self::SubtreeV1 { public_key_der, .. } => { - public_key_der - } - } - } - - /// Return a reference to the inner [`CheckpointSigner`] trait object - /// for the `add-checkpoint` path, which is algorithm-agnostic. - pub(crate) fn as_checkpoint_signer(&self) -> &dyn tlog_checkpoint::CheckpointSigner { - match self { - Self::CosignatureV1 { signer, .. } => &**signer, - Self::SubtreeV1 { signer, .. } => &**signer, - } - } -} - -/// Cached witness signer, built lazily on first request. -/// -/// Held as a `OnceLock` so the PKCS#8 parse + algorithm -/// dispatch happens at most once per worker instance. Subsequent -/// requests reuse the parsed key. -static WITNESS_SIGNER: OnceLock = OnceLock::new(); - -/// Load (or return the already-cached) witness signer. -/// -/// The signing algorithm is derived from the OID in the -/// `WITNESS_SIGNING_KEY` PKCS#8 PEM secret: -/// -/// - `id-Ed25519` → [`WitnessSigner::CosignatureV1`]. -/// - `id-ml-dsa-44` → [`WitnessSigner::SubtreeV1`]. -/// -/// # Errors -/// -/// Returns an error if the `WITNESS_SIGNING_KEY` secret is missing, -/// the PEM is malformed, the OID is neither Ed25519 nor ML-DSA-44, or -/// the configured `witness_name` is not a valid signed-note key name. -pub(crate) fn load_witness_signer(env: &Env) -> Result<&'static WitnessSigner> { - if let Some(s) = WITNESS_SIGNER.get() { - return Ok(s); - } - // Concurrent cold-start requests will each parse the PEM and expand - // the ML-DSA-44 key, dropping the loser's result. Deduplication - // would need `OnceLock::get_or_try_init` (unstable, rust-lang/rust#109737). - let pem = env.secret("WITNESS_SIGNING_KEY")?.to_string(); - let signer = build_witness_signer(&pem)?; - Ok(WITNESS_SIGNER.get_or_init(|| signer)) -} - -/// Build a [`WitnessSigner`] from a PKCS#8 PEM string. -/// -/// Split out from [`load_witness_signer`] so unit tests can exercise the -/// algorithm dispatch without a `worker::Env`. -fn build_witness_signer(pem: &str) -> Result { - let name = KeyName::new(CONFIG.witness_name.clone()) - .map_err(|e| Error::from(format!("invalid witness_name: {e:?}")))?; - let (_label, doc) = - SecretDocument::from_pem(pem).map_err(|e| Error::from(format!("PEM parse: {e}")))?; - let pk_info = PrivateKeyInfoRef::try_from(doc.as_bytes()) - .map_err(|e| Error::from(format!("PrivateKeyInfo parse: {e}")))?; - match pk_info.algorithm.oid { - ID_ED_25519 => { - let key = Ed25519SigningKey::from_pkcs8_pem(pem) - .map_err(|e| Error::from(format!("Ed25519 PKCS#8 parse: {e}")))?; - let public_key_der = key - .verifying_key() - .to_public_key_der() - .map_err(|e| Error::from(format!("Ed25519 SPKI encode: {e}")))? - .to_vec(); - Ok(WitnessSigner::CosignatureV1 { - signer: Box::new(CosignatureV1CheckpointSigner::new(name, key)), - public_key_der, - }) - } - ID_ML_DSA_44 => { - // ml-dsa's PKCS#8 stores only the 32-byte seed; the - // `ExpandedSigningKey` `TryFrom` impl - // (used by `from_pkcs8_pem`) expands it on the way in. The - // expanded key never leaves this worker. - let expanded = ml_dsa::ExpandedSigningKey::::from_pkcs8_pem(pem) - .map_err(|e| Error::from(format!("ML-DSA-44 PKCS#8 parse: {e}")))?; - let public_key_der = expanded - .verifying_key() - .to_public_key_der() - .map_err(|e| Error::from(format!("ML-DSA-44 SPKI encode: {e}")))? - .to_vec(); - Ok(WitnessSigner::SubtreeV1 { - signer: Box::new(SubtreeV1CheckpointSigner::new(name, expanded)), - public_key_der, - }) - } - oid => Err(Error::from(format!( - "unsupported WITNESS_SIGNING_KEY algorithm OID {oid}: \ - expected id-Ed25519 ({ID_ED_25519}) or id-ml-dsa-44 ({ID_ML_DSA_44})" - ))), - } -} - -/// Return the DER-encoded `SubjectPublicKeyInfo` for the witness's own -/// verifying key. Used by the `/metadata` endpoint so clients can learn -/// the witness's identity without hitting a separate endpoint. The SPKI -/// was computed once at signer construction and stored alongside the -/// signer in [`WitnessSigner`]. -/// -/// # Errors -/// -/// Returns an error if the signing key is not available. -pub(crate) fn load_witness_public_key_der(env: &Env) -> Result<&'static [u8]> { - Ok(load_witness_signer(env)?.public_key_der()) -} - -#[cfg(test)] -mod tests { - //! Unit tests for the OID-dispatching signer loader. - //! - //! `build_witness_signer` consumes a PKCS#8 PEM and returns a - //! [`WitnessSigner`] whose variant is dictated entirely by the OID - //! in the PEM's `AlgorithmIdentifier`. These tests cover both - //! supported algorithms and the error path for an unsupported OID. - - use super::{WitnessSigner, build_witness_signer}; - use ed25519_dalek::pkcs8::EncodePrivateKey as _; - - /// Generate a deterministic Ed25519 PEM from a seed byte. - fn ed25519_pem(seed: u8) -> String { - let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]); - sk.to_pkcs8_pem(pkcs8::LineEnding::LF) - .expect("encode PEM") - .to_string() - } - - /// Generate a deterministic ML-DSA-44 PEM from a seed byte. - /// - /// Uses the seed-only PKCS#8 encoding (the same format the - /// `RustCrypto` `ml-dsa` crate emits and that an operator would - /// produce with `openssl genpkey -algorithm ML-DSA-44`) so this - /// exercises the real load path. - fn ml_dsa_44_pem(seed: u8) -> String { - use ml_dsa::SigningKey; - use pkcs8::EncodePrivateKey as _; - let sk = SigningKey::::from_seed(&ml_dsa::B32::from([seed; 32])); - sk.to_pkcs8_pem(pkcs8::LineEnding::LF) - .expect("encode ML-DSA-44 PEM") - .to_string() - } - - #[test] - fn ed25519_pem_dispatches_to_cosignature_v1() { - let signer = build_witness_signer(&ed25519_pem(1)).expect("build Ed25519 signer"); - assert!(matches!(signer, WitnessSigner::CosignatureV1 { .. })); - assert!( - !signer.public_key_der().is_empty(), - "Ed25519 SPKI must be non-empty", - ); - } - - #[test] - fn ml_dsa_44_pem_dispatches_to_subtree_v1() { - let signer = build_witness_signer(&ml_dsa_44_pem(2)).expect("build ML-DSA-44 signer"); - assert!(matches!(signer, WitnessSigner::SubtreeV1 { .. })); - assert!( - !signer.public_key_der().is_empty(), - "ML-DSA-44 SPKI must be non-empty", - ); - } - - /// A P-256 PKCS#8 PEM — a well-formed, supported-by-pkcs8 algorithm - /// that this witness intentionally doesn't accept. The error message - /// must mention "unsupported" so an operator who pasted the wrong - /// key sees that the algorithm choice is the issue. - #[test] - fn unsupported_oid_is_rejected_with_helpful_error() { - // Generate a P-256 PKCS#8 PEM at test time so we don't bake an - // arbitrary key blob into the source. - use p256::elliptic_curve::Generate as _; - use p256::pkcs8::EncodePrivateKey as _; - let sk = p256::SecretKey::generate(); - let pem = sk - .to_pkcs8_pem(pkcs8::LineEnding::LF) - .expect("encode P-256 PEM"); - let msg = match build_witness_signer(&pem) { - Ok(_) => panic!("must reject P-256"), - Err(e) => e.to_string(), - }; - assert!( - msg.contains("unsupported"), - "error must mention the algorithm is unsupported: {msg}", - ); - } - - #[test] - fn malformed_pem_is_rejected() { - let msg = match build_witness_signer("not a PEM") { - Ok(_) => panic!("must reject"), - Err(e) => e.to_string(), - }; - assert!( - msg.to_lowercase().contains("pem"), - "error must mention PEM: {msg}", - ); - } -} - -#[cfg(test)] -mod dev_config_tests { - //! Tests that pin invariants among the dev fixtures: - //! - //! - `config.dev.json` lists the log's Ed25519 public key. The same - //! public key is derived at test time from the log signing PEM - //! embedded in `crates/integration_tests/tests/tlog_witness.rs`, - //! and we pin the SPKI against it. - //! - `.dev.vars` carries the witness's ML-DSA-44 signing key. We - //! pin its byte content against a fixed PKCS#8 PEM constant in - //! this module so a rotation of either side is caught - //! immediately. The integration tests learn the witness's - //! public key at runtime from `/metadata`, so they don't need - //! their own copy of the witness PEM. - //! - //! If `dev_config_spki_matches_embedded_pem` fails, rotate the - //! dev log key in `config.dev.json` together with the - //! `LOG_SIGNING_KEY_PEM` constant in - //! `crates/integration_tests/tests/tlog_witness.rs`. - //! - //! If `dev_vars_witness_key_matches_embedded_pem` fails, rotate - //! the witness key in `.dev.vars` together with the - //! `DEV_WITNESS_SIGNING_KEY_PEM` constant below. - - use base64::prelude::*; - use ed25519_dalek::pkcs8::{DecodePrivateKey as _, EncodePublicKey as _}; - - /// The raw JSON contents of `config.dev.json`. Read at test time - /// rather than via `CONFIG`, because `CONFIG` is built from the - /// `OUT_DIR/config.json` copy that `build.rs` stages based on - /// `$DEPLOY_ENV`, which may not be `dev` during `cargo test`. - const DEV_CONFIG: &str = include_str!("../config.dev.json"); - - /// Raw `.dev.vars` content; read at test time so we can verify the - /// witness PEM has not drifted from the integration-test - /// constant. Wrangler-style key=quoted-value file with a single - /// `WITNESS_SIGNING_KEY` line. - const DEV_VARS: &str = include_str!("../.dev.vars"); - - /// Dev log PEM (Ed25519). MUST match the constant in - /// `crates/integration_tests/tests/tlog_witness.rs`; duplicated here - /// so this unit test can fail closed without `integration_tests` - /// being in scope. If you rotate the dev log key, update both copies - /// and the SPKI in `config.dev.json`. - const DEV_LOG_SIGNING_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MC4CAQAwBQYDK2VwBCIEIA2VCmSeCNVJTboEACcXvVahZHSHEJDxSl94aej1Q8hQ\n\ - -----END PRIVATE KEY-----\n"; - - /// Dev witness PEM (ML-DSA-44 seed-only PKCS#8). MUST match the - /// value of `WITNESS_SIGNING_KEY` in `.dev.vars`. The seed here is - /// `[0x42; 32]` — deterministic, repo-public, dev-only. To rotate, - /// change the seed, regenerate the PEM, and update both - /// `.dev.vars` and this constant. The integration tests pick up - /// the new public key automatically from `/metadata`; they don't - /// need updating. - const DEV_WITNESS_SIGNING_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MDQCAQAwCwYJYIZIAWUDBAMRBCKAIEJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJC\n\ - QkJCQkJC\n\ - -----END PRIVATE KEY-----\n"; - - #[test] - fn dev_config_spki_matches_embedded_pem() { - // Extract the first (and only) log's first public key from - // config.dev.json without pulling in the full config parser — - // this keeps the test robust to unrelated config-shape changes. - let parsed: serde_json::Value = serde_json::from_str(DEV_CONFIG).unwrap(); - let b64 = parsed["logs"]["example.com/log1"]["log_public_keys"][0] - .as_str() - .expect("config.dev.json must have logs[\"example.com/log1\"].log_public_keys[0]"); - let config_spki = BASE64_STANDARD.decode(b64).expect("SPKI is base64"); - - // Derive the SPKI from the PEM and compare. - let sk = ed25519_dalek::SigningKey::from_pkcs8_pem(DEV_LOG_SIGNING_KEY_PEM) - .expect("parse dev log PEM"); - let derived_spki = sk.verifying_key().to_public_key_der().unwrap().to_vec(); - - assert_eq!( - config_spki, derived_spki, - "config.dev.json SPKI and DEV_LOG_SIGNING_KEY_PEM have drifted; \ - a future integration-test run will 403", - ); - } - - /// Pin the relationship between the witness signing key in - /// `.dev.vars` and the ML-DSA-44 PEM that the integration tests - /// expect. `.dev.vars` stores the PEM with literal `\n` escapes - /// (it's read by wrangler as a key=value file); we unescape them - /// before comparing. - #[test] - fn dev_vars_witness_key_matches_embedded_pem() { - // Find the `WITNESS_SIGNING_KEY="..."` line and extract its - // quoted value with `\n` escapes converted to real newlines. - let line = DEV_VARS - .lines() - .find(|l| l.starts_with("WITNESS_SIGNING_KEY=")) - .expect(".dev.vars must define WITNESS_SIGNING_KEY"); - let quoted = line - .strip_prefix("WITNESS_SIGNING_KEY=") - .unwrap() - .trim_start_matches('"') - .trim_end_matches('"'); - let actual = quoted.replace("\\n", "\n"); - assert_eq!( - actual, DEV_WITNESS_SIGNING_KEY_PEM, - ".dev.vars WITNESS_SIGNING_KEY does not match DEV_WITNESS_SIGNING_KEY_PEM \ - (rotate both together with crates/integration_tests/tests/tlog_witness.rs)", - ); - } -} - -// The `#[event(fetch)]` entry point lives in [`frontend_worker`]. diff --git a/crates/witness_worker/src/witness_state_do.rs b/crates/witness_worker/src/witness_state_do.rs deleted file mode 100644 index a0dcf78c..00000000 --- a/crates/witness_worker/src/witness_state_do.rs +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright (c) 2025 Cloudflare, Inc. -// Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause - -//! [`WitnessState`] Durable Object: per-origin atomic `(size, hash)` storage -//! for the [c2sp.org/tlog-witness][spec] protocol. -//! -//! The spec's [`add-checkpoint`][add] endpoint requires that checking the -//! client's `old` size against the witness's latest cosigned size, verifying -//! the consistency proof, and persisting the new state all happen atomically -//! for a given origin — otherwise concurrent requests could roll back the -//! cosigned state (see the "race" example in the spec). Durable Objects -//! naturally serialize `fetch` handlers per object, so routing all requests -//! for a given origin to the same DO instance gives us that atomicity for -//! free without any explicit locking. -//! -//! Each origin gets its own DO instance (`idFromName(origin)`). The instance -//! holds a single key, `latest`, whose value is the JSON-serialized -//! [`LatestCheckpoint`]. -//! -//! This DO exposes a single internal RPC, consumed only by the frontend -//! handler in the same worker: -//! -//! - `POST /check-and-update` — body is a JSON [`CheckAndUpdateRequest`] -//! carrying the client-claimed `old_size`, the proposed new `size`+`hash`, -//! and the consistency proof lines (as hashes). The DO reads its -//! persisted state, verifies that the recorded size matches `old_size`, -//! verifies the Merkle consistency proof against the stored root hash -//! (when a proof is required), and on success writes the new -//! `(size, hash)` and returns 200 with a [`LatestCheckpoint`] body. On -//! size / same-size-different-hash mismatch it returns 409 with a -//! [`LatestCheckpoint`] body carrying the current state so the caller -//! can produce the spec's `text/x.tlog.size` response. On proof -//! verification failure it returns 422. -//! -//! Verifying the consistency proof in the same handler that reads and -//! writes the stored state keeps the whole sequence — including the -//! comparison against the stored `latest_hash` — inside a single atomic -//! DO transaction. -//! -//! [spec]: https://c2sp.org/tlog-witness -//! [add]: https://c2sp.org/tlog-witness#add-checkpoint - -use serde::{Deserialize, Serialize}; -use tlog_core::{Hash, verify_consistency_proof}; -#[allow(clippy::wildcard_imports)] -use worker::*; - -use crate::WITNESS_STATE_BINDING; - -const STATE_KEY: &str = "latest"; - -/// The persisted state for a single log origin. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] -pub struct LatestCheckpoint { - /// Tree size of the latest checkpoint this witness has cosigned for the - /// origin. Zero if the witness has never cosigned a checkpoint for this - /// origin. - pub size: u64, - /// Root hash of the latest cosigned checkpoint. All-zero if `size` is 0. - #[serde(with = "generic_log_worker::hash_serde::hex")] - pub hash: Hash, -} - -/// Body of the internal `/check-and-update` RPC. -#[derive(Serialize, Deserialize, Debug)] -pub struct CheckAndUpdateRequest { - /// The client-claimed old size; must equal the persisted size or the - /// update is rejected (409 Conflict). - pub old_size: u64, - /// Proposed new tree size. - pub new_size: u64, - /// Proposed new root hash. - #[serde(with = "generic_log_worker::hash_serde::hex")] - pub new_hash: Hash, - /// Consistency proof from `(old_size, stored_hash)` to - /// `(new_size, new_hash)`, per RFC 6962 §2.1.2. MUST be empty if - /// `old_size == 0` or `old_size == new_size`, otherwise MUST verify. - #[serde(with = "generic_log_worker::hash_serde::vec_hex")] - pub proof: Vec, -} - -/// A Durable Object holding the latest cosigned (size, hash) for a single -/// log origin. -#[durable_object(fetch)] -struct WitnessState { - state: State, -} - -// SAFETY: Durable Objects are single-threaded; the `RefUnwindSafe` bound -// is required by `wasm-bindgen` when building with `panic=unwind`. -impl std::panic::RefUnwindSafe for WitnessState {} - -impl DurableObject for WitnessState { - fn new(state: State, env: Env) -> Self { - crate::init_sentry(&env); - Self { state } - } - - async fn fetch(&self, req: Request) -> Result { - generic_log_worker::obs::sentry::catch_unwind_report_and_flush( - &[("handler", "do_fetch"), ("do_type", "witness_state")], - self.fetch_inner(req), - ) - .await - } -} - -impl WitnessState { - async fn fetch_inner(&self, mut req: Request) -> Result { - let path = req.path(); - match (req.method(), path.as_str()) { - (Method::Post, "/check-and-update") => { - // Atomicity of the read-verify-compare-write sequence - // below relies on Cloudflare Durable Objects' input/output - // gates: - // - // * Input gate: while this handler is awaiting, no other - // incoming message for this DO instance is delivered, - // so concurrent /check-and-update requests for the same - // origin cannot interleave. Each request sees a - // consistent view of storage before making its decision. - // - // * Output gate: the response returned from this handler - // is held back until every prior storage write has been - // durably committed. This means the caller is never - // told "we cosigned N+K" before N+K has actually been - // persisted as the new latest size — which rules out - // the rollback race the tlog-witness spec warns about - // (example C there). - // - // See: https://developers.cloudflare.com/durable-objects/reference/in-memory-state/ - // - // Together these two gates make `get(STATE_KEY)` → - // comparisons → consistency-proof verify → `put(STATE_KEY, - // …)` behave as a single atomic transaction per DO without - // us having to write any explicit locking or - // `put_multiple`/`transaction` calls. The proof is - // verified against the exact stored hash that the - // compare-and-swap will check, so there is no TOCTOU - // window. - let body: CheckAndUpdateRequest = req.json().await?; - let current: LatestCheckpoint = self - .state - .storage() - .get(STATE_KEY) - .await? - .unwrap_or_default(); - if current.size != body.old_size { - // Spec: respond with the latest size so the caller can - // build a 409 response body. - return Response::from_json(¤t).map(|r| r.with_status(409)); - } - // If old_size == new_size, the spec requires identical root - // hashes AND the proof MUST be empty. - if body.old_size == body.new_size { - if current.hash.0 != body.new_hash.0 { - return Response::from_json(¤t).map(|r| r.with_status(409)); - } - if !body.proof.is_empty() { - return Response::error( - "consistency proof must be empty when old_size == checkpoint size", - 400, - ); - } - } else if body.old_size == 0 { - // First cosignature for this origin. Per the spec the - // proof MUST be empty. - if !body.proof.is_empty() { - return Response::error( - "consistency proof must be empty when old_size is 0 (first cosignature for this origin)", - 400, - ); - } - } else { - // 0 < old_size < new_size: consistency proof required. - // `verify_consistency_proof` takes the larger tree - // first (n=new_size), then the smaller (m=old_size). - if verify_consistency_proof( - &body.proof, - body.new_size, - body.new_hash, - body.old_size, - current.hash, - ) - .is_err() - { - return Response::error("consistency proof failed", 422); - } - } - let new_state = LatestCheckpoint { - size: body.new_size, - hash: body.new_hash, - }; - self.state.storage().put(STATE_KEY, &new_state).await?; - Response::from_json(&new_state) - } - _ => Response::error("not found", 404), - } - } -} - -/// Lookup helper used by the frontend: get a stub for the DO serving a -/// particular log origin. -pub(crate) fn state_stub(env: &Env, origin: &str) -> Result { - let namespace = env.durable_object(WITNESS_STATE_BINDING)?; - namespace.id_from_name(origin)?.get_stub() -} - -#[cfg(test)] -mod tests { - use super::{CheckAndUpdateRequest, LatestCheckpoint}; - use tlog_core::{HASH_SIZE, Hash}; - - /// Pin the on-disk JSON layout of `LatestCheckpoint`. Changing this - /// format would make already-deployed witnesses unable to read their - /// persisted state after a worker upgrade, so any change here must be - /// paired with a migration plan. - #[test] - fn latest_checkpoint_json_format_unchanged() { - let mut bytes = [0u8; HASH_SIZE]; - for (i, b) in bytes.iter_mut().enumerate() { - *b = u8::try_from(i).unwrap(); - } - let lc = LatestCheckpoint { - size: 42, - hash: Hash(bytes), - }; - let json = serde_json::to_string(&lc).unwrap(); - assert_eq!( - json, - r#"{"size":42,"hash":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}"# - ); - - // Round-trip: an existing state blob must still parse after a - // rebuild. - let decoded: LatestCheckpoint = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.size, 42); - assert_eq!(decoded.hash.0, bytes); - } - - /// Pin the wire shape of the internal DO RPC body. The frontend and - /// the DO are in the same worker, but a format change still needs - /// both sides updated in lockstep. - #[test] - fn check_and_update_request_json_format_unchanged() { - let req = CheckAndUpdateRequest { - old_size: 10, - new_size: 20, - new_hash: Hash([0xaa; HASH_SIZE]), - proof: vec![Hash([0xbb; HASH_SIZE]), Hash([0xcc; HASH_SIZE])], - }; - let json = serde_json::to_string(&req).unwrap(); - assert_eq!( - json, - r#"{"old_size":10,"new_size":20,"new_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","proof":["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]}"# - ); - - let decoded: CheckAndUpdateRequest = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.old_size, 10); - assert_eq!(decoded.new_size, 20); - assert_eq!(decoded.new_hash.0, [0xaa; HASH_SIZE]); - assert_eq!(decoded.proof.len(), 2); - assert_eq!(decoded.proof[0].0, [0xbb; HASH_SIZE]); - assert_eq!(decoded.proof[1].0, [0xcc; HASH_SIZE]); - } - - /// The proof array is empty for first-cosign and same-size cases; make - /// sure it round-trips as `[]` not omitted. - #[test] - fn check_and_update_request_empty_proof_roundtrip() { - let req = CheckAndUpdateRequest { - old_size: 0, - new_size: 1, - new_hash: Hash([0u8; HASH_SIZE]), - proof: vec![], - }; - let json = serde_json::to_string(&req).unwrap(); - assert!( - json.contains(r#""proof":[]"#), - "proof must be serialized as an empty array, got: {json}" - ); - let decoded: CheckAndUpdateRequest = serde_json::from_str(&json).unwrap(); - assert!(decoded.proof.is_empty()); - } - - /// The default `LatestCheckpoint` represents "never cosigned for this - /// origin"; the frontend relies on the zero-sized default when a DO - /// has no stored state. - #[test] - fn latest_checkpoint_default_is_zero() { - let lc = LatestCheckpoint::default(); - assert_eq!(lc.size, 0); - assert_eq!(lc.hash.0, [0u8; HASH_SIZE]); - } -} diff --git a/crates/witness_worker/wrangler.jsonc b/crates/witness_worker/wrangler.jsonc deleted file mode 100644 index e2d6a29b..00000000 --- a/crates/witness_worker/wrangler.jsonc +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "tlog-witness", - "main": "build/worker/shim.mjs", - "compatibility_date": "2025-09-25", - "workers_dev": false, - "build": { - "command": "echo 'Default environment not configured. Please specify an environment with the \"-e\" flag.' && exit 1" - }, - "env": { - "dev": { - "build": { - // DEPLOY_ENV is used in build.rs to select the per-environment - // config. Change '--release' to '--dev' to compile with debug - // symbols. - // RUSTFLAGS: force legacy Wasm EH so wasm-bindgen 0.2.126's - // wasmparser accepts the module (modern exnref opcodes from - // nightly ≥2026-05-09 are not yet supported). - "command": "cargo install -q worker-build@0.8.5 && DEPLOY_ENV=dev RUSTFLAGS='-Cllvm-args=-wasm-use-legacy-eh' worker-build --release --panic-unwind" - }, - "workers_dev": true, - "durable_objects": { - "bindings": [ - { - "name": "WITNESS_STATE", - "class_name": "WitnessState" - } - ] - }, - "version_metadata": { - "binding": "VERSION_METADATA" - }, - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": ["WitnessState"] - } - ] - } - } -} From 8c218086f602bcee358f93b875f91842d733d886 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Fri, 18 Sep 2026 11:02:11 +0100 Subject: [PATCH 02/11] mirror_worker: simplify pending checkpoint match --- crates/mirror_worker/src/add_entries.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mirror_worker/src/add_entries.rs b/crates/mirror_worker/src/add_entries.rs index 43b935d8..7e390346 100644 --- a/crates/mirror_worker/src/add_entries.rs +++ b/crates/mirror_worker/src/add_entries.rs @@ -1007,7 +1007,7 @@ fn resolve_target_pending( snapshot: &MirrorStateSnapshot, verifiers: &signed_note::VerifierList, ) -> std::result::Result { - let Some(pending) = snapshot.pending.as_ref() else { + let Some(pending) = &snapshot.pending else { return Err("no pending checkpoint"); }; if header.upload_end == pending.size { From 468ccc93c95afd0b52ac50f3cc59db207b544ca7 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Fri, 18 Sep 2026 11:03:49 +0100 Subject: [PATCH 03/11] mirror_worker: return 422 for invalid transitions --- .../integration_tests/tests/tlog_witness.rs | 59 ++++++++++++++++++- crates/mirror_worker/src/mirror_state_do.rs | 6 +- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/integration_tests/tests/tlog_witness.rs b/crates/integration_tests/tests/tlog_witness.rs index 355d7291..b51b84a0 100644 --- a/crates/integration_tests/tests/tlog_witness.rs +++ b/crates/integration_tests/tests/tlog_witness.rs @@ -359,8 +359,37 @@ async fn tlog_witness_end_to_end() { let signer = log_signer(); let mut log = ToyLog::new(); + // A size-zero checkpoint must use the RFC 6962 empty-tree hash. + { + let tree = TreeWithTimestamp::new(0, Hash([1u8; HASH_SIZE]), now_millis()); + let cp = tree.sign(LOG_ORIGIN, &[], &[&signer], &mut rng()).unwrap(); + let note = Note::from_bytes(&cp).unwrap(); + let body = serialize_add_checkpoint_request(0, &[], ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 422, + "invalid empty-tree hash: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } + + // The initial checkpoint transition cannot include a consistency proof. + { + log.push(b"leaf 0"); + let cp = log.sign_checkpoint(&signer); + let note = Note::from_bytes(&cp).unwrap(); + let body = serialize_add_checkpoint_request(0, &[Hash([0u8; HASH_SIZE])], ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 422, + "initial non-empty proof: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } + // ----------------------- (2) First submission: old=0 ----------------------- - log.push(b"leaf 0"); { let cp = log.sign_checkpoint(&signer); let note = Note::from_bytes(&cp).unwrap(); @@ -380,6 +409,34 @@ async fn tlog_witness_end_to_end() { verify_witness_signature(¬e, &sigs, &meta); } + // A same-size transition must match the recorded hash and omit the proof. + { + let cp = log.sign_checkpoint(&signer); + let note = Note::from_bytes(&cp).unwrap(); + let body = serialize_add_checkpoint_request(1, &[Hash([0u8; HASH_SIZE])], ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 422, + "same-size non-empty proof: body={:?}", + String::from_utf8_lossy(&r.body) + ); + + let different = TreeWithTimestamp::new(1, Hash([1u8; HASH_SIZE]), now_millis()); + let cp = different + .sign(LOG_ORIGIN, &[], &[&signer], &mut rng()) + .unwrap(); + let note = Note::from_bytes(&cp).unwrap(); + let body = serialize_add_checkpoint_request(1, &[], ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 409, + "same-size hash mismatch: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } + // ----------------------- (3) Second submission with consistency proof ----------------------- let old_size = log.size(); log.push(b"leaf 1"); diff --git a/crates/mirror_worker/src/mirror_state_do.rs b/crates/mirror_worker/src/mirror_state_do.rs index f9c350b5..3fc213ae 100644 --- a/crates/mirror_worker/src/mirror_state_do.rs +++ b/crates/mirror_worker/src/mirror_state_do.rs @@ -240,13 +240,13 @@ impl MirrorState { CheckpointTransitionError::ProofMustBeEmpty(ProofRequirement::SameSize) => { Response::error( "consistency proof must be empty when old_size == checkpoint size", - 400, + 422, ) } CheckpointTransitionError::ProofMustBeEmpty(ProofRequirement::Initial) => { Response::error( "consistency proof must be empty when old_size is 0 (first pending checkpoint for this origin)", - 400, + 422, ) } CheckpointTransitionError::ConsistencyProofFailed => { @@ -254,7 +254,7 @@ impl MirrorState { } CheckpointTransitionError::InvalidEmptyTreeHash => Response::error( "size-zero checkpoint must use the empty-tree hash", - 400, + 422, ), }; } From 49edb38bd9e14720a56ea0d8ad1c6507bea64e6e Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Fri, 18 Sep 2026 11:04:06 +0100 Subject: [PATCH 04/11] mirror_worker: sign accepted checkpoints only --- crates/mirror_worker/src/frontend_worker.rs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 7f83835f..84f57ee3 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -312,17 +312,6 @@ async fn add_checkpoint( }; verify_source_checkpoint(&checkpoint, &verifiers, "add-checkpoint")?; - let witness_signature = if enabled_roles(CONFIG.mode).witness() { - Some( - load_witness_signer(&env)? - .as_checkpoint_signer() - .sign(now_millis(), &checkpoint_text) - .map_err(|error| Error::from(format!("witness signing: {error:?}")))?, - ) - } else { - None - }; - let update = UpdatePendingRequest { old_size, new_size: checkpoint_text.size(), @@ -334,9 +323,13 @@ async fn add_checkpoint( return Ok(response); } - let Some(signature) = witness_signature else { + if !enabled_roles(CONFIG.mode).witness() { return Ok(StatusCode::OK.into_response()); - }; + } + let signature = load_witness_signer(&env)? + .as_checkpoint_signer() + .sign(now_millis(), &checkpoint_text) + .map_err(|error| Error::from(format!("witness signing: {error:?}")))?; Ok(( StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], From fc533a768d7826679bd6a145816564b3b5aa7f3d Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Fri, 18 Sep 2026 11:09:17 +0100 Subject: [PATCH 05/11] mirror_worker: deserialize signed-note key names --- crates/mirror_worker/config/src/lib.rs | 86 ++++++++++++--------- crates/mirror_worker/src/frontend_worker.rs | 8 +- crates/mirror_worker/src/lib.rs | 17 ++-- 3 files changed, 64 insertions(+), 47 deletions(-) diff --git a/crates/mirror_worker/config/src/lib.rs b/crates/mirror_worker/config/src/lib.rs index f8aa9f47..ecb67048 100644 --- a/crates/mirror_worker/config/src/lib.rs +++ b/crates/mirror_worker/config/src/lib.rs @@ -5,7 +5,7 @@ use ed25519_dalek::pkcs8::DecodePublicKey as _; use ml_dsa::{MlDsa44, VerifyingKey as MlDsaVerifyingKey}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::Error as _}; use serde_with::{base64::Base64, serde_as}; use signed_note::{Ed25519NoteVerifier, KeyName, NoteVerifier}; use std::collections::{BTreeSet, HashMap}; @@ -50,20 +50,22 @@ pub struct AppConfig { pub witness: Option, pub mirror: Option, #[serde(deserialize_with = "deserialize_logs")] - pub logs: HashMap, + pub logs: HashMap, } #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct IdentityConfig { - pub name: String, + #[serde(deserialize_with = "deserialize_key_name")] + pub name: KeyName, pub description: Option, } #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct MirrorConfig { - pub name: String, + #[serde(deserialize_with = "deserialize_key_name")] + pub name: KeyName, pub description: Option, pub clean_interval_secs: Option, pub commit_packages: Option, @@ -87,14 +89,22 @@ impl MirrorConfig { } } -fn deserialize_logs<'de, D>(deserializer: D) -> Result, D::Error> +fn deserialize_key_name<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let name = String::deserialize(deserializer)?; + KeyName::new(name).map_err(D::Error::custom) +} + +fn deserialize_logs<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { struct LogsVisitor; impl<'de> serde::de::Visitor<'de> for LogsVisitor { - type Value = HashMap; + type Value = HashMap; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("a map of checkpoint origin to log parameters") @@ -106,6 +116,7 @@ where { let mut logs = HashMap::with_capacity(access.size_hint().unwrap_or(0)); while let Some((origin, params)) = access.next_entry::()? { + let origin = KeyName::new(origin).map_err(serde::de::Error::custom)?; if logs.contains_key(&origin) { return Err(serde::de::Error::custom(format!( "duplicate checkpoint origin {origin:?} in logs" @@ -161,12 +172,6 @@ impl AppConfig { self.mode.as_str() )); } - if let Some(identity) = &self.witness { - validate_identity_name("witness.name", &identity.name)?; - } - if let Some(identity) = &self.mirror { - validate_identity_name("mirror.name", &identity.name)?; - } if let (Some(witness), Some(mirror)) = (&self.witness, &self.mirror) && witness.name == mirror.name { @@ -179,12 +184,6 @@ impl AppConfig { } } -fn validate_identity_name(field: &str, name: &str) -> Result<(), String> { - KeyName::new(name.to_owned()) - .map(|_| ()) - .map_err(|e| format!("{field} {name:?} is not a valid signed-note key name: {e:?}")) -} - #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum CheckpointAlgorithm { #[serde(rename = "ed25519")] @@ -214,15 +213,15 @@ pub struct LogParams { #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct CheckpointSigner { - pub name: String, + #[serde(deserialize_with = "deserialize_key_name")] + pub name: KeyName, pub algorithm: CheckpointAlgorithm, #[serde_as(as = "Base64")] pub public_key: Vec, } impl LogParams { - fn validate(&self, origin: &str) -> Result<(), String> { - validate_identity_name(&format!("log {origin:?} origin"), origin)?; + fn validate(&self, origin: &KeyName) -> Result<(), String> { if self.checkpoint_signers.is_empty() { return Err(format!( "log {origin:?}: checkpoint_signers must not be empty" @@ -230,11 +229,7 @@ impl LogParams { } let mut seen = BTreeSet::new(); for (i, signer) in self.checkpoint_signers.iter().enumerate() { - let name = KeyName::new(signer.name.clone()).map_err(|e| { - format!( - "log {origin:?}: checkpoint_signers[{i}].name is not a valid signed-note key name: {e:?}" - ) - })?; + let name = signer.name.clone(); let key_id = match signer.algorithm { CheckpointAlgorithm::Ed25519 => { let key = ed25519_dalek::VerifyingKey::from_public_key_der(&signer.public_key) @@ -271,6 +266,10 @@ mod tests { use ed25519_dalek::pkcs8::EncodePublicKey as _; use ml_dsa::{Keypair as _, SigningKey}; + fn key_name(name: &str) -> KeyName { + KeyName::new(name.to_owned()).unwrap() + } + fn config(mode: Mode, witness: bool, mirror: bool) -> AppConfig { let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]) .verifying_key() @@ -283,22 +282,22 @@ mod tests { submission_prefix: "https://submit.example/".to_owned(), monitoring_prefix: Some("https://monitor.example/".to_owned()), witness: witness.then(|| IdentityConfig { - name: "witness.example".to_owned(), + name: key_name("witness.example"), description: None, }), mirror: mirror.then(|| MirrorConfig { - name: "mirror.example".to_owned(), + name: key_name("mirror.example"), description: None, clean_interval_secs: None, commit_packages: None, max_chunk_bytes: None, }), logs: HashMap::from([( - "log.example".to_owned(), + key_name("log.example"), LogParams { description: None, checkpoint_signers: vec![CheckpointSigner { - name: "log.example".to_owned(), + name: key_name("log.example"), algorithm: CheckpointAlgorithm::Ed25519, public_key: key, }], @@ -361,11 +360,11 @@ mod tests { let mut config = config(Mode::Witness, true, false); config .logs - .get_mut("log.example") + .get_mut(&key_name("log.example")) .unwrap() .checkpoint_signers .push(CheckpointSigner { - name: "log.example/post-quantum".to_owned(), + name: key_name("log.example/post-quantum"), algorithm: CheckpointAlgorithm::SubtreeV1, public_key: ml_dsa_spki(9), }); @@ -377,7 +376,7 @@ mod tests { let mut wrong_ml = config(Mode::Witness, true, false); let signer = &mut wrong_ml .logs - .get_mut("log.example") + .get_mut(&key_name("log.example")) .unwrap() .checkpoint_signers[0]; signer.algorithm = CheckpointAlgorithm::SubtreeV1; @@ -386,7 +385,7 @@ mod tests { let mut wrong_ed = config(Mode::Witness, true, false); let signer = &mut wrong_ed .logs - .get_mut("log.example") + .get_mut(&key_name("log.example")) .unwrap() .checkpoint_signers[0]; signer.public_key = ml_dsa_spki(10); @@ -398,7 +397,7 @@ mod tests { let mut config = config(Mode::Witness, true, false); config .logs - .get_mut("log.example") + .get_mut(&key_name("log.example")) .unwrap() .checkpoint_signers[0] .public_key = b"not DER".to_vec(); @@ -408,7 +407,7 @@ mod tests { #[test] fn rejects_duplicate_signer_id() { let mut config = config(Mode::Witness, true, false); - let log = config.logs.get_mut("log.example").unwrap(); + let log = config.logs.get_mut(&key_name("log.example")).unwrap(); log.checkpoint_signers.push(CheckpointSigner { name: log.checkpoint_signers[0].name.clone(), algorithm: log.checkpoint_signers[0].algorithm, @@ -424,6 +423,21 @@ mod tests { assert!(config.validate().unwrap_err().contains("must be distinct")); } + #[test] + fn invalid_key_names_fail_deserialization() { + let fixture = include_str!("../../config.witness.json"); + for invalid in [ + fixture.replace("dev.witness.example", "invalid witness"), + fixture.replace("\"example.com/witness-log\":", "\"invalid origin\":"), + fixture.replace( + "\"name\": \"example.com/witness-log\"", + "\"name\": \"invalid signer\"", + ), + ] { + assert!(serde_json::from_str::(&invalid).is_err()); + } + } + #[test] fn standalone_config_fixtures_validate() { for fixture in [ diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 84f57ee3..001231bc 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -222,12 +222,12 @@ fn metadata_logs() -> Vec> { .iter() .map(|(origin, log)| LogMetadata { description: log.description.as_deref(), - origin, + origin: origin.as_str(), checkpoint_signers: log .checkpoint_signers .iter() .map(|signer| CheckpointSignerMetadata { - name: &signer.name, + name: signer.name.as_str(), algorithm: signer.algorithm.as_str(), public_key: &signer.public_key, }) @@ -258,7 +258,7 @@ async fn metadata(State(env): State) -> ApiResult { .expect("validated mirror mode has mirror config"); let signer = load_mirror_signer(&env)?; Some(IdentityMetadata { - name: &identity.name, + name: identity.name.as_str(), description: identity.description.as_deref(), public_key: signer.public_key_der(), algorithm: signer.algorithm(), @@ -288,7 +288,7 @@ fn identity_metadata<'a>( signer: &'a IdentitySigner, ) -> IdentityMetadata<'a> { IdentityMetadata { - name: &identity.name, + name: identity.name.as_str(), description: identity.description.as_deref(), public_key: signer.public_key_der(), algorithm: signer.algorithm(), diff --git a/crates/mirror_worker/src/lib.rs b/crates/mirror_worker/src/lib.rs index f3b9e41b..a0256b59 100644 --- a/crates/mirror_worker/src/lib.rs +++ b/crates/mirror_worker/src/lib.rs @@ -110,7 +110,7 @@ pub(crate) static LOG_KEYS: LazyLock>> = LazyLock::n CONFIG .logs .iter() - .map(|(origin, log)| (origin.clone(), parse_log_keys(log))) + .map(|(origin, log)| (origin.as_str().to_owned(), parse_log_keys(log))) .collect() }); @@ -135,8 +135,7 @@ fn parse_log_keys(log: &config::LogParams) -> Vec { log.checkpoint_signers .iter() .map(|signer| { - let name = KeyName::new(signer.name.clone()) - .expect("checkpoint signer name validated by AppConfig::validate"); + let name = signer.name.clone(); match signer.algorithm { CheckpointAlgorithm::Ed25519 => LogKey::Ed25519 { name, @@ -268,7 +267,11 @@ pub(crate) fn load_mirror_signer(env: &Env) -> Result<&'static IdentitySigner> { return Ok(s); } let pem = env.secret("MIRROR_SIGNING_KEY")?.to_string(); - let signer = build_identity_signer(&CONFIG.mirror_config().name, "MIRROR_SIGNING_KEY", &pem)?; + let signer = build_identity_signer( + CONFIG.mirror_config().name.as_str(), + "MIRROR_SIGNING_KEY", + &pem, + )?; Ok(MIRROR_SIGNER.get_or_init(|| signer)) } @@ -329,7 +332,7 @@ pub(crate) fn load_witness_signer(env: &Env) -> Result<&'static IdentitySigner> .witness .as_ref() .expect("validated witness mode must have witness config"); - let signer = build_identity_signer(&identity.name, "WITNESS_SIGNING_KEY", &pem)?; + let signer = build_identity_signer(identity.name.as_str(), "WITNESS_SIGNING_KEY", &pem)?; Ok(WITNESS_SIGNER.get_or_init(|| signer)) } @@ -486,12 +489,12 @@ mod signer_tests { description: None, checkpoint_signers: vec![ CheckpointSigner { - name: "log.example/ed".to_owned(), + name: KeyName::new("log.example/ed".to_owned()).unwrap(), algorithm: CheckpointAlgorithm::Ed25519, public_key: ed_key.verifying_key().to_public_key_der().unwrap().to_vec(), }, CheckpointSigner { - name: "log.example/ml".to_owned(), + name: KeyName::new("log.example/ml".to_owned()).unwrap(), algorithm: CheckpointAlgorithm::SubtreeV1, public_key: ml_key.verifying_key().to_public_key_der().unwrap().to_vec(), }, From 9442eb784e38e6514177312abbeef6731abbf6a1 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Mon, 21 Sep 2026 10:12:56 -0400 Subject: [PATCH 06/11] mirror_worker: derive mode from configured roles --- crates/mirror_worker/README.md | 7 +- crates/mirror_worker/config.dev.json | 1 - crates/mirror_worker/config.mirror.json | 1 - crates/mirror_worker/config.schema.json | 19 +--- crates/mirror_worker/config.witness.json | 1 - crates/mirror_worker/config/src/lib.rs | 114 ++++++-------------- crates/mirror_worker/src/frontend_worker.rs | 26 ++--- crates/mirror_worker/src/lib.rs | 50 +-------- 8 files changed, 52 insertions(+), 167 deletions(-) diff --git a/crates/mirror_worker/README.md b/crates/mirror_worker/README.md index 7e80eb02..a8cf09c4 100644 --- a/crates/mirror_worker/README.md +++ b/crates/mirror_worker/README.md @@ -7,10 +7,9 @@ with one per-origin `MirrorState` Durable Object. ## Configuration -`mode` is one of `witness`, `mirror`, or `witness-and-mirror`. The matching -`witness` and `mirror` identity sections are required only when that role is -enabled. `logs` is keyed by exact checkpoint origin and supports structured -Ed25519 and `subtree/v1` checkpoint signers. +The presence of the `witness` and `mirror` identity sections enables each role. +`logs` is keyed by exact checkpoint origin and supports structured Ed25519 and +`subtree/v1` checkpoint signers. Role keys remain separate secrets: diff --git a/crates/mirror_worker/config.dev.json b/crates/mirror_worker/config.dev.json index 4a5d60c5..1336d310 100644 --- a/crates/mirror_worker/config.dev.json +++ b/crates/mirror_worker/config.dev.json @@ -1,6 +1,5 @@ { "logging_level": "info", - "mode": "witness-and-mirror", "submission_prefix": "http://localhost:8787/", "monitoring_prefix": "http://localhost:8787/", "witness": { diff --git a/crates/mirror_worker/config.mirror.json b/crates/mirror_worker/config.mirror.json index 88978e36..bdc37c4b 100644 --- a/crates/mirror_worker/config.mirror.json +++ b/crates/mirror_worker/config.mirror.json @@ -1,6 +1,5 @@ { "logging_level": "info", - "mode": "mirror", "submission_prefix": "http://localhost:8789/", "monitoring_prefix": "http://localhost:8789/", "mirror": { diff --git a/crates/mirror_worker/config.schema.json b/crates/mirror_worker/config.schema.json index 5094cbb1..6ec4f03d 100644 --- a/crates/mirror_worker/config.schema.json +++ b/crates/mirror_worker/config.schema.json @@ -1,10 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "required": ["mode", "submission_prefix", "logs"], + "required": ["submission_prefix", "logs"], "additionalProperties": false, "properties": { - "mode": {"type": "string", "enum": ["witness", "mirror", "witness-and-mirror"]}, "logging_level": {"type": "string", "enum": ["trace", "debug", "info", "warn", "error"]}, "submission_prefix": {"type": "string"}, "monitoring_prefix": {"type": "string"}, @@ -38,19 +37,9 @@ } } }, - "allOf": [ - { - "if": {"properties": {"mode": {"const": "witness"}}}, - "then": {"required": ["witness"], "not": {"required": ["mirror"]}} - }, - { - "if": {"properties": {"mode": {"const": "mirror"}}}, - "then": {"required": ["mirror"], "not": {"required": ["witness"]}} - }, - { - "if": {"properties": {"mode": {"const": "witness-and-mirror"}}}, - "then": {"required": ["witness", "mirror"]} - } + "anyOf": [ + {"required": ["witness"]}, + {"required": ["mirror"]} ], "definitions": { "identity": { diff --git a/crates/mirror_worker/config.witness.json b/crates/mirror_worker/config.witness.json index 7cd4d2f7..d880e19a 100644 --- a/crates/mirror_worker/config.witness.json +++ b/crates/mirror_worker/config.witness.json @@ -1,6 +1,5 @@ { "logging_level": "info", - "mode": "witness", "submission_prefix": "http://localhost:8788/", "witness": { "name": "dev.witness.example", diff --git a/crates/mirror_worker/config/src/lib.rs b/crates/mirror_worker/config/src/lib.rs index ecb67048..33f7e96c 100644 --- a/crates/mirror_worker/config/src/lib.rs +++ b/crates/mirror_worker/config/src/lib.rs @@ -11,39 +11,9 @@ use signed_note::{Ed25519NoteVerifier, KeyName, NoteVerifier}; use std::collections::{BTreeSet, HashMap}; use tlog_cosignature::SubtreeV1NoteVerifier; -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum Mode { - Witness, - Mirror, - WitnessAndMirror, -} - -impl Mode { - #[must_use] - pub const fn witness_enabled(self) -> bool { - matches!(self, Self::Witness | Self::WitnessAndMirror) - } - - #[must_use] - pub const fn mirror_enabled(self) -> bool { - matches!(self, Self::Mirror | Self::WitnessAndMirror) - } - - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Witness => "witness", - Self::Mirror => "mirror", - Self::WitnessAndMirror => "witness-and-mirror", - } - } -} - #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct AppConfig { - pub mode: Mode, pub logging_level: Option, pub submission_prefix: String, pub monitoring_prefix: Option, @@ -134,12 +104,22 @@ where impl AppConfig { #[must_use] pub const fn witness_enabled(&self) -> bool { - self.mode.witness_enabled() + self.witness.is_some() } #[must_use] pub const fn mirror_enabled(&self) -> bool { - self.mode.mirror_enabled() + self.mirror.is_some() + } + + #[must_use] + pub const fn mode(&self) -> &'static str { + match (self.witness_enabled(), self.mirror_enabled()) { + (true, true) => "witness-and-mirror", + (true, false) => "witness", + (false, true) => "mirror", + (false, false) => "disabled", + } } #[must_use] @@ -154,23 +134,14 @@ impl AppConfig { .expect("validated mirror mode must have mirror config") } - /// Validate mode-specific identities, signed-note names, algorithms, and keys. + /// Validate role configuration, algorithms, and keys. /// /// # Errors /// /// Returns an operator-readable description of the invalid field. pub fn validate(&self) -> Result<(), String> { - if self.witness_enabled() != self.witness.is_some() { - return Err(format!( - "mode {} requires witness configuration iff witness is enabled", - self.mode.as_str() - )); - } - if self.mirror_enabled() != self.mirror.is_some() { - return Err(format!( - "mode {} requires mirror configuration iff mirror is enabled", - self.mode.as_str() - )); + if !self.witness_enabled() && !self.mirror_enabled() { + return Err("at least one of witness or mirror must be configured".to_owned()); } if let (Some(witness), Some(mirror)) = (&self.witness, &self.mirror) && witness.name == mirror.name @@ -270,14 +241,13 @@ mod tests { KeyName::new(name.to_owned()).unwrap() } - fn config(mode: Mode, witness: bool, mirror: bool) -> AppConfig { + fn config(witness: bool, mirror: bool) -> AppConfig { let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]) .verifying_key() .to_public_key_der() .unwrap() .to_vec(); AppConfig { - mode, logging_level: None, submission_prefix: "https://submit.example/".to_owned(), monitoring_prefix: Some("https://monitor.example/".to_owned()), @@ -308,43 +278,21 @@ mod tests { #[test] fn accepts_all_three_modes() { - config(Mode::Witness, true, false).validate().unwrap(); - config(Mode::Mirror, false, true).validate().unwrap(); - config(Mode::WitnessAndMirror, true, true) - .validate() - .unwrap(); - } - - #[test] - fn rejects_missing_or_disabled_identity_sections() { - assert!(config(Mode::Witness, false, false).validate().is_err()); - assert!(config(Mode::Witness, true, true).validate().is_err()); - assert!(config(Mode::Mirror, false, false).validate().is_err()); - assert!(config(Mode::Mirror, true, true).validate().is_err()); - assert!( - config(Mode::WitnessAndMirror, true, false) - .validate() - .is_err() - ); + config(true, false).validate().unwrap(); + config(false, true).validate().unwrap(); + config(true, true).validate().unwrap(); } #[test] - fn rejects_unknown_mode() { - let error = serde_json::from_str::(r#""witness-mirror""#).unwrap_err(); - assert!(error.to_string().contains("unknown variant")); + fn rejects_disabled_config() { + assert!(config(false, false).validate().is_err()); } #[test] - fn mode_serde_spelling_is_stable() { - assert_eq!( - serde_json::to_string(&Mode::Witness).unwrap(), - r#""witness""# - ); - assert_eq!(serde_json::to_string(&Mode::Mirror).unwrap(), r#""mirror""#); - assert_eq!( - serde_json::to_string(&Mode::WitnessAndMirror).unwrap(), - r#""witness-and-mirror""# - ); + fn derives_mode_from_role_sections() { + assert_eq!(config(true, false).mode(), "witness"); + assert_eq!(config(false, true).mode(), "mirror"); + assert_eq!(config(true, true).mode(), "witness-and-mirror"); } fn ml_dsa_spki(seed: u8) -> Vec { @@ -357,7 +305,7 @@ mod tests { #[test] fn accepts_ml_dsa_spki_and_mixed_algorithms() { - let mut config = config(Mode::Witness, true, false); + let mut config = config(true, false); config .logs .get_mut(&key_name("log.example")) @@ -373,7 +321,7 @@ mod tests { #[test] fn rejects_algorithm_key_mismatch() { - let mut wrong_ml = config(Mode::Witness, true, false); + let mut wrong_ml = config(true, false); let signer = &mut wrong_ml .logs .get_mut(&key_name("log.example")) @@ -382,7 +330,7 @@ mod tests { signer.algorithm = CheckpointAlgorithm::SubtreeV1; assert!(wrong_ml.validate().unwrap_err().contains("ML-DSA-44 SPKI")); - let mut wrong_ed = config(Mode::Witness, true, false); + let mut wrong_ed = config(true, false); let signer = &mut wrong_ed .logs .get_mut(&key_name("log.example")) @@ -394,7 +342,7 @@ mod tests { #[test] fn rejects_malformed_spki() { - let mut config = config(Mode::Witness, true, false); + let mut config = config(true, false); config .logs .get_mut(&key_name("log.example")) @@ -406,7 +354,7 @@ mod tests { #[test] fn rejects_duplicate_signer_id() { - let mut config = config(Mode::Witness, true, false); + let mut config = config(true, false); let log = config.logs.get_mut(&key_name("log.example")).unwrap(); log.checkpoint_signers.push(CheckpointSigner { name: log.checkpoint_signers[0].name.clone(), @@ -418,7 +366,7 @@ mod tests { #[test] fn combined_mode_requires_distinct_identity_names() { - let mut config = config(Mode::WitnessAndMirror, true, true); + let mut config = config(true, true); config.mirror.as_mut().unwrap().name = config.witness.as_ref().unwrap().name.clone(); assert!(config.validate().unwrap_err().contains("must be distinct")); } diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 001231bc..21370a2e 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -4,7 +4,7 @@ //! HTTP entry point and protocol handlers. use crate::{ - CONFIG, IdentitySigner, enabled_roles, load_mirror_signer, load_witness_signer, log_verifiers, + CONFIG, IdentitySigner, load_mirror_signer, load_witness_signer, log_verifiers, mirror_state_do::{PendingCheckpoint, UpdatePendingRequest, state_stub}, }; use axum::{ @@ -45,7 +45,7 @@ fn start() { async fn add_accept_encoding( mut response: axum::http::Response, ) -> axum::http::Response { - if enabled_roles(CONFIG.mode).mirror() { + if CONFIG.mirror_enabled() { response .headers_mut() .insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")); @@ -75,7 +75,7 @@ async fn fetch( ) .route("/metadata", get(metadata)) .route("/", get(root)); - if enabled_roles(CONFIG.mode).mirror() { + if CONFIG.mirror_enabled() { router = router.route("/add-entries", post(crate::add_entries::add_entries)); } router @@ -100,11 +100,7 @@ async fn fetch( } async fn root() -> impl IntoResponse { - let roles = match CONFIG.mode { - config::Mode::Witness => "witness", - config::Mode::Mirror => "mirror", - config::Mode::WitnessAndMirror => "witness and mirror", - }; + let roles = CONFIG.mode().replace('-', " "); ( StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], @@ -240,8 +236,7 @@ fn metadata_logs() -> Vec> { #[worker::send] async fn metadata(State(env): State) -> ApiResult { - let roles = enabled_roles(CONFIG.mode); - let witness = if roles.witness() { + let witness = if CONFIG.witness_enabled() { let identity = CONFIG .witness .as_ref() @@ -251,7 +246,7 @@ async fn metadata(State(env): State) -> ApiResult { } else { None }; - let mirror = if roles.mirror() { + let mirror = if CONFIG.mirror_enabled() { let identity = CONFIG .mirror .as_ref() @@ -270,7 +265,7 @@ async fn metadata(State(env): State) -> ApiResult { Ok(( StatusCode::OK, Json(MetadataResponse { - mode: CONFIG.mode.as_str(), + mode: CONFIG.mode(), submission_prefix: &CONFIG.submission_prefix, monitoring_prefix: CONFIG .monitoring_prefix @@ -323,7 +318,7 @@ async fn add_checkpoint( return Ok(response); } - if !enabled_roles(CONFIG.mode).witness() { + if !CONFIG.witness_enabled() { return Ok(StatusCode::OK.into_response()); } let signature = load_witness_signer(&env)? @@ -357,13 +352,12 @@ fn verify_source_checkpoint( #[worker::send] async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult { let mut signers = Vec::with_capacity(2); - let roles = enabled_roles(CONFIG.mode); - if roles.witness() + if CONFIG.witness_enabled() && let Some(signer) = load_witness_signer(&env)?.as_subtree_signer() { signers.push(signer); } - if roles.mirror() + if CONFIG.mirror_enabled() && let Some(signer) = load_mirror_signer(&env)?.as_subtree_signer() { signers.push(signer); diff --git a/crates/mirror_worker/src/lib.rs b/crates/mirror_worker/src/lib.rs index a0256b59..7254d128 100644 --- a/crates/mirror_worker/src/lib.rs +++ b/crates/mirror_worker/src/lib.rs @@ -65,33 +65,6 @@ pub(crate) const MIRROR_STATE_BINDING: &str = "MIRROR_STATE"; /// The binding name used in `wrangler.jsonc` for the `MirrorCleaner` DO. pub(crate) const MIRROR_CLEANER_BINDING: &str = "MIRROR_CLEANER"; -#[derive(Clone, Copy)] -pub(crate) struct EnabledRoles { - witness: bool, - mirror: bool, -} - -pub(crate) const fn enabled_roles(mode: config::Mode) -> EnabledRoles { - EnabledRoles { - witness: mode.witness_enabled(), - mirror: mode.mirror_enabled(), - } -} - -impl EnabledRoles { - pub(crate) const fn witness(self) -> bool { - self.witness - } - - pub(crate) const fn mirror(self) -> bool { - self.mirror - } - - pub(crate) const fn combined(self) -> bool { - self.witness && self.mirror - } -} - /// The compile-time-embedded worker configuration. /// /// `build.rs` validates `config..json` against the schema and @@ -260,7 +233,7 @@ static WITNESS_SIGNER: OnceLock = OnceLock::new(); /// Returns an error if the `MIRROR_SIGNING_KEY` secret is missing, the PEM /// is malformed, or the key is neither Ed25519 nor ML-DSA-44. pub(crate) fn load_mirror_signer(env: &Env) -> Result<&'static IdentitySigner> { - if !enabled_roles(CONFIG.mode).mirror() { + if !CONFIG.mirror_enabled() { return Err(Error::from("mirror identity is disabled")); } if let Some(s) = MIRROR_SIGNER.get() { @@ -321,7 +294,7 @@ fn build_identity_signer( } pub(crate) fn load_witness_signer(env: &Env) -> Result<&'static IdentitySigner> { - if !enabled_roles(CONFIG.mode).witness() { + if !CONFIG.witness_enabled() { return Err(Error::from("witness identity is disabled")); } if let Some(signer) = WITNESS_SIGNER.get() { @@ -338,7 +311,7 @@ pub(crate) fn load_witness_signer(env: &Env) -> Result<&'static IdentitySigner> /// Load enabled identity keys and reject key reuse across roles. pub(crate) fn validate_identity_keys(env: &Env) -> Result<()> { - if !enabled_roles(CONFIG.mode).combined() { + if !CONFIG.witness_enabled() || !CONFIG.mirror_enabled() { return Ok(()); } let witness = load_witness_signer(env)?; @@ -397,7 +370,7 @@ pub(crate) fn load_ticket_sealer(env: &Env) -> Result<&'static TicketSealer> { #[cfg(test)] mod signer_tests { use super::{ - IdentitySigner, build_identity_signer, enabled_roles, ensure_distinct_identity_keys, + IdentitySigner, build_identity_signer, ensure_distinct_identity_keys, log_verifiers_for_keys, parse_log_keys, }; use base64::Engine as _; @@ -523,21 +496,6 @@ mod signer_tests { let ml_note = Note::new(checkpoint_bytes.as_bytes(), &[ml_signature]).unwrap(); ml_note.verify(&log_verifiers_for_keys(&keys)).unwrap(); } - - #[test] - fn standalone_role_policy_gates_routes_metadata_and_secrets() { - let witness = enabled_roles(config::Mode::Witness); - assert!(witness.witness()); - assert!(!witness.mirror()); - - let mirror = enabled_roles(config::Mode::Mirror); - assert!(!mirror.witness()); - assert!(mirror.mirror()); - - let combined = enabled_roles(config::Mode::WitnessAndMirror); - assert!(combined.witness()); - assert!(combined.mirror()); - } } #[cfg(test)] From 75f08d143fd25be238aeb876c3551cbe62b71a67 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Mon, 21 Sep 2026 10:13:14 -0400 Subject: [PATCH 07/11] mirror_worker: separate role monitoring prefixes --- crates/integration_tests/tests/tlog_mirror.rs | 4 ++-- .../integration_tests/tests/tlog_witness.rs | 7 ++++-- crates/mirror_worker/README.md | 5 +++-- crates/mirror_worker/config.dev.json | 5 +++-- crates/mirror_worker/config.mirror.json | 2 +- crates/mirror_worker/config.schema.json | 9 ++++---- crates/mirror_worker/config.witness.json | 3 ++- crates/mirror_worker/config/src/lib.rs | 22 +++++++++++++++++-- crates/mirror_worker/src/frontend_worker.rs | 8 +++---- 9 files changed, 44 insertions(+), 21 deletions(-) diff --git a/crates/integration_tests/tests/tlog_mirror.rs b/crates/integration_tests/tests/tlog_mirror.rs index f9450a4a..ff6f3b97 100644 --- a/crates/integration_tests/tests/tlog_mirror.rs +++ b/crates/integration_tests/tests/tlog_mirror.rs @@ -526,7 +526,6 @@ struct MetadataResponse { mode: String, mirror: Option, submission_prefix: String, - monitoring_prefix: String, logs: Vec, } @@ -534,6 +533,7 @@ struct MetadataResponse { #[derive(Deserialize, Debug)] struct IdentityMetadata { name: String, + monitoring_prefix: String, #[serde_as(as = "Base64")] public_key: Vec, algorithm: String, @@ -602,7 +602,7 @@ async fn tlog_mirror_end_to_end() { ); assert!(mirror.supports_sign_subtree); assert!(meta.submission_prefix.starts_with("http")); - assert!(meta.monitoring_prefix.starts_with("http")); + assert!(mirror.monitoring_prefix.starts_with("http")); let log_meta = meta .logs .iter() diff --git a/crates/integration_tests/tests/tlog_witness.rs b/crates/integration_tests/tests/tlog_witness.rs index b51b84a0..d3482de7 100644 --- a/crates/integration_tests/tests/tlog_witness.rs +++ b/crates/integration_tests/tests/tlog_witness.rs @@ -194,8 +194,8 @@ fn base_url() -> String { struct MetadataResponse { mode: String, witness: Option, + mirror: Option, submission_prefix: String, - monitoring_prefix: String, logs: Vec, } @@ -211,6 +211,7 @@ struct LogMetadata { #[derive(Deserialize, Debug)] struct IdentityMetadata { name: String, + monitoring_prefix: String, #[serde_as(as = "Base64")] public_key: Vec, } @@ -341,10 +342,12 @@ async fn tlog_witness_end_to_end() { let meta = fetch_metadata().await; assert_eq!(meta.mode, "witness-and-mirror"); let witness = meta.witness.as_ref().expect("witness identity metadata"); + let mirror = meta.mirror.as_ref().expect("mirror identity metadata"); assert_eq!(witness.name, "dev.witness.example"); assert!(!witness.public_key.is_empty()); assert!(meta.submission_prefix.starts_with("http")); - assert!(meta.monitoring_prefix.starts_with("http")); + assert!(witness.monitoring_prefix.starts_with("http")); + assert_ne!(witness.monitoring_prefix, mirror.monitoring_prefix); let log = meta .logs .iter() diff --git a/crates/mirror_worker/README.md b/crates/mirror_worker/README.md index a8cf09c4..2f673321 100644 --- a/crates/mirror_worker/README.md +++ b/crates/mirror_worker/README.md @@ -8,8 +8,9 @@ with one per-origin `MirrorState` Durable Object. ## Configuration The presence of the `witness` and `mirror` identity sections enables each role. -`logs` is keyed by exact checkpoint origin and supports structured Ed25519 and -`subtree/v1` checkpoint signers. +Combined deployments use a shared submission prefix and distinct monitoring +prefixes per identity. `logs` is keyed by exact checkpoint origin and supports +structured Ed25519 and `subtree/v1` checkpoint signers. Role keys remain separate secrets: diff --git a/crates/mirror_worker/config.dev.json b/crates/mirror_worker/config.dev.json index 1336d310..ea71be6d 100644 --- a/crates/mirror_worker/config.dev.json +++ b/crates/mirror_worker/config.dev.json @@ -1,14 +1,15 @@ { "logging_level": "info", "submission_prefix": "http://localhost:8787/", - "monitoring_prefix": "http://localhost:8787/", "witness": { "name": "dev.witness.example", - "description": "Local-dev witness for smoke testing" + "description": "Local-dev witness for smoke testing", + "monitoring_prefix": "http://localhost:8787/witness/" }, "mirror": { "name": "dev.mirror.example", "description": "Local-dev mirror for smoke testing", + "monitoring_prefix": "http://localhost:8787/mirror/", "clean_interval_secs": 5, "commit_packages": 2 }, diff --git a/crates/mirror_worker/config.mirror.json b/crates/mirror_worker/config.mirror.json index bdc37c4b..9f358d58 100644 --- a/crates/mirror_worker/config.mirror.json +++ b/crates/mirror_worker/config.mirror.json @@ -1,10 +1,10 @@ { "logging_level": "info", "submission_prefix": "http://localhost:8789/", - "monitoring_prefix": "http://localhost:8789/", "mirror": { "name": "dev.mirror.example", "description": "Standalone local-dev mirror", + "monitoring_prefix": "http://localhost:8789/", "clean_interval_secs": 5, "commit_packages": 2 }, diff --git a/crates/mirror_worker/config.schema.json b/crates/mirror_worker/config.schema.json index 6ec4f03d..07740bf6 100644 --- a/crates/mirror_worker/config.schema.json +++ b/crates/mirror_worker/config.schema.json @@ -6,7 +6,6 @@ "properties": { "logging_level": {"type": "string", "enum": ["trace", "debug", "info", "warn", "error"]}, "submission_prefix": {"type": "string"}, - "monitoring_prefix": {"type": "string"}, "witness": {"$ref": "#/definitions/identity"}, "mirror": {"$ref": "#/definitions/mirror"}, "logs": { @@ -44,20 +43,22 @@ "definitions": { "identity": { "type": "object", - "required": ["name"], + "required": ["name", "monitoring_prefix"], "additionalProperties": false, "properties": { "name": {"type": "string", "pattern": "^[^+\\s]+$"}, - "description": {"type": "string"} + "description": {"type": "string"}, + "monitoring_prefix": {"type": "string"} } }, "mirror": { "type": "object", - "required": ["name"], + "required": ["name", "monitoring_prefix"], "additionalProperties": false, "properties": { "name": {"type": "string", "pattern": "^[^+\\s]+$"}, "description": {"type": "string"}, + "monitoring_prefix": {"type": "string"}, "clean_interval_secs": {"type": "integer", "minimum": 1, "default": 3600}, "commit_packages": {"type": "integer", "minimum": 1, "maximum": 1024, "default": 32}, "max_chunk_bytes": {"type": "integer", "minimum": 1, "default": 16777216} diff --git a/crates/mirror_worker/config.witness.json b/crates/mirror_worker/config.witness.json index d880e19a..48831fb7 100644 --- a/crates/mirror_worker/config.witness.json +++ b/crates/mirror_worker/config.witness.json @@ -3,7 +3,8 @@ "submission_prefix": "http://localhost:8788/", "witness": { "name": "dev.witness.example", - "description": "Standalone local-dev witness" + "description": "Standalone local-dev witness", + "monitoring_prefix": "http://localhost:8788/" }, "logs": { "example.com/witness-log": { diff --git a/crates/mirror_worker/config/src/lib.rs b/crates/mirror_worker/config/src/lib.rs index 33f7e96c..c055df44 100644 --- a/crates/mirror_worker/config/src/lib.rs +++ b/crates/mirror_worker/config/src/lib.rs @@ -16,7 +16,6 @@ use tlog_cosignature::SubtreeV1NoteVerifier; pub struct AppConfig { pub logging_level: Option, pub submission_prefix: String, - pub monitoring_prefix: Option, pub witness: Option, pub mirror: Option, #[serde(deserialize_with = "deserialize_logs")] @@ -29,6 +28,7 @@ pub struct IdentityConfig { #[serde(deserialize_with = "deserialize_key_name")] pub name: KeyName, pub description: Option, + pub monitoring_prefix: String, } #[derive(Deserialize, Debug)] @@ -37,6 +37,7 @@ pub struct MirrorConfig { #[serde(deserialize_with = "deserialize_key_name")] pub name: KeyName, pub description: Option, + pub monitoring_prefix: String, pub clean_interval_secs: Option, pub commit_packages: Option, pub max_chunk_bytes: Option, @@ -148,6 +149,14 @@ impl AppConfig { { return Err("witness.name and mirror.name must be distinct".to_owned()); } + if let (Some(witness), Some(mirror)) = (&self.witness, &self.mirror) + && witness.monitoring_prefix == mirror.monitoring_prefix + { + return Err( + "witness.monitoring_prefix and mirror.monitoring_prefix must be distinct" + .to_owned(), + ); + } for (origin, log) in &self.logs { log.validate(origin)?; } @@ -250,14 +259,15 @@ mod tests { AppConfig { logging_level: None, submission_prefix: "https://submit.example/".to_owned(), - monitoring_prefix: Some("https://monitor.example/".to_owned()), witness: witness.then(|| IdentityConfig { name: key_name("witness.example"), description: None, + monitoring_prefix: "https://witness.example/".to_owned(), }), mirror: mirror.then(|| MirrorConfig { name: key_name("mirror.example"), description: None, + monitoring_prefix: "https://mirror.example/".to_owned(), clean_interval_secs: None, commit_packages: None, max_chunk_bytes: None, @@ -371,6 +381,14 @@ mod tests { assert!(config.validate().unwrap_err().contains("must be distinct")); } + #[test] + fn combined_mode_requires_distinct_monitoring_prefixes() { + let mut config = config(true, true); + config.mirror.as_mut().unwrap().monitoring_prefix = + config.witness.as_ref().unwrap().monitoring_prefix.clone(); + assert!(config.validate().unwrap_err().contains("must be distinct")); + } + #[test] fn invalid_key_names_fail_deserialization() { let fixture = include_str!("../../config.witness.json"); diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 21370a2e..6a45721b 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -175,7 +175,6 @@ impl IntoResponse for AppError { struct MetadataResponse<'a> { mode: &'a str, submission_prefix: &'a str, - monitoring_prefix: &'a str, #[serde(skip_serializing_if = "Option::is_none")] witness: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -189,6 +188,7 @@ struct IdentityMetadata<'a> { name: &'a str, #[serde(skip_serializing_if = "Option::is_none")] description: Option<&'a str>, + monitoring_prefix: &'a str, #[serde_as(as = "Base64As")] public_key: &'a [u8], algorithm: &'a str, @@ -255,6 +255,7 @@ async fn metadata(State(env): State) -> ApiResult { Some(IdentityMetadata { name: identity.name.as_str(), description: identity.description.as_deref(), + monitoring_prefix: &identity.monitoring_prefix, public_key: signer.public_key_der(), algorithm: signer.algorithm(), supports_sign_subtree: signer.supports_sign_subtree(), @@ -267,10 +268,6 @@ async fn metadata(State(env): State) -> ApiResult { Json(MetadataResponse { mode: CONFIG.mode(), submission_prefix: &CONFIG.submission_prefix, - monitoring_prefix: CONFIG - .monitoring_prefix - .as_deref() - .unwrap_or(&CONFIG.submission_prefix), witness, mirror, logs: metadata_logs(), @@ -285,6 +282,7 @@ fn identity_metadata<'a>( IdentityMetadata { name: identity.name.as_str(), description: identity.description.as_deref(), + monitoring_prefix: &identity.monitoring_prefix, public_key: signer.public_key_der(), algorithm: signer.algorithm(), supports_sign_subtree: signer.supports_sign_subtree(), From 7181038d2d9ee42ade9d71de208a291fb19bed2f Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Mon, 21 Sep 2026 13:00:49 -0400 Subject: [PATCH 08/11] ci: remove retired witness worker job --- .github/workflows/integration.yml | 48 ------------------------------- 1 file changed, 48 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 2db2125c..4c011da5 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -63,54 +63,6 @@ jobs: BASE_URL: http://localhost:8787 LOG_NAME: dev2026h1a - integration-tlog-witness: - name: TLog Witness Integration Tests - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - - # wasm-pack is intentionally NOT installed here: witness_worker only - # uses worker-build for its wasm build, unlike the CT and MTC jobs - # which also compile *_wasm sibling crates that need wasm-pack. - - name: Install worker-build - run: cargo install worker-build@0.8.5 --locked - - # RUSTFLAGS: force legacy Wasm EH (see ct_worker build comment). - - name: Build witness_worker (dev environment) - working-directory: crates/witness_worker - run: DEPLOY_ENV=dev RUSTFLAGS='-Cllvm-args=-wasm-use-legacy-eh' worker-build --release --panic-unwind - - # .dev.vars contains the WITNESS_SIGNING_KEY used by wrangler dev. This - # is a dev-only key (not a production secret) and is committed to the - # repository. - - name: Start wrangler dev - working-directory: crates/witness_worker - run: npx wrangler@4.80.0 -e=dev dev --port 8787 --persist-to .wrangler/state & - - - name: Wait for wrangler dev to be ready - run: | - for i in $(seq 1 30); do - if curl -sf http://localhost:8787/metadata > /dev/null 2>&1; then - echo "wrangler dev is ready" - exit 0 - fi - echo "Waiting for wrangler dev... attempt $i/30" - sleep 2 - done - echo "wrangler dev failed to start in time" - exit 1 - - - name: Run TLog Witness integration tests - run: cargo test -p integration_tests --test tlog_witness --verbose - env: - BASE_URL: http://localhost:8787 - integration-tlog-mirror: name: TLog Mirror Integration Tests runs-on: ubuntu-latest From e2fd97b882ba618add34d75f2b5866b7359c542f Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Tue, 22 Sep 2026 10:24:50 -0400 Subject: [PATCH 09/11] mirror_worker: extract pending update handling --- crates/mirror_worker/src/mirror_state_do.rs | 107 ++++++++++---------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/crates/mirror_worker/src/mirror_state_do.rs b/crates/mirror_worker/src/mirror_state_do.rs index 3fc213ae..f830d1cc 100644 --- a/crates/mirror_worker/src/mirror_state_do.rs +++ b/crates/mirror_worker/src/mirror_state_do.rs @@ -210,61 +210,8 @@ impl MirrorState { self.advance_next_entry(body).await } (Method::Post, "/update-pending") => { - // The DO input/output gates make the read-verify-compare- - // write below atomic: concurrent requests for this origin - // cannot interleave, and the response is held until the - // write is durable, so a following add-entries cannot race - // it. let body: UpdatePendingRequest = req.json().await?; - let current: Option = - self.state.storage().get(PENDING_KEY).await?; - let transition = validate_checkpoint_transition( - current.as_ref().map(|checkpoint| CheckpointState { - size: checkpoint.size, - hash: checkpoint.hash, - }), - body.old_size, - CheckpointState { - size: body.new_size, - hash: body.new_hash, - }, - &body.proof, - ); - if let Err(error) = transition { - return match error { - CheckpointTransitionError::OldSizeMismatch - | CheckpointTransitionError::HashMismatch => { - Response::from_json(¤t.unwrap_or_default()) - .map(|response| response.with_status(409)) - } - CheckpointTransitionError::ProofMustBeEmpty(ProofRequirement::SameSize) => { - Response::error( - "consistency proof must be empty when old_size == checkpoint size", - 422, - ) - } - CheckpointTransitionError::ProofMustBeEmpty(ProofRequirement::Initial) => { - Response::error( - "consistency proof must be empty when old_size is 0 (first pending checkpoint for this origin)", - 422, - ) - } - CheckpointTransitionError::ConsistencyProofFailed => { - Response::error("consistency proof failed", 422) - } - CheckpointTransitionError::InvalidEmptyTreeHash => Response::error( - "size-zero checkpoint must use the empty-tree hash", - 422, - ), - }; - } - let new_state = PendingCheckpoint { - size: body.new_size, - hash: body.new_hash, - signed_note_bytes: body.signed_note_bytes, - }; - self.state.storage().put(PENDING_KEY, &new_state).await?; - Response::from_json(&new_state) + self.update_pending(body).await } _ => Response::error("not found", 404), } @@ -272,6 +219,58 @@ impl MirrorState { } impl MirrorState { + async fn update_pending(&self, body: UpdatePendingRequest) -> Result { + // The DO input/output gates make this read-verify-write sequence + // atomic and hold the response until the write is durable. + let current: Option = self.state.storage().get(PENDING_KEY).await?; + let transition = validate_checkpoint_transition( + current.as_ref().map(|checkpoint| CheckpointState { + size: checkpoint.size, + hash: checkpoint.hash, + }), + body.old_size, + CheckpointState { + size: body.new_size, + hash: body.new_hash, + }, + &body.proof, + ); + if let Err(error) = transition { + return match error { + CheckpointTransitionError::OldSizeMismatch + | CheckpointTransitionError::HashMismatch => { + Response::from_json(¤t.unwrap_or_default()) + .map(|response| response.with_status(409)) + } + CheckpointTransitionError::ProofMustBeEmpty(ProofRequirement::SameSize) => { + Response::error( + "consistency proof must be empty when old_size == checkpoint size", + 422, + ) + } + CheckpointTransitionError::ProofMustBeEmpty(ProofRequirement::Initial) => { + Response::error( + "consistency proof must be empty when old_size is 0 (first pending checkpoint for this origin)", + 422, + ) + } + CheckpointTransitionError::ConsistencyProofFailed => { + Response::error("consistency proof failed", 422) + } + CheckpointTransitionError::InvalidEmptyTreeHash => { + Response::error("size-zero checkpoint must use the empty-tree hash", 422) + } + }; + } + let new_state = PendingCheckpoint { + size: body.new_size, + hash: body.new_hash, + signed_note_bytes: body.signed_note_bytes, + }; + self.state.storage().put(PENDING_KEY, &new_state).await?; + Response::from_json(&new_state) + } + /// Publish without rewinding the committed checkpoint. async fn commit(&self, body: CommitRequest) -> Result { let _guard = self.commit_mux.lock().await; From 286e076cbe11f035772946aac99f6851229d5cd4 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Tue, 22 Sep 2026 10:43:13 -0400 Subject: [PATCH 10/11] mirror_worker: publish witness checkpoints to R2 --- .../integration_tests/tests/tlog_witness.rs | 36 ++++ crates/mirror_worker/README.md | 7 +- crates/mirror_worker/config.dev.json | 4 +- crates/mirror_worker/config.mirror.json | 2 +- crates/mirror_worker/config.witness.json | 2 +- crates/mirror_worker/src/add_entries.rs | 9 + crates/mirror_worker/src/frontend_worker.rs | 54 +++--- crates/mirror_worker/src/mirror_state_do.rs | 159 +++++++++++++++++- crates/mirror_worker/src/storage.rs | 23 ++- crates/mirror_worker/wrangler.jsonc | 17 +- 10 files changed, 264 insertions(+), 49 deletions(-) diff --git a/crates/integration_tests/tests/tlog_witness.rs b/crates/integration_tests/tests/tlog_witness.rs index d3482de7..5275e1cf 100644 --- a/crates/integration_tests/tests/tlog_witness.rs +++ b/crates/integration_tests/tests/tlog_witness.rs @@ -56,6 +56,7 @@ use ml_dsa::MlDsa44; use rand::rng; use serde::Deserialize; use serde_with::{base64::Base64, serde_as}; +use sha2::{Digest as _, Sha256}; use signed_note::{KeyName, Note, NoteSignature, VerifierList}; use std::time::Duration; use tlog_checkpoint::{CheckpointSigner, Ed25519CheckpointSigner, TreeWithTimestamp}; @@ -76,6 +77,8 @@ use tlog_witness::{ /// Origin the witness is configured to accept checkpoints for (see /// `crates/mirror_worker/config.dev.json`). const LOG_ORIGIN: &str = "example.com/witness-log"; +const MIRROR_R2_BUCKET: &str = "mirror-worker-public-dev"; +const WITNESS_R2_BUCKET: &str = "witness-worker-public-dev"; /// PKCS#8 PEM for the Ed25519 log key. The corresponding SPKI is committed /// in `crates/mirror_worker/config.dev.json`. This keypair is dev-only and @@ -236,6 +239,32 @@ async fn fetch_metadata() -> MetadataResponse { resp.json().await.expect("metadata json") } +async fn fetch_monitored_checkpoint() -> Note { + let origin_hash = hex::encode(Sha256::digest(LOG_ORIGIN.as_bytes())); + let key = format!("{origin_hash}/checkpoint"); + let bytes = integration_tests::local_r2::get("mirror_worker", WITNESS_R2_BUCKET, &key) + .await + .expect("read witness checkpoint from local R2") + .expect("witness checkpoint in local R2"); + Note::from_bytes(&bytes).expect("monitoring checkpoint note") +} + +async fn assert_static_monitoring_only() { + let origin_hash = hex::encode(Sha256::digest(LOG_ORIGIN.as_bytes())); + let key = format!("{origin_hash}/checkpoint"); + let worker_response = reqwest::get(format!("{}/{key}", base_url())) + .await + .expect("worker monitoring request"); + assert_eq!(worker_response.status().as_u16(), 404); + assert!( + integration_tests::local_r2::get("mirror_worker", MIRROR_R2_BUCKET, &key) + .await + .expect("inspect mirror R2 bucket") + .is_none(), + "witness checkpoint leaked into the mirror bucket" + ); +} + struct AddCheckpointResult { status: u16, content_type: Option, @@ -410,6 +439,10 @@ async fn tlog_witness_end_to_end() { "response must contain at least one signature" ); verify_witness_signature(¬e, &sigs, &meta); + let monitored = fetch_monitored_checkpoint().await; + assert_eq!(monitored.text(), note.text()); + verify_witness_signature(¬e, monitored.signatures(), &meta); + assert_static_monitoring_only().await; } // A same-size transition must match the recorded hash and omit the proof. @@ -455,6 +488,9 @@ async fn tlog_witness_end_to_end() { "second submission: body={:?}", String::from_utf8_lossy(&r.body) ); + let monitored = fetch_monitored_checkpoint().await; + assert_eq!(monitored.text(), note.text()); + verify_witness_signature(¬e, monitored.signatures(), &meta); } // ----------------------- (4) Stale old_size → 409 ----------------------- diff --git a/crates/mirror_worker/README.md b/crates/mirror_worker/README.md index 2f673321..88aaf610 100644 --- a/crates/mirror_worker/README.md +++ b/crates/mirror_worker/README.md @@ -12,14 +12,17 @@ Combined deployments use a shared submission prefix and distinct monitoring prefixes per identity. `logs` is keyed by exact checkpoint origin and supports structured Ed25519 and `subtree/v1` checkpoint signers. +Witness and mirror monitoring prefixes are backed by separate public R2 buckets. +The Worker only serves submission and metadata APIs. + Role keys remain separate secrets: - `WITNESS_SIGNING_KEY` signs successful `add-checkpoint` responses and witness subtree responses. - `MIRROR_SIGNING_KEY` signs completed mirror checkpoints and mirror subtree responses. - `MIRROR_TICKET_KEY` seals mirror upload tickets. -Disabled-role secrets are not loaded. Mirror R2, ticket, and cleaner access is -confined to mirror operations. +Disabled-role secrets and R2 bindings are not loaded. Ticket and cleaner access +is confined to mirror operations. ## Mirror State diff --git a/crates/mirror_worker/config.dev.json b/crates/mirror_worker/config.dev.json index ea71be6d..f79a585a 100644 --- a/crates/mirror_worker/config.dev.json +++ b/crates/mirror_worker/config.dev.json @@ -4,12 +4,12 @@ "witness": { "name": "dev.witness.example", "description": "Local-dev witness for smoke testing", - "monitoring_prefix": "http://localhost:8787/witness/" + "monitoring_prefix": "https://witness.dev.example/" }, "mirror": { "name": "dev.mirror.example", "description": "Local-dev mirror for smoke testing", - "monitoring_prefix": "http://localhost:8787/mirror/", + "monitoring_prefix": "https://mirror.dev.example/", "clean_interval_secs": 5, "commit_packages": 2 }, diff --git a/crates/mirror_worker/config.mirror.json b/crates/mirror_worker/config.mirror.json index 9f358d58..9d92abeb 100644 --- a/crates/mirror_worker/config.mirror.json +++ b/crates/mirror_worker/config.mirror.json @@ -4,7 +4,7 @@ "mirror": { "name": "dev.mirror.example", "description": "Standalone local-dev mirror", - "monitoring_prefix": "http://localhost:8789/", + "monitoring_prefix": "https://mirror.dev.example/", "clean_interval_secs": 5, "commit_packages": 2 }, diff --git a/crates/mirror_worker/config.witness.json b/crates/mirror_worker/config.witness.json index 48831fb7..ac65fb07 100644 --- a/crates/mirror_worker/config.witness.json +++ b/crates/mirror_worker/config.witness.json @@ -4,7 +4,7 @@ "witness": { "name": "dev.witness.example", "description": "Standalone local-dev witness", - "monitoring_prefix": "http://localhost:8788/" + "monitoring_prefix": "https://witness.dev.example/" }, "logs": { "example.com/witness-log": { diff --git a/crates/mirror_worker/src/add_entries.rs b/crates/mirror_worker/src/add_entries.rs index 7e390346..2008cfb3 100644 --- a/crates/mirror_worker/src/add_entries.rs +++ b/crates/mirror_worker/src/add_entries.rs @@ -1037,6 +1037,9 @@ fn resolve_target_pending( size: committed.size, hash: committed.hash, signed_note_bytes: committed.signed_note_bytes.clone(), + witness_published: true, + witness_response_bytes: Vec::new(), + update_request_hash: Hash::default(), }); } @@ -1098,6 +1101,9 @@ fn resolve_target_pending( size: cp_text.size(), hash: *cp_text.hash(), signed_note_bytes: plaintext, + witness_published: true, + witness_response_bytes: Vec::new(), + update_request_hash: Hash::default(), }) } @@ -1341,6 +1347,9 @@ mod tests { size: target, hash: target_hash, signed_note_bytes: Vec::new(), + witness_published: true, + witness_response_bytes: Vec::new(), + update_request_hash: Hash::default(), }; (prefix, pkg, cp) } diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 6a45721b..ad233c92 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -5,7 +5,7 @@ use crate::{ CONFIG, IdentitySigner, load_mirror_signer, load_witness_signer, log_verifiers, - mirror_state_do::{PendingCheckpoint, UpdatePendingRequest, state_stub}, + mirror_state_do::{PendingCheckpoint, UpdatePendingRequest, UpdatePendingResponse, state_stub}, }; use axum::{ Json, Router, @@ -19,7 +19,6 @@ use generic_log_worker::{ frontend::request_metrics, init_logging, obs::{Wshim, metrics}, - util::now_millis, }; use serde::Serialize; use serde_with::{base64::Base64 as Base64As, serde_as}; @@ -27,9 +26,8 @@ use signed_note::{NoteSignature, VerifierList}; use tlog_checkpoint::CheckpointSigner as _; use tlog_witness::{ CONTENT_TYPE_TLOG_SIZE, MAX_REQUEST_BODY_SIZE, TrustedSignatureError, - serialize_add_checkpoint_response, serialize_sign_subtree_response, - validate_add_checkpoint_request, validate_sign_subtree_proof, validate_sign_subtree_request, - verify_trusted_checkpoint_signature, + serialize_sign_subtree_response, validate_add_checkpoint_request, validate_sign_subtree_proof, + validate_sign_subtree_request, verify_trusted_checkpoint_signature, }; use tower_service::Service as _; #[allow(clippy::wildcard_imports)] @@ -312,23 +310,16 @@ async fn add_checkpoint( proof: consistency_proof, signed_note_bytes: checkpoint.to_bytes(), }; - if let Some(response) = dispatch_update_pending(&env, origin, &update).await? { - return Ok(response); + match dispatch_update_pending(&env, origin, &update).await? { + UpdatePendingOutcome::Rejected(response) => Ok(response), + UpdatePendingOutcome::Updated(update) if CONFIG.witness_enabled() => Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + update.witness_response_bytes, + ) + .into_response()), + UpdatePendingOutcome::Updated(_) => Ok(StatusCode::OK.into_response()), } - - if !CONFIG.witness_enabled() { - return Ok(StatusCode::OK.into_response()); - } - let signature = load_witness_signer(&env)? - .as_checkpoint_signer() - .sign(now_millis(), &checkpoint_text) - .map_err(|error| Error::from(format!("witness signing: {error:?}")))?; - Ok(( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - serialize_add_checkpoint_response(std::slice::from_ref(&signature)), - ) - .into_response()) } fn verify_source_checkpoint( @@ -409,7 +400,7 @@ async fn dispatch_update_pending( env: &Env, origin: &str, update: &UpdatePendingRequest, -) -> Result> { +) -> Result { let stub = state_stub(env, origin)?; let mut response = stub .fetch_with_request(Request::new_with_init( @@ -427,12 +418,14 @@ async fn dispatch_update_pending( )?) .await?; match response.status_code() { - 200 => Ok(None), + 200 => Ok(UpdatePendingOutcome::Updated(response.json().await?)), 409 => { let current: PendingCheckpoint = response.json().await?; - Ok(Some(tlog_size_conflict(current.size))) + Ok(UpdatePendingOutcome::Rejected(tlog_size_conflict( + current.size, + ))) } - 422 => Ok(Some( + 422 => Ok(UpdatePendingOutcome::Rejected( ( StatusCode::UNPROCESSABLE_ENTITY, "Unprocessable Entity: consistency proof failed", @@ -444,9 +437,11 @@ async fn dispatch_update_pending( .text() .await .unwrap_or_else(|_| "Bad request".into()); - Ok(Some((StatusCode::BAD_REQUEST, message).into_response())) + Ok(UpdatePendingOutcome::Rejected( + (StatusCode::BAD_REQUEST, message).into_response(), + )) } - status => Ok(Some( + status => Ok(UpdatePendingOutcome::Rejected( ( StatusCode::INTERNAL_SERVER_ERROR, format!("Internal error: DO returned {status}"), @@ -456,6 +451,11 @@ async fn dispatch_update_pending( } } +enum UpdatePendingOutcome { + Updated(UpdatePendingResponse), + Rejected(axum::response::Response), +} + fn tlog_size_conflict(size: u64) -> axum::response::Response { ( StatusCode::CONFLICT, diff --git a/crates/mirror_worker/src/mirror_state_do.rs b/crates/mirror_worker/src/mirror_state_do.rs index f830d1cc..66c9e429 100644 --- a/crates/mirror_worker/src/mirror_state_do.rs +++ b/crates/mirror_worker/src/mirror_state_do.rs @@ -26,15 +26,22 @@ use serde::{Deserialize, Serialize}; use serde_with::{base64::Base64 as Base64As, serde_as}; +use sha2::{Digest as _, Sha256}; +use signed_note::Note; +use tlog_checkpoint::CheckpointText; use tlog_core::Hash; use tlog_witness::{ - CheckpointState, CheckpointTransitionError, ProofRequirement, validate_checkpoint_transition, + CheckpointState, CheckpointTransitionError, ProofRequirement, + serialize_add_checkpoint_response, validate_checkpoint_transition, }; use tokio::sync::Mutex; #[allow(clippy::wildcard_imports)] use worker::*; -use crate::{MIRROR_STATE_BINDING, commit, storage::load_origin_bucket}; +use crate::{ + CONFIG, MIRROR_STATE_BINDING, commit, load_witness_signer, + storage::{load_origin_bucket, load_witness_origin_bucket}, +}; const PENDING_KEY: &str = "pending"; const COMMITTED_KEY: &str = "committed"; @@ -55,6 +62,16 @@ pub struct PendingCheckpoint { /// Full signed-note bytes, encoded as base64 in persisted JSON. #[serde_as(as = "Base64As")] pub signed_note_bytes: Vec, + /// Whether the witness checkpoint has been published to R2. + #[serde(default)] + pub witness_published: bool, + /// Serialized witness response for idempotent request retries. + #[serde_as(as = "Base64As")] + #[serde(default)] + pub witness_response_bytes: Vec, + /// Fingerprint of the accepted update request. + #[serde(default, with = "generic_log_worker::hash_serde::hex")] + pub update_request_hash: Hash, } /// The persisted *committed checkpoint* (the *mirror checkpoint*) for a @@ -160,6 +177,13 @@ pub struct UpdatePendingRequest { pub signed_note_bytes: Vec, } +#[serde_as] +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdatePendingResponse { + #[serde_as(as = "Base64As")] + pub witness_response_bytes: Vec, +} + /// Per-origin witness and mirror state. #[durable_object(fetch)] struct MirrorState { @@ -167,6 +191,8 @@ struct MirrorState { env: Env, /// Invariant: checkpoint commits are serialized per origin. commit_mux: Mutex<()>, + /// Invariant: pending checkpoint updates are serialized per origin. + update_mux: Mutex<()>, } // SAFETY: Durable Objects are single-threaded; the `RefUnwindSafe` bound @@ -181,6 +207,7 @@ impl DurableObject for MirrorState { state, env, commit_mux: Mutex::new(()), + update_mux: Mutex::new(()), } } @@ -220,9 +247,34 @@ impl MirrorState { impl MirrorState { async fn update_pending(&self, body: UpdatePendingRequest) -> Result { + let _guard = self.update_mux.lock().await; + let request_hash = update_request_hash(&body); + // The DO input/output gates make this read-verify-write sequence // atomic and hold the response until the write is durable. - let current: Option = self.state.storage().get(PENDING_KEY).await?; + let mut current: Option = self.state.storage().get(PENDING_KEY).await?; + if CONFIG.witness_enabled() + && let Some(checkpoint) = current.as_mut() + && !checkpoint.witness_published + { + self.recover_witness_checkpoint(checkpoint).await?; + } + if CONFIG.witness_enabled() + && let Some(checkpoint) = current.as_ref() + && !checkpoint.witness_response_bytes.is_empty() + && (checkpoint.update_request_hash == request_hash + || (checkpoint.update_request_hash == Hash::default() + && checkpoint.size == body.new_size + && checkpoint.hash == body.new_hash + && checkpoint_text_matches( + &checkpoint.signed_note_bytes, + &body.signed_note_bytes, + )?)) + { + return Response::from_json(&UpdatePendingResponse { + witness_response_bytes: checkpoint.witness_response_bytes.clone(), + }); + } let transition = validate_checkpoint_transition( current.as_ref().map(|checkpoint| CheckpointState { size: checkpoint.size, @@ -262,13 +314,65 @@ impl MirrorState { } }; } - let new_state = PendingCheckpoint { + let (signed_note_bytes, witness_response_bytes) = if CONFIG.witness_enabled() { + self.cosign_witness_checkpoint(&body.signed_note_bytes)? + } else { + (body.signed_note_bytes, Vec::new()) + }; + let mut new_state = PendingCheckpoint { size: body.new_size, hash: body.new_hash, - signed_note_bytes: body.signed_note_bytes, + signed_note_bytes, + witness_published: false, + witness_response_bytes: witness_response_bytes.clone(), + update_request_hash: request_hash, }; self.state.storage().put(PENDING_KEY, &new_state).await?; - Response::from_json(&new_state) + if CONFIG.witness_enabled() { + self.publish_witness_checkpoint(&mut new_state).await?; + } + Response::from_json(&UpdatePendingResponse { + witness_response_bytes, + }) + } + + fn cosign_witness_checkpoint(&self, note_bytes: &[u8]) -> Result<(Vec, Vec)> { + let mut note = Note::from_bytes(note_bytes) + .map_err(|error| Error::from(format!("parse checkpoint note: {error:?}")))?; + let checkpoint = CheckpointText::from_bytes(note.text()) + .map_err(|error| Error::from(format!("parse checkpoint text: {error:?}")))?; + let signature = load_witness_signer(&self.env)? + .as_checkpoint_signer() + .sign(generic_log_worker::util::now_millis(), &checkpoint) + .map_err(|error| Error::from(format!("witness signing: {error:?}")))?; + let response = serialize_add_checkpoint_response(std::slice::from_ref(&signature)); + let mut signatures = note.signatures().to_vec(); + signatures.push(signature); + note = Note::new(note.text(), &signatures) + .map_err(|error| Error::from(format!("build witness checkpoint: {error:?}")))?; + Ok((note.to_bytes(), response)) + } + + async fn recover_witness_checkpoint(&self, checkpoint: &mut PendingCheckpoint) -> Result<()> { + if checkpoint.witness_response_bytes.is_empty() { + let (note, response) = self.cosign_witness_checkpoint(&checkpoint.signed_note_bytes)?; + checkpoint.signed_note_bytes = note; + checkpoint.witness_response_bytes = response; + self.state.storage().put(PENDING_KEY, &*checkpoint).await?; + } + self.publish_witness_checkpoint(checkpoint).await + } + + async fn publish_witness_checkpoint(&self, checkpoint: &mut PendingCheckpoint) -> Result<()> { + let origin = self + .state + .id() + .name() + .ok_or_else(|| Error::from("mirror state DO missing origin name"))?; + let bucket = load_witness_origin_bucket(&self.env, &origin)?; + commit::write_checkpoint(&bucket, checkpoint.signed_note_bytes.clone()).await?; + checkpoint.witness_published = true; + self.state.storage().put(PENDING_KEY, checkpoint).await } /// Publish without rewinding the committed checkpoint. @@ -366,6 +470,32 @@ impl MirrorState { } } +fn update_request_hash(body: &UpdatePendingRequest) -> Hash { + let mut digest = Sha256::new(); + digest.update(b"mirror-worker update-pending v1\0"); + digest.update(body.old_size.to_be_bytes()); + digest.update(body.new_size.to_be_bytes()); + digest.update(body.new_hash.0); + digest.update( + u64::try_from(body.proof.len()) + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + for hash in &body.proof { + digest.update(hash.0); + } + digest.update(&body.signed_note_bytes); + Hash(digest.finalize().into()) +} + +fn checkpoint_text_matches(left: &[u8], right: &[u8]) -> Result { + let left = Note::from_bytes(left) + .map_err(|error| Error::from(format!("parse persisted checkpoint note: {error:?}")))?; + let right = Note::from_bytes(right) + .map_err(|error| Error::from(format!("parse submitted checkpoint note: {error:?}")))?; + Ok(left.text() == right.text()) +} + /// Lookup helper used by the frontend: get a stub for the DO serving a /// particular log origin. pub(crate) fn state_stub(env: &Env, origin: &str) -> Result { @@ -391,19 +521,25 @@ mod tests { size: 42, hash: Hash(bytes), signed_note_bytes: b"signed-note-bytes".to_vec(), + witness_published: true, + witness_response_bytes: b"witness-response".to_vec(), + update_request_hash: Hash([0xff; HASH_SIZE]), }; let json = serde_json::to_string(&pc).unwrap(); // Pin the expected canonical encoding, matching base64 of the // signed-note bytes. assert_eq!( json, - r#"{"size":42,"hash":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","signed_note_bytes":"c2lnbmVkLW5vdGUtYnl0ZXM="}"# + r#"{"size":42,"hash":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","signed_note_bytes":"c2lnbmVkLW5vdGUtYnl0ZXM=","witness_published":true,"witness_response_bytes":"d2l0bmVzcy1yZXNwb25zZQ==","update_request_hash":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}"# ); let decoded: PendingCheckpoint = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.size, 42); assert_eq!(decoded.hash.0, bytes); assert_eq!(decoded.signed_note_bytes, b"signed-note-bytes"); + assert!(decoded.witness_published); + assert_eq!(decoded.witness_response_bytes, b"witness-response"); + assert_eq!(decoded.update_request_hash, Hash([0xff; HASH_SIZE])); } /// Pin the wire shape of the internal DO RPC body. The frontend @@ -457,6 +593,9 @@ mod tests { assert_eq!(pc.size, 0); assert_eq!(pc.hash.0, [0u8; HASH_SIZE]); assert!(pc.signed_note_bytes.is_empty()); + assert!(!pc.witness_published); + assert!(pc.witness_response_bytes.is_empty()); + assert_eq!(pc.update_request_hash, Hash::default()); } #[test] @@ -489,6 +628,9 @@ mod tests { size: 5, hash: Hash([0xaa; HASH_SIZE]), signed_note_bytes: b"p".to_vec(), + witness_published: true, + witness_response_bytes: Vec::new(), + update_request_hash: Hash::default(), }), committed: Some(CommittedCheckpoint { size: 3, @@ -523,6 +665,9 @@ mod tests { size: 0, hash: tlog_core::EMPTY_HASH, signed_note_bytes: b"zero checkpoint".to_vec(), + witness_published: true, + witness_response_bytes: Vec::new(), + update_request_hash: Hash::default(), }), ..MirrorStateSnapshot::default() }; diff --git a/crates/mirror_worker/src/storage.rs b/crates/mirror_worker/src/storage.rs index 4105eb80..58dadb42 100644 --- a/crates/mirror_worker/src/storage.rs +++ b/crates/mirror_worker/src/storage.rs @@ -1,10 +1,10 @@ // Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause -//! Object storage for the mirrored copy of each origin log. +//! Object storage for mirror and witness monitoring data. //! -//! A single R2 bucket (bound as [`PUBLIC_BUCKET_BINDING`]) backs every -//! origin the mirror serves. Objects for a given origin are stored under +//! Separate R2 buckets back the mirror and witness monitoring interfaces. +//! Objects for a given origin are stored under //! a distinct `/` key prefix, where `origin hash` is the //! lowercase hex SHA-256 of the log's origin, the same identifier the //! [c2sp.org/tlog-mirror][spec] monitoring interface uses in its URL @@ -30,6 +30,8 @@ use worker::*; /// `wrangler.jsonc` binding name for the mirror's public R2 bucket. pub(crate) const PUBLIC_BUCKET_BINDING: &str = "PUBLIC_BUCKET"; +/// `wrangler.jsonc` binding name for the witness's public R2 bucket. +pub(crate) const WITNESS_BUCKET_BINDING: &str = "WITNESS_BUCKET"; /// Compute a log's *origin hash*: the lowercase hex-encoded SHA-256 of /// the origin string, per [c2sp.org/tlog-mirror][spec]. Used both as the @@ -84,7 +86,20 @@ impl ObjectBackend for OriginBucket { /// Returns an error if the `PUBLIC_BUCKET` binding is missing or not an /// R2 bucket. pub(crate) fn load_origin_bucket(env: &Env, origin: &str) -> Result { - let bucket = env.bucket(PUBLIC_BUCKET_BINDING)?; + load_bound_origin_bucket(env, PUBLIC_BUCKET_BINDING, origin) +} + +/// Build an [`OriginBucket`] for witness checkpoints. +/// +/// # Errors +/// +/// Returns an error if the `WITNESS_BUCKET` binding is missing or invalid. +pub(crate) fn load_witness_origin_bucket(env: &Env, origin: &str) -> Result { + load_bound_origin_bucket(env, WITNESS_BUCKET_BINDING, origin) +} + +fn load_bound_origin_bucket(env: &Env, binding: &str, origin: &str) -> Result { + let bucket = env.bucket(binding)?; Ok(OriginBucket { inner: ObjectBucket::new(bucket), prefix: format!("{}/", origin_hash(origin)), diff --git a/crates/mirror_worker/wrangler.jsonc b/crates/mirror_worker/wrangler.jsonc index 8e6c2ec4..afed02c0 100644 --- a/crates/mirror_worker/wrangler.jsonc +++ b/crates/mirror_worker/wrangler.jsonc @@ -37,15 +37,16 @@ } ] }, - // Public R2 bucket backing the mirrored copy of every origin - // log. The mirror stores each origin under a distinct - // `/` key prefix (see `crate::storage`), so a - // single bucket serves all configured logs. Served publicly - // via the c2sp.org/tlog-tiles read interface. + // Separate public buckets serve mirror tiles and witness + // checkpoints. Each stores origins under `/`. "r2_buckets": [ { "bucket_name": "mirror-worker-public-dev", "binding": "PUBLIC_BUCKET" + }, + { + "bucket_name": "witness-worker-public-dev", + "binding": "WITNESS_BUCKET" } ], "version_metadata": { @@ -71,6 +72,12 @@ } ] }, + "r2_buckets": [ + { + "bucket_name": "witness-worker-public-dev", + "binding": "WITNESS_BUCKET" + } + ], "version_metadata": { "binding": "VERSION_METADATA" }, From e9bb8f7c06345f1e6713261e13977e6e0518b7f1 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Tue, 22 Sep 2026 11:19:58 -0400 Subject: [PATCH 11/11] mirror_worker: use optional mirror config directly --- crates/mirror_worker/config/src/lib.rs | 21 ++------------------- crates/mirror_worker/src/add_entries.rs | 9 +++++---- crates/mirror_worker/src/cleaner_do.rs | 18 ++++++++++++------ crates/mirror_worker/src/frontend_worker.rs | 12 ++++-------- crates/mirror_worker/src/lib.rs | 15 ++++++--------- 5 files changed, 29 insertions(+), 46 deletions(-) diff --git a/crates/mirror_worker/config/src/lib.rs b/crates/mirror_worker/config/src/lib.rs index c055df44..5fa61686 100644 --- a/crates/mirror_worker/config/src/lib.rs +++ b/crates/mirror_worker/config/src/lib.rs @@ -108,14 +108,9 @@ impl AppConfig { self.witness.is_some() } - #[must_use] - pub const fn mirror_enabled(&self) -> bool { - self.mirror.is_some() - } - #[must_use] pub const fn mode(&self) -> &'static str { - match (self.witness_enabled(), self.mirror_enabled()) { + match (self.witness_enabled(), self.mirror.is_some()) { (true, true) => "witness-and-mirror", (true, false) => "witness", (false, true) => "mirror", @@ -123,25 +118,13 @@ impl AppConfig { } } - #[must_use] - /// Return the mirror settings for a validated mirror-enabled config. - /// - /// # Panics - /// - /// Panics if mirror configuration is absent. - pub fn mirror_config(&self) -> &MirrorConfig { - self.mirror - .as_ref() - .expect("validated mirror mode must have mirror config") - } - /// Validate role configuration, algorithms, and keys. /// /// # Errors /// /// Returns an operator-readable description of the invalid field. pub fn validate(&self) -> Result<(), String> { - if !self.witness_enabled() && !self.mirror_enabled() { + if !self.witness_enabled() && self.mirror.is_none() { return Err("at least one of witness or mirror must be configured".to_owned()); } if let (Some(witness), Some(mirror)) = (&self.witness, &self.mirror) diff --git a/crates/mirror_worker/src/add_entries.rs b/crates/mirror_worker/src/add_entries.rs index 2008cfb3..1002a65f 100644 --- a/crates/mirror_worker/src/add_entries.rs +++ b/crates/mirror_worker/src/add_entries.rs @@ -396,14 +396,15 @@ where { // config.schema.json caps commit_packages (max 1024), enforced by the // build script, so this always fits usize; the fallback is unreachable. - let commit_packages = - usize::try_from(crate::CONFIG.mirror_config().commit_packages()).unwrap_or(usize::MAX); + let mirror_config = crate::CONFIG.mirror.as_ref().ok_or_else(|| { + AppError::InternalServerError("add-entries requires mirror configuration".to_owned()) + })?; + let commit_packages = usize::try_from(mirror_config.commit_packages()).unwrap_or(usize::MAX); // Byte ceiling on buffered entries; flush early when reached so peak // memory is bounded regardless of package sizes. Saturating to // usize::MAX on a 32-bit target just means "never trip the byte cap", // leaving the package-count cap in force. - let max_chunk_bytes = - usize::try_from(crate::CONFIG.mirror_config().max_chunk_bytes()).unwrap_or(usize::MAX); + let max_chunk_bytes = usize::try_from(mirror_config.max_chunk_bytes()).unwrap_or(usize::MAX); // Entries below the request-start frontier are already persisted; new // persistence begins at this fixed boundary. diff --git a/crates/mirror_worker/src/cleaner_do.rs b/crates/mirror_worker/src/cleaner_do.rs index 422d6e1e..1457a485 100644 --- a/crates/mirror_worker/src/cleaner_do.rs +++ b/crates/mirror_worker/src/cleaner_do.rs @@ -200,10 +200,13 @@ impl MirrorCleaner { self.initialize().await?; } // Reschedule first so the loop continues even if cleaning fails. + let clean_interval_secs = CONFIG + .mirror + .as_ref() + .ok_or_else(|| Error::from("mirror cleaner requires mirror configuration"))? + .clean_interval_secs(); self.storage() - .set_alarm(Duration::from_secs( - CONFIG.mirror_config().clean_interval_secs(), - )) + .set_alarm(Duration::from_secs(clean_interval_secs)) .await?; if let Err(e) = self.clean(served).await { log::warn!("mirror cleaner [{}]: clean failed: {e}", served.origin); @@ -227,10 +230,13 @@ impl MirrorCleaner { /// Start the alarm loop and load any checkpointed progress. async fn initialize(&self) -> Result<()> { // OK if an alarm is already set; this guarantees one exists. + let clean_interval_secs = CONFIG + .mirror + .as_ref() + .ok_or_else(|| Error::from("mirror cleaner requires mirror configuration"))? + .clean_interval_secs(); self.storage() - .set_alarm(Duration::from_secs( - CONFIG.mirror_config().clean_interval_secs(), - )) + .set_alarm(Duration::from_secs(clean_interval_secs)) .await?; if let Some(cleaned) = self.storage().get::(CLEANED_SIZE_KEY).await? { *self.cleaned_size.borrow_mut() = cleaned; diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index ad233c92..8f83f843 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -43,7 +43,7 @@ fn start() { async fn add_accept_encoding( mut response: axum::http::Response, ) -> axum::http::Response { - if CONFIG.mirror_enabled() { + if CONFIG.mirror.is_some() { response .headers_mut() .insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")); @@ -73,7 +73,7 @@ async fn fetch( ) .route("/metadata", get(metadata)) .route("/", get(root)); - if CONFIG.mirror_enabled() { + if CONFIG.mirror.is_some() { router = router.route("/add-entries", post(crate::add_entries::add_entries)); } router @@ -244,11 +244,7 @@ async fn metadata(State(env): State) -> ApiResult { } else { None }; - let mirror = if CONFIG.mirror_enabled() { - let identity = CONFIG - .mirror - .as_ref() - .expect("validated mirror mode has mirror config"); + let mirror = if let Some(identity) = CONFIG.mirror.as_ref() { let signer = load_mirror_signer(&env)?; Some(IdentityMetadata { name: identity.name.as_str(), @@ -346,7 +342,7 @@ async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult = OnceLock::new(); /// Returns an error if the `MIRROR_SIGNING_KEY` secret is missing, the PEM /// is malformed, or the key is neither Ed25519 nor ML-DSA-44. pub(crate) fn load_mirror_signer(env: &Env) -> Result<&'static IdentitySigner> { - if !CONFIG.mirror_enabled() { - return Err(Error::from("mirror identity is disabled")); - } + let config = CONFIG + .mirror + .as_ref() + .ok_or_else(|| Error::from("mirror identity is disabled"))?; if let Some(s) = MIRROR_SIGNER.get() { return Ok(s); } let pem = env.secret("MIRROR_SIGNING_KEY")?.to_string(); - let signer = build_identity_signer( - CONFIG.mirror_config().name.as_str(), - "MIRROR_SIGNING_KEY", - &pem, - )?; + let signer = build_identity_signer(config.name.as_str(), "MIRROR_SIGNING_KEY", &pem)?; Ok(MIRROR_SIGNER.get_or_init(|| signer)) } @@ -311,7 +308,7 @@ pub(crate) fn load_witness_signer(env: &Env) -> Result<&'static IdentitySigner> /// Load enabled identity keys and reject key reuse across roles. pub(crate) fn validate_identity_keys(env: &Env) -> Result<()> { - if !CONFIG.witness_enabled() || !CONFIG.mirror_enabled() { + if !CONFIG.witness_enabled() || CONFIG.mirror.is_none() { return Ok(()); } let witness = load_witness_signer(env)?;