diff --git a/Cargo.lock b/Cargo.lock index 15f688f..42a243e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1706,6 +1706,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mirror_worker" +version = "0.2.0" +dependencies = [ + "base64", + "console_error_panic_hook", + "console_log", + "ed25519-dalek", + "getrandom 0.4.2", + "hex", + "jsonschema", + "log", + "mirror_worker_config", + "serde", + "serde_json", + "serde_with", + "signed_note", + "tlog_checkpoint", + "tlog_core", + "tlog_witness", + "worker", +] + +[[package]] +name = "mirror_worker_config" +version = "0.2.0" +dependencies = [ + "ed25519-dalek", + "serde", + "serde_with", + "signed_note", +] + [[package]] name = "ml-dsa" version = "0.1.0-rc.8" @@ -2903,7 +2936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3058,6 +3091,18 @@ dependencies = [ "tlog_tiles", ] +[[package]] +name = "tlog_mirror" +version = "0.2.0" +dependencies = [ + "base64", + "byteorder", + "hmac", + "sha2", + "thiserror 2.0.17", + "tlog_core", +] + [[package]] name = "tlog_tiles" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index f0c74ae..b412bfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ 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", @@ -20,6 +22,7 @@ members = [ "crates/tlog_core", "crates/tlog_cosignature", "crates/tlog_entry", + "crates/tlog_mirror", "crates/tlog_tiles", "crates/tlog_tiles_wasm", "crates/tlog_witness", @@ -41,6 +44,8 @@ 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", @@ -49,6 +54,7 @@ default-members = [ "crates/tlog_core", "crates/tlog_cosignature", "crates/tlog_entry", + "crates/tlog_mirror", "crates/tlog_tiles", "crates/tlog_tiles_wasm", "crates/tlog_witness", @@ -105,6 +111,7 @@ futures-executor = "0.3.31" futures-util = "0.3.31" getrandom = { version = "0.4", features = ["wasm_js"] } hex = "0.4" +hmac = "0.13" itertools = "0.14.0" jsonschema = "0.30" length_prefixed = { path = "crates/length_prefixed" } @@ -134,6 +141,7 @@ tlog_checkpoint = { path = "crates/tlog_checkpoint", version = "0.2.0" } tlog_core = { path = "crates/tlog_core", version = "0.2.0" } tlog_cosignature = { path = "crates/tlog_cosignature", version = "0.2.0" } tlog_entry = { path = "crates/tlog_entry", version = "0.2.0" } +tlog_mirror = { path = "crates/tlog_mirror", version = "0.2.0" } tlog_tiles = { path = "crates/tlog_tiles", version = "0.2.0" } tlog_witness = { path = "crates/tlog_witness", version = "0.2.0" } tokio = { version = "1", features = ["sync"] } diff --git a/crates/integration_tests/tests/tlog_mirror.rs b/crates/integration_tests/tests/tlog_mirror.rs new file mode 100644 index 0000000..9e22f9b --- /dev/null +++ b/crates/integration_tests/tests/tlog_mirror.rs @@ -0,0 +1,447 @@ +// 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 +//! [c2sp.org/tlog-mirror][spec]. +//! +//! These tests require a running `wrangler dev` instance of `mirror_worker` +//! on `localhost:8787` (or `BASE_URL`), backed by **fresh** persistent state. +//! Delete `crates/mirror_worker/.wrangler/state/` between runs to reset the +//! per-origin pending checkpoint the mirror has accepted. CI does this +//! automatically. +//! +//! # Test layout +//! +//! The mirror's `add-checkpoint` API is deeply stateful: each successful +//! submission advances a per-origin pending `(size, hash, signed_note_bytes)` +//! that every subsequent submission must be consistent with. Giving each +//! case its own `#[tokio::test]` would cross-pollute that state +//! unpredictably (tests run in a non-deterministic order by default), so +//! we collapse the scenarios into a single `#[tokio::test]` that threads +//! one in-memory [`ToyLog`] through every step in order. Each step +//! asserts the expected HTTP status and then (if the call was a +//! successful advance) updates the local log to match what the mirror +//! just recorded. +//! +//! Steps covered (happy-path + basic error codes per the spec): +//! +//! 1. `GET /` returns the configured mirror identity. +//! 2. First `add-checkpoint` (`old=0`, no proof) → 200 with empty body. +//! 3. Second `add-checkpoint` with consistency proof → 200, advancing +//! pending state. +//! 4. Stale `old_size` → 409 with `text/x.tlog.size` body. +//! 5. Unknown origin → 404. +//! 6. Signature from untrusted key → 403. +//! 7. Trusted `(name, id)` but garbage signature bytes → 403. +//! 8. Bad consistency proof (right size, wrong hashes) → 422. +//! 9. `old > checkpoint.size` → 400. +//! 10. Malformed body (missing blank line) → 400. +//! +//! Per spec, the mirror MUST NOT cosign on `add-checkpoint`; the response +//! body for a successful submission is empty. Cosignature emission +//! happens only on `add-entries` once entries catch up to the pending +//! tree size — that's a future slice and a future test. +//! +//! # Key management +//! +//! The SPKI committed to `crates/mirror_worker/config.dev.json` MUST +//! match the dev log key embedded below. (The mirror reuses the same +//! Ed25519 dev keypair as the witness — see +//! `crates/mirror_worker/src/lib.rs::dev_config_tests`.) If the configs +//! diverge the mirror will 403 the tests with "No valid signatures from +//! trusted log keys". +//! +//! [spec]: https://c2sp.org/tlog-mirror + +use ed25519_dalek::{pkcs8::DecodePrivateKey, SigningKey as Ed25519SigningKey}; +use rand::rng; +use signed_note::{KeyName, Note, NoteSignature}; +use std::time::Duration; +use tlog_checkpoint::{CheckpointSigner, Ed25519CheckpointSigner, TreeWithTimestamp}; +use tlog_core::{ + consistency_proof, record_hash, stored_hashes, tree_hash, Hash, HashReader, TlogError, + HASH_SIZE, +}; +use tlog_witness::{serialize_add_checkpoint_request, CONTENT_TYPE_TLOG_SIZE}; + +// --------------------------------------------------------------------------- +// Test fixtures: log origin + Ed25519 log key +// --------------------------------------------------------------------------- + +/// Origin the mirror is configured to accept checkpoints for (see +/// `crates/mirror_worker/config.dev.json`). +const LOG_ORIGIN: &str = "example.com/log1"; + +/// PKCS#8 PEM for the Ed25519 log key. The corresponding SPKI is +/// committed in `crates/mirror_worker/config.dev.json` (and identically +/// 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 MUST NOT be used for anything other than these integration tests. +const LOG_SIGNING_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MC4CAQAwBQYDK2VwBCIEIA2VCmSeCNVJTboEACcXvVahZHSHEJDxSl94aej1Q8hQ\n\ + -----END PRIVATE KEY-----\n"; + +fn log_signer() -> Ed25519CheckpointSigner { + let sk = Ed25519SigningKey::from_pkcs8_pem(LOG_SIGNING_KEY_PEM).expect("parse dev log key"); + let name = KeyName::new(LOG_ORIGIN.to_owned()).expect("KeyName for origin"); + Ed25519CheckpointSigner::new(name, sk) +} + +/// Generate a fresh Ed25519 log signer that the mirror does *not* trust +/// — used by the 403 step. +fn untrusted_log_signer() -> Ed25519CheckpointSigner { + let sk = Ed25519SigningKey::generate(&mut rng()); + let name = KeyName::new(LOG_ORIGIN.to_owned()).unwrap(); + Ed25519CheckpointSigner::new(name, sk) +} + +// --------------------------------------------------------------------------- +// Toy log: maintains enough state to produce valid checkpoints and +// consistency proofs for whatever sequence of leaves the test has +// pushed. Identical shape to the witness integration test. +// --------------------------------------------------------------------------- + +struct StoredHashes(Vec); + +impl HashReader for StoredHashes { + fn read_hashes(&self, indexes: &[u64]) -> std::result::Result, TlogError> { + indexes + .iter() + .map(|&i| { + self.0 + .get(usize::try_from(i).unwrap()) + .copied() + .ok_or(TlogError::IndexesNotInTree) + }) + .collect() + } +} + +struct ToyLog { + n: u64, + stored: StoredHashes, +} + +impl ToyLog { + fn new() -> Self { + Self { + n: 0, + stored: StoredHashes(Vec::new()), + } + } + + fn push(&mut self, data: &[u8]) { + let new = stored_hashes(self.n, data, &self.stored).expect("stored_hashes"); + self.stored.0.extend(new); + self.n += 1; + } + + fn size(&self) -> u64 { + self.n + } + + fn root(&self, size: u64) -> Hash { + tree_hash(size, &self.stored).expect("tree_hash") + } + + fn sign_checkpoint(&self, signer: &Ed25519CheckpointSigner) -> Vec { + let size = self.size(); + let hash = self.root(size); + let tree = TreeWithTimestamp::new(size, hash, now_millis()); + tree.sign(LOG_ORIGIN, &[], &[signer], &mut rng()) + .expect("sign checkpoint") + } + + /// `consistency_proof(old_size → current)`. Wraps + /// `tlog_core::consistency_proof`, whose argument order is reversed + /// from RFC 6962 convention (larger size first). + fn consistency_proof(&self, old_size: u64) -> Vec { + let size = self.size(); + if old_size == 0 || old_size == size { + return Vec::new(); + } + consistency_proof(size, old_size, &self.stored).expect("consistency_proof") + } +} + +fn now_millis() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(), + ) + .unwrap_or(u64::MAX) +} + +// --------------------------------------------------------------------------- +// HTTP plumbing +// --------------------------------------------------------------------------- + +fn base_url() -> String { + std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()) +} + +struct AddCheckpointResult { + status: u16, + content_type: Option, + body: Vec, +} + +async fn post_add_checkpoint(body: &[u8]) -> AddCheckpointResult { + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/add-checkpoint", base_url())) + .header("content-type", "text/plain; charset=utf-8") + .body(body.to_vec()) + .send() + .await + .expect("add-checkpoint request"); + let status = resp.status().as_u16(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(ToOwned::to_owned); + let body = resp.bytes().await.expect("response bytes").to_vec(); + AddCheckpointResult { + status, + content_type, + body, + } +} + +async fn fetch_root() -> String { + let client = reqwest::Client::new(); + let resp = client + .get(format!("{}/", base_url())) + .send() + .await + .expect("root request"); + assert_eq!(resp.status().as_u16(), 200, "root status"); + resp.text().await.expect("root text") +} + +async fn wait_for_mirror() { + for _ in 0..30 { + if reqwest::Client::new() + .get(format!("{}/", base_url())) + .send() + .await + .is_ok() + { + return; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + panic!("mirror did not become ready at {}", base_url()); +} + +// --------------------------------------------------------------------------- +// The whole test suite, as one ordered sequence. +// --------------------------------------------------------------------------- + +// Scenarios are intentionally collapsed into one `#[tokio::test]` so +// they thread through a single `ToyLog` — see the module-level comment +// above. That makes this function long by design. +#[allow(clippy::too_many_lines)] +#[tokio::test] +async fn tlog_mirror_end_to_end() { + wait_for_mirror().await; + + // ----------------------- (1) GET / ----------------------- + // + // The mirror does not yet expose a `/metadata` endpoint; this slice + // ships only `add-checkpoint` and a status root. Pin that the root + // identifies the configured mirror. + let root = fetch_root().await; + assert!( + root.contains("dev.mirror.example") && root.contains("c2sp.org/tlog-mirror"), + "root does not look like a mirror status page: {root:?}", + ); + + let signer = log_signer(); + let mut log = ToyLog::new(); + + // ----------------------- (2) First submission: old=0 ----------------------- + 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, &[], ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 200, + "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: {:?}", + String::from_utf8_lossy(&r.body) + ); + } + + // ----------------------- (3) Second submission with consistency proof ----------------------- + let old_size = log.size(); + log.push(b"leaf 1"); + { + let cp = log.sign_checkpoint(&signer); + let note = Note::from_bytes(&cp).unwrap(); + let proof = log.consistency_proof(old_size); + let body = serialize_add_checkpoint_request(old_size, &proof, ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 200, + "second submission: body={:?}", + String::from_utf8_lossy(&r.body) + ); + assert!(r.body.is_empty(), "200 response body must be empty"); + } + + // ----------------------- (4) Stale old_size → 409 ----------------------- + // At this point the mirror has recorded pending size = 2. Advance our + // local log to size = 4 and submit with old=1 (stale). + log.push(b"leaf 2"); + log.push(b"leaf 3"); + { + let cp = log.sign_checkpoint(&signer); + let note = Note::from_bytes(&cp).unwrap(); + let proof = log.consistency_proof(1); + let body = serialize_add_checkpoint_request(1, &proof, ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 409, + "stale old_size: body={:?}", + String::from_utf8_lossy(&r.body) + ); + assert_eq!( + r.content_type.as_deref().map(str::trim), + Some(CONTENT_TYPE_TLOG_SIZE), + "409 must be Content-Type {CONTENT_TYPE_TLOG_SIZE}" + ); + let size_str = std::str::from_utf8(&r.body).unwrap().trim_end_matches('\n'); + let recorded: u64 = size_str.parse().expect("409 body is a decimal size"); + assert_eq!( + recorded, 2, + "409 body must carry the mirror's latest pending size" + ); + } + + // ----------------------- (5) Unknown origin → 404 ----------------------- + { + let sk = Ed25519SigningKey::generate(&mut rng()); + let origin = "not.configured.example/log"; + let name = KeyName::new(origin.to_owned()).unwrap(); + let other_signer = Ed25519CheckpointSigner::new(name, sk); + let tree = TreeWithTimestamp::new(1, record_hash(b"x"), now_millis()); + let cp = tree + .sign(origin, &[], &[&other_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, + 404, + "unknown origin: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } + + // ----------------------- (6) Untrusted key → 403 ----------------------- + { + let other = untrusted_log_signer(); + let tree = TreeWithTimestamp::new(1, record_hash(b"x"), now_millis()); + let cp = tree.sign(LOG_ORIGIN, &[], &[&other], &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, + 403, + "untrusted key: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } + + // ----- (7) Trusted (name, id) but garbage signature bytes → 403 ----- + // + // Per c2sp.org/signed-note, a signature line that claims a trusted + // `(name, id)` but whose bytes fail to verify makes the note + // malformed. The mirror MUST surface this as 403 Forbidden (same + // as "no trusted signature at all"), matching the witness behaviour. + { + let verifier = signer.verifier(); + let tree = TreeWithTimestamp::new(1, record_hash(b"x"), now_millis()); + // Sign a valid checkpoint, then replace the log's real + // signature line with one carrying the right `(name, id)` but + // garbage bytes. + let cp = tree.sign(LOG_ORIGIN, &[], &[&signer], &mut rng()).unwrap(); + let parsed = Note::from_bytes(&cp).unwrap(); + let bogus = NoteSignature::new(verifier.name().clone(), verifier.key_id(), vec![0u8; 64]); + let tampered = Note::new(parsed.text(), &[bogus]).unwrap(); + let body = serialize_add_checkpoint_request(0, &[], &tampered).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 403, + "trusted key + bad sig bytes: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } + + // ----------------------- (8) Bad consistency proof → 422 ----------------------- + // Advance our local log by one more leaf so we need a proof to + // submit it, then hand-craft a wrong proof of the right length. + log.push(b"leaf 4"); + { + let cp = log.sign_checkpoint(&signer); + let note = Note::from_bytes(&cp).unwrap(); + let correct = log.consistency_proof(2); + let bogus = vec![Hash([0u8; HASH_SIZE]); correct.len().max(1)]; + let body = serialize_add_checkpoint_request(2, &bogus, ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 422, + "bad proof: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } + + // ----------------------- (9) old_size > checkpoint.size → 400 ----------------------- + { + // Build a small independent log so checkpoint.size is + // controllably small. + let mut small = ToyLog::new(); + small.push(b"x"); + let cp = small.sign_checkpoint(&signer); + let note = Note::from_bytes(&cp).unwrap(); + let body = serialize_add_checkpoint_request(999, &[], ¬e).unwrap(); + let r = post_add_checkpoint(&body).await; + assert_eq!( + r.status, + 400, + "old > checkpoint size: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } + + // ----------------------- (10) Malformed body → 400 ----------------------- + { + let r = post_add_checkpoint(b"old 0\n").await; + assert_eq!( + r.status, + 400, + "malformed body: body={:?}", + String::from_utf8_lossy(&r.body) + ); + } +} diff --git a/crates/mirror_worker/Cargo.toml b/crates/mirror_worker/Cargo.toml new file mode 100644 index 0000000..f30a7ef --- /dev/null +++ b/crates/mirror_worker/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "mirror_worker" +publish = false +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +readme.workspace = true +homepage.workspace = true +repository.workspace = true +description = "A transparency-log mirror (c2sp.org/tlog-mirror) on Cloudflare Workers" +categories = ["cryptography"] +keywords = ["transparency", "mirror", "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 = "mirror_worker_config" } +jsonschema.workspace = true +serde_json.workspace = true + +[dev-dependencies] +base64.workspace = true + +[dependencies] +config = { path = "./config", package = "mirror_worker_config" } +console_error_panic_hook.workspace = true +console_log.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true +hex.workspace = true +log.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_witness.workspace = true +worker.workspace = true + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(wasm_bindgen_unstable_test_coverage)', +] } + +[package.metadata.cargo-machete] +ignored = ["getrandom"] diff --git a/crates/mirror_worker/LICENSE b/crates/mirror_worker/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/mirror_worker/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/mirror_worker/build.rs b/crates/mirror_worker/build.rs new file mode 100644 index 0000000..5a6b0d3 --- /dev/null +++ b/crates/mirror_worker/build.rs @@ -0,0 +1,60 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Build script to include per-environment mirror configuration. +//! +//! The shape mirrors `crates/witness_worker/build.rs`: read +//! `config..json`, validate against `config.schema.json`, +//! deserialize into the strongly-typed [`config::AppConfig`], run the +//! cross-field validation, and stage a copy under `OUT_DIR/config.json` +//! so the worker can `include_str!` it at compile time. + +use config::AppConfig; +use std::env; +use std::fs; + +fn main() { + let env = env::var("DEPLOY_ENV").unwrap_or_else(|_| "dev".to_string()); + let config_file = &format!("config.{env}.json"); + let config_contents = &fs::read_to_string(config_file).unwrap_or_else(|e| { + panic!("failed to read config file '{config_file}': {e}"); + }); + + // Validate the config JSON against the schema. + let json = serde_json::from_str(config_contents).unwrap_or_else(|e| { + panic!("failed to deserialize JSON config '{config_file}': {e}"); + }); + let schema = serde_json::from_str(include_str!("config.schema.json")).unwrap_or_else(|e| { + panic!("failed to deserialize JSON schema 'config.schema.json': {e}"); + }); + jsonschema::validate(&schema, &json).unwrap_or_else(|e| { + panic!("config '{config_file}' does not match schema 'config.schema.json': {e}"); + }); + + // Deserialize to the strongly-typed config; this catches type/shape errors + // that the JSON Schema may not. + // + // Note: there is no need for a separate "duplicate origin" check here. + // Origins are the keys of `AppConfig.logs`, so duplicates are duplicate + // JSON object keys, which `serde_json` rejects at deserialization time. + let app_config: AppConfig = serde_json::from_str(config_contents).unwrap_or_else(|e| { + panic!("failed to parse '{config_file}' as AppConfig: {e}"); + }); + + // Run the canonical validation defined in the config crate. Failures + // surface as build errors with operator-readable messages. + app_config.validate().unwrap_or_else(|e| { + panic!("config '{config_file}' failed validation: {e}"); + }); + + // Copy to OUT_DIR for include_str! at compile time. + let out_dir = env::var("OUT_DIR").unwrap(); + fs::copy(config_file, format!("{out_dir}/config.json")).expect("failed to copy config file"); + + // Make DEPLOY_ENV available at compile time via env!() + println!("cargo::rustc-env=DEPLOY_ENV={env}"); + + println!("cargo::rerun-if-env-changed=DEPLOY_ENV"); + println!("cargo::rerun-if-changed=config.schema.json"); + println!("cargo::rerun-if-changed={config_file}"); +} diff --git a/crates/mirror_worker/config.dev.json b/crates/mirror_worker/config.dev.json new file mode 100644 index 0000000..321b019 --- /dev/null +++ b/crates/mirror_worker/config.dev.json @@ -0,0 +1,15 @@ +{ + "logging_level": "info", + "mirror_name": "dev.mirror.example", + "description": "Local-dev mirror 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_mirror.rs.", + "log_public_keys": [ + "MCowBQYDK2VwAyEAzdnT3CQc3ag2fmKnO1ntodIm0wfsymDkK89IOrHKLWc=" + ] + } + } +} diff --git a/crates/mirror_worker/config.schema.json b/crates/mirror_worker/config.schema.json new file mode 100644 index 0000000..7dc0bef --- /dev/null +++ b/crates/mirror_worker/config.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["mirror_name", "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 //...)." + }, + "logs": { + "type": "object", + "description": "Origin logs this mirror mirrors, 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 mirror accepts checkpoint signatures from any of these keys and ignores signatures from others." + } + } + } + } + } + } +} diff --git a/crates/mirror_worker/config/Cargo.toml b/crates/mirror_worker/config/Cargo.toml new file mode 100644 index 0000000..17d7ebd --- /dev/null +++ b/crates/mirror_worker/config/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mirror_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 mirror_worker" + +[dependencies] +ed25519-dalek.workspace = true +serde.workspace = true +serde_with.workspace = true +signed_note.workspace = true diff --git a/crates/mirror_worker/config/LICENSE b/crates/mirror_worker/config/LICENSE new file mode 120000 index 0000000..5853aae --- /dev/null +++ b/crates/mirror_worker/config/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/crates/mirror_worker/config/src/lib.rs b/crates/mirror_worker/config/src/lib.rs new file mode 100644 index 0000000..d6807f5 --- /dev/null +++ b/crates/mirror_worker/config/src/lib.rs @@ -0,0 +1,291 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Configuration for [`mirror_worker`](../mirror_worker/). +//! +//! A [`c2sp.org/tlog-mirror`][spec] mirror is configured with a list of +//! origin logs it mirrors. Each entry gives the log's `origin` (the first +//! line of its checkpoint notes) and one or more SPKI-encoded public keys +//! that the mirror will accept signatures from on incoming pending +//! checkpoints (via [`add-checkpoint`][add-cp]). +//! +//! The mirror's own ML-DSA-44 signing key (used for the +//! `cosignature/v1`-family `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). +//! +//! [spec]: https://c2sp.org/tlog-mirror +//! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint + +use ed25519_dalek::pkcs8::DecodePublicKey as _; +use ed25519_dalek::VerifyingKey as Ed25519VerifyingKey; +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 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, + /// Origin logs this mirror mirrors, 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. `mirror_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 mirror 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) mirror_name is a valid signed-note key name. + KeyName::new(self.mirror_name.clone()).map_err(|e| { + format!( + "mirror_name {:?} is not a valid signed-note key name: {e:?}", + self.mirror_name, + ) + })?; + + // (2-4) per-log validation. + for (origin, log) in &self.logs { + log.validate(origin)?; + } + + Ok(()) + } +} + +/// Per-log parameters: the public keys the mirror 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 mirror verifies incoming + /// pending 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; mirror 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, + mirror_name: "mirror.example/m".to_owned(), + description: None, + submission_prefix: "https://mirror.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_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}"); + } + + #[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_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"); + } + + #[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/mirror_worker/reset-dev.sh b/crates/mirror_worker/reset-dev.sh new file mode 100755 index 0000000..db07f17 --- /dev/null +++ b/crates/mirror_worker/reset-dev.sh @@ -0,0 +1,2 @@ +#!/bin/sh +rm -rf .wrangler/state diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs new file mode 100644 index 0000000..9c8d010 --- /dev/null +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -0,0 +1,298 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! HTTP entry point + handler 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. +//! - `GET /` — root status string. Convenience only; not part of the +//! spec. +//! +//! The mirror's per-origin persistent state lives in a [`MirrorState`] +//! Durable Object; see [`crate::mirror_state_do`] for details. Atomicity +//! of the "check old-pending-size, verify proof, update pending" +//! sequence follows from the DO's single-threaded fetch handler. +//! +//! Future slices will add `POST /add-entries` (entry ingestion + mirror +//! cosignature emission), `GET /metadata` (mirror identity + per-log +//! configuration, including the ML-DSA-44 mirror cosigner public key), +//! and the [tlog-tiles][tiles] read interface served at +//! `//...`. +//! +//! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint +//! [`MirrorState`]: crate::mirror_state_do +//! [tiles]: https://c2sp.org/tlog-tiles + +use signed_note::NoteError; +use tlog_checkpoint::CheckpointText; +use tlog_witness::{parse_add_checkpoint_request, AddCheckpointRequest, CONTENT_TYPE_TLOG_SIZE}; +#[allow(clippy::wildcard_imports)] +use worker::*; + +use crate::{ + log_verifiers, + mirror_state_do::{state_stub, PendingCheckpoint, UpdatePendingRequest}, + CONFIG, +}; + +/// Entry point: initialize logging. +#[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. +#[event(fetch, respond_with_errors)] +async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { + Router::new() + .post_async("/add-checkpoint", |req, ctx| async move { + add_checkpoint(req, ctx.env).await + }) + .get("/", |_req, _ctx| { + Response::ok(format!( + "{} — c2sp.org/tlog-mirror mirror\n", + CONFIG.mirror_name + )) + }) + .run(req, env) + .await +} + +/// Handle `POST /add-checkpoint`. +/// +/// Per [spec][add-cp]: "The request is handled identically to that of a +/// witness, updating the pending checkpoint (but not the mirror +/// checkpoint), with the exception that it does not need to generate +/// and respond with any cosignatures. The mirror MAY handle the request +/// by internally updating the pending checkpoint and responding with an +/// empty response body. The mirror MUST retain the log's signature in +/// the pending checkpoint." +/// +/// The flow: +/// +/// 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 *pending* state (via the +/// [`MirrorState`] DO): if `old_size` doesn't match the stored +/// pending 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 from the stored pending hash; on +/// failure → 422. +/// 8. Persist the new pending state atomically. The full signed-note +/// bytes are stored alongside size+hash so the mirror can later +/// serve them back to `add-entries` clients (per spec) — this also +/// satisfies the MUST-retain-the-signature requirement. +/// 9. Return 200 with an empty body. The mirror cosigner MUST NOT sign +/// here; cosignatures are emitted only by `add-entries` once entries +/// catch up to the pending tree size (a future slice). +/// +/// Steps 5, 6, and 7 are combined inside the DO's `/update-pending` RPC +/// so the "check then verify proof then write" sequence is atomic per +/// origin. +/// +/// [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint +/// [`MirrorState`]: crate::mirror_state_do +async fn add_checkpoint(mut req: Request, env: Env) -> Result { + // (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. + let body = req.bytes().await?; + if body.len() > MAX_ADD_CHECKPOINT_BODY_SIZE { + return Response::error( + format!("Bad request: body exceeds {MAX_ADD_CHECKPOINT_BODY_SIZE} bytes"), + 400, + ); + } + 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 Response::error(format!("Bad request: {e}"), 400); + } + }; + + // (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 Response::error(format!("Bad request: {e}"), 400); + } + }; + let origin = cp_text.origin(); + let Some(verifiers) = log_verifiers(origin) else { + return Response::error("Unknown log origin", 404); + }; + + // (3) Verify the checkpoint signature against trusted log keys. + // + // Per c2sp.org/tlog-witness (whose semantics the mirror inherits + // here), the verifier 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 the witness implementation. Other + // `NoteError` variants indicate a syntactically malformed + // signature line and are surfaced as `400 Bad Request`. + if let Err(e) = checkpoint.verify(&verifiers) { + match e { + NoteError::UnverifiedNote | NoteError::InvalidSignature { .. } => { + log::info!("add-checkpoint: rejecting note: {e:?}"); + return Response::error("No valid signatures from trusted log keys", 403); + } + _ => { + log::warn!("add-checkpoint: verify failed: {e:?}"); + return Response::error(format!("Bad request: {e}"), 400); + } + } + } + + // (4) Range check. + if old_size > cp_text.size() { + return Response::error( + format!( + "Bad request: old_size {old_size} > checkpoint size {}", + cp_text.size() + ), + 400, + ); + } + + // (5, 6, 7, 8) Atomic check-proof-and-update against the per-origin + // DO. See [`dispatch_update_pending`] for the status-code mapping. + // The full signed-note bytes are passed in so the DO can persist + // them alongside size+hash. + let update = UpdatePendingRequest { + old_size, + new_size: cp_text.size(), + new_hash: *cp_text.hash(), + proof: consistency_proof, + signed_note_bytes: checkpoint.text().to_vec(), + }; + if let Some(resp) = dispatch_update_pending(&env, origin, &update).await? { + return Ok(resp); + } + + // (9) Spec: respond with an empty body. The mirror cosigner MUST + // NOT sign here. + Response::empty() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// 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; + +/// 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 + .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 + }, + ..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: PendingCheckpoint = resp.json().await?; + Ok(Some(tlog_size_conflict(¤t)?)) + } + 422 => Ok(Some(Response::error( + "Unprocessable Entity: consistency proof failed", + 422, + )?)), + 400 => { + let msg = resp.text().await.unwrap_or_else(|_| "Bad request".into()); + Ok(Some(Response::error(format!("Bad request: {msg}"), 400)?)) + } + status => Ok(Some(Response::error( + format!("Internal error: DO returned {status}"), + 500, + )?)), + } +} + +/// 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) -> Result { + let body = format!("{}\n", current.size); + let headers = Headers::new(); + headers.set("content-type", CONTENT_TYPE_TLOG_SIZE)?; + Ok(Response::from_body(ResponseBody::Body(body.into_bytes()))? + .with_status(409) + .with_headers(headers)) +} diff --git a/crates/mirror_worker/src/lib.rs b/crates/mirror_worker/src/lib.rs new file mode 100644 index 0000000..7265cde --- /dev/null +++ b/crates/mirror_worker/src/lib.rs @@ -0,0 +1,167 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! A transparency-log mirror implementing [c2sp.org/tlog-mirror][spec] on +//! Cloudflare Workers. +//! +//! The mirror exposes the spec's submission endpoints — currently +//! [`add-checkpoint`][add-cp] (this crate slice) and, in upcoming slices, +//! [`add-entries`][add-e] and the [tlog-tiles][tiles] read interface. +//! +//! Per-origin persistent state — the *pending checkpoint* (the latest +//! signed checkpoint the mirror has accepted but not yet fully ingested +//! entries for) and, eventually, the *mirror checkpoint* (the latest +//! state for which the mirror has cosigned and made entries available) — +//! lives in a [`MirrorState`] Durable Object, one per log origin. The +//! DO's single-threaded execution model provides the atomic +//! "check-old-state, verify, persist-new-state" sequence the spec +//! requires. +//! +//! [spec]: https://c2sp.org/tlog-mirror +//! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint +//! [add-e]: https://c2sp.org/tlog-mirror#add-entries +//! [tiles]: https://c2sp.org/tlog-tiles +//! [`MirrorState`]: mirror_state_do::MirrorState + +use config::AppConfig; +use signed_note::{Ed25519NoteVerifier, KeyName, NoteVerifier, VerifierList}; +use std::collections::HashMap; +use std::sync::LazyLock; + +mod frontend_worker; +mod mirror_state_do; + +/// The binding name used in `wrangler.jsonc` for the [`MirrorState`] DO. +/// +/// [`MirrorState`]: mirror_state_do::MirrorState +pub(crate) const MIRROR_STATE_BINDING: &str = "MIRROR_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 +/// mirror 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 { + use ed25519_dalek::pkcs8::DecodePublicKey as _; + 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)) +} + +#[cfg(test)] +mod dev_config_tests { + //! Tests that pin invariants between `config.dev.json` and the + //! integration-test fixtures that mirror it. + //! + //! If either of these tests fails, the dev keypair embedded in + //! `crates/integration_tests/` and the SPKI in + //! `crates/mirror_worker/config.dev.json` have drifted out of sync; + //! rotate both together. The dev mirror reuses the same Ed25519 log + //! keypair that the dev witness uses (from + //! `crates/integration_tests/tests/tlog_witness.rs`) so a single + //! rotation rolls both worker configs forward. + + 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"); + + /// Dev log PEM. 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 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"; + + #[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", + ); + } +} + +// The `#[event(fetch)]` entry point lives in [`frontend_worker`]. diff --git a/crates/mirror_worker/src/mirror_state_do.rs b/crates/mirror_worker/src/mirror_state_do.rs new file mode 100644 index 0000000..b2d61df --- /dev/null +++ b/crates/mirror_worker/src/mirror_state_do.rs @@ -0,0 +1,370 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! [`MirrorState`] Durable Object: per-origin atomic state for the +//! [c2sp.org/tlog-mirror][spec] protocol. +//! +//! The DO holds two pieces of per-origin state: +//! +//! - `pending`: the latest signed checkpoint the mirror has accepted via +//! [`add-checkpoint`][add-cp] but has not yet fully ingested entries +//! for. Stored as the full signed-note bytes so the mirror can later +//! serve them back to `add-entries` clients (the spec recommends +//! storing the signed checkpoint in the ticket; we do that via +//! [`tlog_mirror::TicketMacer`] from the frontend, but we also keep +//! the latest pending here as the canonical source of truth for the +//! `add-checkpoint` consistency proof check). +//! +//! - `mirror`: the latest checkpoint for which the mirror has cosigned +//! and committed all entries. Always at-or-behind `pending`. Not yet +//! used in this slice — `add-entries` (a future slice) is what +//! advances `mirror`. +//! +//! For now, only `pending` is updated. The DO exposes a single internal +//! RPC consumed by the frontend handler in the same worker: +//! +//! - `POST /update-pending` — body is a JSON +//! [`UpdatePendingRequest`] carrying the client-claimed `old_size`, +//! the proposed new `size`/`hash`, the consistency proof, and the +//! full signed-note bytes of the new pending checkpoint. The DO reads +//! its persisted state, verifies that the recorded pending size +//! matches `old_size`, verifies the Merkle consistency proof against +//! the stored pending hash (when a proof is required), and on success +//! writes the new pending state and returns 200 with a +//! [`PendingCheckpoint`] body. On size / same-size-different-hash +//! mismatch it returns 409 with a [`PendingCheckpoint`] 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. +//! +//! Atomicity of the read-verify-compare-write sequence is provided by +//! Cloudflare Durable Objects' input/output gates (see the inline +//! commentary in the handler). +//! +//! [spec]: https://c2sp.org/tlog-mirror +//! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint + +use serde::{Deserialize, Serialize}; +use serde_with::{base64::Base64 as Base64As, serde_as}; +use tlog_core::{verify_consistency_proof, Hash}; +#[allow(clippy::wildcard_imports)] +use worker::*; + +use crate::MIRROR_STATE_BINDING; + +const PENDING_KEY: &str = "pending"; + +/// The persisted *pending checkpoint* for a single log origin. +/// +/// The mirror stores the full signed-note bytes (not just size+hash) so +/// that it can serve them back to `add-entries` clients via the ticket +/// scheme, and so the log's signature on the pending checkpoint is +/// retained per spec. +#[serde_as] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct PendingCheckpoint { + /// Tree size of the latest pending checkpoint. Zero if the mirror + /// has never accepted a pending checkpoint for this origin. + pub size: u64, + /// Root hash of the latest pending checkpoint. All-zero if `size` + /// is 0. + #[serde(with = "hash_hex")] + pub hash: Hash, + /// The full signed-note bytes of the pending checkpoint, including + /// the log's signature. Empty if `size` is 0. Encoded as base64 in + /// the on-disk JSON so the DO state remains valid UTF-8 (signed + /// notes are ASCII text but the JSON-with-arbitrary-bytes + /// alternative is fragile, and base64 keeps the storage layer + /// uniform with the wire format used by the ticket scheme). + #[serde_as(as = "Base64As")] + pub signed_note_bytes: Vec, +} + +/// Body of the internal `/update-pending` RPC. +#[serde_as] +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdatePendingRequest { + /// The client-claimed old size; must equal the persisted pending + /// 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 = "hash_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 = "hash_vec_hex")] + pub proof: Vec, + /// Full signed-note bytes of the new pending checkpoint. Persisted + /// alongside the size/hash so the mirror can serve them back to + /// `add-entries` clients. + #[serde_as(as = "Base64As")] + pub signed_note_bytes: Vec, +} + +/// A Durable Object holding the latest pending (and, in future slices, +/// committed) checkpoint state for a single log origin. +#[durable_object(fetch)] +struct MirrorState { + state: State, +} + +impl DurableObject for MirrorState { + fn new(state: State, _env: Env) -> Self { + Self { state } + } + + async fn fetch(&self, mut req: Request) -> Result { + let path = req.path(); + match (req.method(), path.as_str()) { + (Method::Post, "/update-pending") => { + // 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 /update-pending 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 accepted N+K" before N+K has actually + // been persisted as the new pending — so an + // immediately-following `add-entries` cannot race the + // write. + // + // See: https://developers.cloudflare.com/durable-objects/reference/in-memory-state/ + let body: UpdatePendingRequest = req.json().await?; + let current: PendingCheckpoint = self + .state + .storage() + .get(PENDING_KEY) + .await + .unwrap_or(None) + .unwrap_or_default(); + if current.size != body.old_size { + // Spec: respond with the latest pending 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 pending 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 pending checkpoint 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 = 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) + } + _ => 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(MIRROR_STATE_BINDING)?; + namespace.id_from_name(origin)?.get_stub() +} + +// --------------------------------------------------------------------------- +// Serde helpers: emit/parse `Hash` as hex. Same shape as +// `witness_worker/src/witness_state_do.rs`; we use hex so the DO's JSON +// state is human-readable in wrangler's dev console. The exact encoding +// is internal and doesn't need to be compact. +// --------------------------------------------------------------------------- +mod hash_hex { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use tlog_core::{Hash, HASH_SIZE}; + + pub fn serialize(h: &Hash, ser: S) -> std::result::Result + where + S: Serializer, + { + hex::encode(h.0).serialize(ser) + } + + pub fn deserialize<'de, D>(de: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(de)?; + from_hex(&s).map_err(serde::de::Error::custom) + } + + /// Decode a single hex-encoded [`Hash`]. Shared with the `Vec` + /// helper in the sibling `hash_vec_hex` module. + pub(super) fn from_hex(s: &str) -> std::result::Result { + let bytes: [u8; HASH_SIZE] = hex::decode(s) + .map_err(|e| e.to_string())? + .try_into() + .map_err(|v: Vec| format!("hash must be {} bytes, got {}", HASH_SIZE, v.len()))?; + Ok(Hash(bytes)) + } +} + +/// Serde helper for `Vec`. Encodes as a JSON array of hex strings +/// so the DO RPC body stays human-readable alongside the other +/// [`hash_hex`]-encoded fields. +mod hash_vec_hex { + use super::hash_hex::from_hex; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use tlog_core::Hash; + + pub fn serialize(v: &[Hash], ser: S) -> std::result::Result + where + S: Serializer, + { + v.iter() + .map(|h| hex::encode(h.0)) + .collect::>() + .serialize(ser) + } + + pub fn deserialize<'de, D>(de: D) -> std::result::Result, D::Error> + where + D: Deserializer<'de>, + { + let strs = Vec::::deserialize(de)?; + strs.iter() + .map(|s| from_hex(s).map_err(serde::de::Error::custom)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::{PendingCheckpoint, UpdatePendingRequest}; + use tlog_core::{Hash, HASH_SIZE}; + + /// 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() { + let mut bytes = [0u8; HASH_SIZE]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = u8::try_from(i).unwrap(); + } + let pc = PendingCheckpoint { + size: 42, + hash: Hash(bytes), + signed_note_bytes: b"signed-note-bytes".to_vec(), + }; + 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="}"# + ); + + // 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); + assert_eq!(decoded.signed_note_bytes, b"signed-note-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 update_pending_request_json_format_unchanged() { + let req = UpdatePendingRequest { + old_size: 10, + new_size: 20, + new_hash: Hash([0xaa; HASH_SIZE]), + proof: vec![Hash([0xbb; HASH_SIZE]), Hash([0xcc; HASH_SIZE])], + signed_note_bytes: b"sn".to_vec(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert_eq!( + json, + r#"{"old_size":10,"new_size":20,"new_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","proof":["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],"signed_note_bytes":"c24="}"# + ); + let decoded: UpdatePendingRequest = 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.signed_note_bytes, b"sn"); + } + + /// The proof array is empty for first-pending and same-size cases; + /// make sure it round-trips as `[]` not omitted. + #[test] + fn update_pending_request_empty_proof_roundtrip() { + let req = UpdatePendingRequest { + old_size: 0, + new_size: 1, + new_hash: Hash([0u8; HASH_SIZE]), + proof: vec![], + signed_note_bytes: 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: UpdatePendingRequest = serde_json::from_str(&json).unwrap(); + 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(); + assert_eq!(pc.size, 0); + assert_eq!(pc.hash.0, [0u8; HASH_SIZE]); + assert!(pc.signed_note_bytes.is_empty()); + } +} diff --git a/crates/mirror_worker/wrangler.jsonc b/crates/mirror_worker/wrangler.jsonc new file mode 100644 index 0000000..6e7feb7 --- /dev/null +++ b/crates/mirror_worker/wrangler.jsonc @@ -0,0 +1,34 @@ +{ + "name": "tlog-mirror", + "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. + "command": "cargo install -q worker-build@0.7.5 && DEPLOY_ENV=dev worker-build --release" + }, + "workers_dev": true, + "durable_objects": { + "bindings": [ + { + "name": "MIRROR_STATE", + "class_name": "MirrorState" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MirrorState"] + } + ] + } + } +} diff --git a/crates/tlog_mirror/Cargo.toml b/crates/tlog_mirror/Cargo.toml new file mode 100644 index 0000000..75bc2a7 --- /dev/null +++ b/crates/tlog_mirror/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "tlog_mirror" +# Held back from crates.io while c2sp.org/tlog-mirror stabilizes. Lift +# the publish gate (and remove this comment) once the spec is final. +publish = false +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +readme.workspace = true +homepage.workspace = true +repository.workspace = true +description = "An implementation of c2sp.org/tlog-mirror" +categories = ["cryptography"] +keywords = ["transparency", "mirror", "checkpoint", "crypto", "pki"] + +[package.metadata.release] +release = false + +[lib] +crate-type = ["rlib"] + +[dependencies] +base64.workspace = true +byteorder.workspace = true +hmac.workspace = true +sha2.workspace = true +thiserror.workspace = true +tlog_core.workspace = true diff --git a/crates/tlog_mirror/LICENSE b/crates/tlog_mirror/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/tlog_mirror/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/tlog_mirror/src/error.rs b/crates/tlog_mirror/src/error.rs new file mode 100644 index 0000000..0a3ee94 --- /dev/null +++ b/crates/tlog_mirror/src/error.rs @@ -0,0 +1,76 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Error types for `tlog_mirror`. + +use thiserror::Error; + +/// Errors returned when parsing tlog-mirror wire-format messages. +#[derive(Debug, Error)] +pub enum ParseError { + /// An IO error occurred while reading from the input stream. + #[error("io: {0}")] + Io(#[from] std::io::Error), + + /// The `log_origin_size` u16 prefix advertised more bytes than were + /// available in the input. + #[error("log_origin truncated: advertised {advertised} bytes")] + LogOriginTruncated { + /// Size advertised by the wire `log_origin_size` u16. + advertised: u16, + }, + + /// The `log_origin` bytes were not valid UTF-8. + #[error("log_origin is not valid UTF-8")] + LogOriginNotUtf8, + + /// `upload_start` was greater than `upload_end`. + #[error("upload_start ({start}) > upload_end ({end})")] + UploadRangeInverted { + /// `upload_start` from the wire. + start: u64, + /// `upload_end` from the wire. + end: u64, + }, + + /// `num_hashes` for a subtree consistency proof exceeded the spec's + /// maximum of 63. + #[error("num_hashes {0} exceeds spec maximum of 63")] + TooManyHashes(u8), + + /// The `text/x.tlog.mirror-info` body did not have exactly three + /// newline-terminated lines. + #[error("mirror-info body is malformed: {0}")] + MalformedMirrorInfo(&'static str), + + /// A decimal field in the `text/x.tlog.mirror-info` body could not be + /// parsed as a `u64`. + #[error("mirror-info decimal field {field} could not be parsed: {value:?}")] + InvalidDecimal { + /// Field name (`tree_size` or `next_entry`). + field: &'static str, + /// Verbatim bytes from the wire. + value: String, + }, + + /// The base64-encoded ticket in a `text/x.tlog.mirror-info` body could + /// not be decoded. + #[error("mirror-info ticket is not valid base64")] + InvalidTicketBase64, +} + +/// Errors returned by [`TicketMacer`](crate::TicketMacer). +#[derive(Debug, Error)] +pub enum TicketError { + /// The sealed ticket was shorter than the HMAC tag length + /// ([`TAG_LEN`](crate::TAG_LEN), 16 bytes), so it cannot possibly be + /// a valid ticket. + #[error("sealed ticket too short: {0} bytes, need at least 16")] + TooShort(usize), + + /// HMAC tag verification failed: either the tag or the payload has + /// been tampered with, or the ticket was authenticated with a + /// different key. + #[error("ticket authentication failed")] + AuthenticationFailed, +} diff --git a/crates/tlog_mirror/src/lib.rs b/crates/tlog_mirror/src/lib.rs new file mode 100644 index 0000000..c528e6b --- /dev/null +++ b/crates/tlog_mirror/src/lib.rs @@ -0,0 +1,46 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! An implementation of [c2sp.org/tlog-mirror](https://c2sp.org/tlog-mirror). +//! +//! This crate provides the wire-format pieces needed to build a tlog +//! mirror, deliberately scoped to spec-level concerns: +//! +//! * [`wire::AddEntriesRequestHeader`] and [`wire::EntryPackage`] for the +//! `add-entries` request body. +//! * [`wire::MirrorInfo`] for the `text/x.tlog.mirror-info` 409 Conflict +//! response body. +//! * [`TicketMacer`] for the default opaque-ticket authentication +//! scheme (HMAC-SHA-256 truncated to 128 bits over the +//! signed-checkpoint bytes). The construction is deterministic; +//! confidentiality is not required because pending checkpoints are +//! public. +//! +//! The mirror's `add-checkpoint` endpoint handles requests identically +//! to a witness's `add-checkpoint` (per spec) but writes to a *pending* +//! checkpoint slot rather than the witness's monotonic checkpoint state. +//! The wire format is reused via the `tlog_witness` crate; the request +//! handler is not currently shared and lives in each worker's frontend. +//! +//! Storage, retention, and pruning policy are out of scope for this +//! crate. +//! +//! See the spec for a full description of the mirror protocol. + +// TODO: factor the witness `add-checkpoint` request handler currently +// living in `crates/witness_worker/src/frontend_worker.rs` into a +// reusable helper (likely in `tlog_witness` or a new `tlog_witness_io` +// crate) so that mirrors and witnesses share the implementation, not +// just the wire format. Tracked alongside #230. + +pub mod ticket; +pub mod wire; + +mod error; + +pub use error::{ParseError, TicketError}; +pub use ticket::{TicketMacer, TAG_LEN}; +pub use wire::{ + package_ranges, AddEntriesRequestHeader, EntryPackage, MirrorInfo, PackageRanges, + MAX_HASHES_PER_PROOF, MIRROR_INFO_CONTENT_TYPE, PACKAGE_ALIGNMENT, +}; diff --git a/crates/tlog_mirror/src/ticket.rs b/crates/tlog_mirror/src/ticket.rs new file mode 100644 index 0000000..988eaad --- /dev/null +++ b/crates/tlog_mirror/src/ticket.rs @@ -0,0 +1,213 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Default ticket authentication scheme for tlog-mirror operators. +//! +//! The c2sp.org/tlog-mirror spec leaves the ticket payload opaque and +//! requires only that the mirror authenticate any data it derives from +//! a ticket. Pending checkpoints are public data (operators publish them +//! at `//checkpoint`), so the ticket +//! has no confidentiality requirement; authentication is sufficient. +//! +//! [`TicketMacer`] uses HMAC-SHA-256 truncated to 128 bits as the +//! authentication tag. The ticket layout is: +//! +//! ```text +//! ticket = tag (16 bytes) || plaintext +//! ``` +//! +//! where `tag = HMAC-SHA-256(key, plaintext)[..16]`. A 128-bit tag +//! provides 2^128 forgery resistance, matching AES-GCM tag strength and +//! common practice in TLS and `IPsec`. Truncation of HMAC-SHA-256 to 128 +//! bits is endorsed by NIST SP 800-107 (which permits truncation down to +//! 32 bits and recommends 64+ for general use, 96+ for high-security +//! contexts) and used by widely-deployed protocols including +//! `HMAC-SHA256-128` TLS cipher suites and `IPsec` ESP +//! `AUTH_HMAC_SHA2_256_128`. +//! +//! The construction is **deterministic**: identical plaintexts produce +//! identical tickets. This is appropriate because pending-checkpoint +//! bytes are public, so linkability across retries reveals no information +//! that the mirror does not already publish. +//! +//! Mirror operators who want a different payload type, AAD binding, or +//! confidentiality (e.g. AEAD over a structured plaintext) MAY ignore +//! this module and roll their own; the wire format treats the ticket as +//! opaque bytes. + +use hmac::{Hmac, KeyInit, Mac}; +use sha2::Sha256; + +use crate::error::TicketError; + +/// Length of the HMAC-SHA-256 authentication tag, in bytes. The full +/// SHA-256 output is 32 bytes; we truncate to the leftmost 16 bytes. +pub const TAG_LEN: usize = 16; + +type HmacSha256 = Hmac; + +/// HMAC-SHA-256-128-based ticket authenticator. +/// +/// Holds an immutable HMAC instance preinitialized with the operator's +/// secret key. Sealing a ticket allocates a `Vec` containing +/// `tag_16 || plaintext`. Opening a ticket constant-time-verifies the +/// 128-bit tag and returns a slice of the original plaintext, or a +/// [`TicketError`] on tamper or short input. +#[derive(Clone)] +pub struct TicketMacer { + /// HMAC instance with the key already mixed in. Cloning is cheap + /// (clones the precomputed inner/outer SHA-256 state) and avoids + /// the per-seal HMAC key-derivation cost. + mac: HmacSha256, +} + +impl TicketMacer { + /// Construct a new authenticator from a 32-byte symmetric key. + /// + /// The key length is not security-critical for HMAC — any byte string + /// will work — but a 32-byte key matches SHA-256's output size and is + /// recommended by RFC 2104. + /// + /// # Panics + /// Cannot panic in practice. `Hmac::new_from_slice` only fails on + /// implementations with key-size constraints; HMAC accepts keys of + /// any length. + #[must_use] + pub fn new(key: &[u8; 32]) -> Self { + let mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of any length"); + Self { mac } + } + + /// Authenticate a payload, returning `tag_16 || plaintext`. + #[must_use] + pub fn seal(&self, payload: &[u8]) -> Vec { + let mut mac = self.mac.clone(); + mac.update(payload); + let full_tag = mac.finalize().into_bytes(); + let mut out = Vec::with_capacity(TAG_LEN + payload.len()); + out.extend_from_slice(&full_tag[..TAG_LEN]); + out.extend_from_slice(payload); + out + } + + /// Open an authenticated ticket, returning a slice of the original + /// plaintext. The returned slice borrows from `sealed`. + /// + /// # Errors + /// Returns [`TicketError::TooShort`] if `sealed` is shorter than + /// [`TAG_LEN`] (and so cannot contain a valid tag), or + /// [`TicketError::AuthenticationFailed`] if the tag does not validate + /// (tamper, wrong key, etc.). Tag comparison is constant-time. + pub fn open<'a>(&self, sealed: &'a [u8]) -> Result<&'a [u8], TicketError> { + if sealed.len() < TAG_LEN { + return Err(TicketError::TooShort(sealed.len())); + } + let (tag, payload) = sealed.split_at(TAG_LEN); + let mut mac = self.mac.clone(); + mac.update(payload); + // `verify_truncated_left` does the constant-time compare against + // the left-truncated full tag, which is exactly our scheme. + mac.verify_truncated_left(tag) + .map_err(|_| TicketError::AuthenticationFailed)?; + Ok(payload) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn macer() -> TicketMacer { + TicketMacer::new(&[0x42; 32]) + } + + #[test] + fn seal_open_roundtrip() { + let m = macer(); + let payload = b"signed-checkpoint-bytes-here"; + let sealed = m.seal(payload); + // Layout: 16-byte tag || plaintext. + assert_eq!(sealed.len(), TAG_LEN + payload.len()); + let opened = m.open(&sealed).unwrap(); + assert_eq!(opened, payload); + } + + #[test] + fn seal_is_deterministic() { + let m = macer(); + let payload = b"same plaintext"; + let a = m.seal(payload); + let b = m.seal(payload); + // Identical plaintexts produce identical tickets. This is the + // intended behaviour: pending-checkpoint bytes are public, and + // determinism makes the ticket a content-addressable handle on + // the pending checkpoint. + assert_eq!(a, b); + } + + #[test] + fn open_rejects_short_input() { + let m = macer(); + let err = m.open(b"short").unwrap_err(); + assert!(matches!(err, TicketError::TooShort(5))); + } + + #[test] + fn open_rejects_empty_input() { + let m = macer(); + let err = m.open(b"").unwrap_err(); + assert!(matches!(err, TicketError::TooShort(0))); + } + + #[test] + fn open_rejects_tampered_payload() { + let m = macer(); + let mut sealed = m.seal(b"hello"); + // Flip a bit in the payload (past the tag). + sealed[TAG_LEN] ^= 0x01; + let err = m.open(&sealed).unwrap_err(); + assert!(matches!(err, TicketError::AuthenticationFailed)); + } + + #[test] + fn open_rejects_tampered_tag() { + let m = macer(); + let mut sealed = m.seal(b"hello"); + // Flip a bit in the tag. + sealed[0] ^= 0x01; + let err = m.open(&sealed).unwrap_err(); + assert!(matches!(err, TicketError::AuthenticationFailed)); + } + + #[test] + fn open_rejects_wrong_key() { + let a = macer(); + let b = TicketMacer::new(&[0x07; 32]); + let sealed = a.seal(b"hello"); + let err = b.open(&sealed).unwrap_err(); + assert!(matches!(err, TicketError::AuthenticationFailed)); + } + + #[test] + fn seal_open_roundtrip_empty_payload() { + let m = macer(); + let sealed = m.seal(b""); + // 16-byte tag, no payload. + assert_eq!(sealed.len(), TAG_LEN); + assert_eq!(m.open(&sealed).unwrap(), b""); + } + + #[test] + fn open_returns_payload_borrow() { + // Sanity-check that `open` returns a slice into the input buffer + // rather than allocating. This is the documented behaviour and + // matches our "data is public, just authenticate" stance. + let m = macer(); + let sealed = m.seal(b"world"); + let opened = m.open(&sealed).unwrap(); + // The returned slice should point inside `sealed`. + let sealed_range = sealed.as_ptr_range(); + let opened_ptr = opened.as_ptr(); + assert!(sealed_range.start <= opened_ptr && opened_ptr < sealed_range.end); + } +} diff --git a/crates/tlog_mirror/src/wire.rs b/crates/tlog_mirror/src/wire.rs new file mode 100644 index 0000000..4f4a226 --- /dev/null +++ b/crates/tlog_mirror/src/wire.rs @@ -0,0 +1,701 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Wire format for the c2sp.org/tlog-mirror HTTP API. +//! +//! This module implements the framing for two message bodies defined by +//! [c2sp.org/tlog-mirror](https://c2sp.org/tlog-mirror): +//! +//! * The `add-entries` request body, which is a header followed by a +//! sequence of *entry packages*, each with a subtree consistency proof. +//! See [`AddEntriesRequestHeader`], [`EntryPackage`], and +//! [`package_ranges`]. +//! * The `text/x.tlog.mirror-info` 409 response body, which is three +//! newline-terminated lines: pending-checkpoint tree size, next entry, +//! and an opaque base64-encoded ticket. See [`MirrorInfo`]. +//! +//! `add-entries` request bodies have unbounded size, so this module +//! provides parsers that work against `Read`/`Write` streams rather than +//! whole-buffer slurp parsers. Callers stream the header, then iterate +//! the deterministic ranges yielded by [`package_ranges`] and parse each +//! [`EntryPackage`] in turn. + +use std::io::{self, Read, Write}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use tlog_core::{Hash, HASH_SIZE}; + +use crate::error::ParseError; + +/// `Content-Type` of the 409 Conflict response body returned by +/// `add-entries` and `add-checkpoint`. See +/// [c2sp.org/tlog-mirror](https://c2sp.org/tlog-mirror#add-entries). +pub const MIRROR_INFO_CONTENT_TYPE: &str = "text/x.tlog.mirror-info"; + +/// Spec-defined maximum number of hashes in a single subtree consistency +/// proof, per +/// [c2sp.org/tlog-mirror](https://c2sp.org/tlog-mirror#add-entries). +pub const MAX_HASHES_PER_PROOF: u8 = 63; + +/// Entry packages are aligned to multiples of this many entries, matching +/// the entry-bundle granularity from +/// [c2sp.org/tlog-tiles](https://c2sp.org/tlog-tiles). +pub const PACKAGE_ALIGNMENT: u64 = 256; + +/// Header parsed from an `add-entries` request body. +/// +/// The header is followed by a deterministic sequence of [`EntryPackage`]s +/// covering the half-open interval `[upload_start, upload_end)`. Use +/// [`package_ranges`] to enumerate the per-package `[start, end)` pairs in +/// the order they appear on the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddEntriesRequestHeader { + /// Origin string of the log being uploaded to. Maximum 65535 bytes + /// (u16 length prefix on the wire). UTF-8. + pub log_origin: String, + /// First log index in the upload, inclusive. Must be `<= upload_end`. + pub upload_start: u64, + /// First log index after the upload, exclusive. Must equal the tree + /// size of a known pending checkpoint per the spec. + pub upload_end: u64, + /// Opaque ticket bytes. May be empty. Maximum 65535 bytes (u16 length + /// prefix on the wire). The default authentication scheme is + /// provided by [`TicketMacer`](crate::TicketMacer); operators MAY use + /// any authenticated payload. + pub ticket: Vec, +} + +impl AddEntriesRequestHeader { + /// Read the header from the start of an `add-entries` request body. + /// + /// Reads exactly the header bytes, leaving the cursor positioned at + /// the first entry package (or end-of-stream if `upload_start == + /// upload_end`). + /// + /// # Errors + /// Returns [`ParseError::Io`] on short reads, [`ParseError::LogOriginNotUtf8`] + /// if the origin string is not valid UTF-8, or + /// [`ParseError::UploadRangeInverted`] if `upload_start > upload_end`. + pub fn read_from(mut reader: R) -> Result { + let log_origin_size = reader.read_u16::()?; + let mut log_origin_bytes = vec![0u8; usize::from(log_origin_size)]; + reader.read_exact(&mut log_origin_bytes).map_err(|e| { + if e.kind() == io::ErrorKind::UnexpectedEof { + ParseError::LogOriginTruncated { + advertised: log_origin_size, + } + } else { + ParseError::Io(e) + } + })?; + let log_origin = + String::from_utf8(log_origin_bytes).map_err(|_| ParseError::LogOriginNotUtf8)?; + + let upload_start = reader.read_u64::()?; + let upload_end = reader.read_u64::()?; + if upload_start > upload_end { + return Err(ParseError::UploadRangeInverted { + start: upload_start, + end: upload_end, + }); + } + + let ticket_size = reader.read_u16::()?; + let mut ticket = vec![0u8; usize::from(ticket_size)]; + reader.read_exact(&mut ticket)?; + + Ok(Self { + log_origin, + upload_start, + upload_end, + ticket, + }) + } + + /// Write the header to the start of an `add-entries` request body. + /// + /// # Errors + /// Returns [`io::ErrorKind::InvalidInput`] if `log_origin` or `ticket` + /// exceeds 65535 bytes (the u16 length-prefix limit). Otherwise + /// propagates the writer's IO errors. + pub fn write_to(&self, mut writer: W) -> io::Result<()> { + let log_origin_size = + u16::try_from(self.log_origin.len()).map_err(|_| oversize("log_origin"))?; + let ticket_size = u16::try_from(self.ticket.len()).map_err(|_| oversize("ticket"))?; + writer.write_u16::(log_origin_size)?; + writer.write_all(self.log_origin.as_bytes())?; + writer.write_u64::(self.upload_start)?; + writer.write_u64::(self.upload_end)?; + writer.write_u16::(ticket_size)?; + writer.write_all(&self.ticket)?; + Ok(()) + } +} + +fn oversize(field: &'static str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{field} exceeds u16 length prefix (max {})", u16::MAX), + ) +} + +/// Iterator over the deterministic `[start, end)` ranges of the entry +/// packages that an `add-entries` body will contain, given an +/// `[upload_start, upload_end)` interval. +/// +/// Per the spec, packages are aligned at multiples of [`PACKAGE_ALIGNMENT`] +/// (256), so the first and last package are typically partial. Returns an +/// empty iterator when `upload_start == upload_end`. +/// +/// Callers SHOULD use this to enumerate package ranges rather than +/// recomputing the rounding math, both because it is easy to get wrong and +/// because future spec extensions may change the alignment. +#[must_use] +pub fn package_ranges(upload_start: u64, upload_end: u64) -> PackageRanges { + PackageRanges { + upload_end, + next: upload_start, + } +} + +/// Iterator returned by [`package_ranges`]. +#[derive(Debug, Clone)] +pub struct PackageRanges { + upload_end: u64, + next: u64, +} + +impl Iterator for PackageRanges { + type Item = (u64, u64); + + fn next(&mut self) -> Option { + if self.next >= self.upload_end { + return None; + } + // From the spec: with rounded_start = upload_start rounded down to + // a multiple of 256 and rounded_end = upload_end rounded up, + // package i covers [start, end) where + // start = max(upload_start, rounded_start + i * 256) + // end = min(upload_end, rounded_start + (i + 1) * 256) + let start = self.next; + // Round `start` down to the next package boundary, then advance one + // package. Equivalent to `rounded_start + (i+1) * 256` from the + // spec since `start` is by construction `>= rounded_start + i*256`. + // Saturate at `upload_end` if the multiplication would overflow + // (only possible for `start >= u64::MAX - 254`, i.e. malicious or + // buggy wire input). The min-with-`upload_end` below would clamp + // to that value anyway in the non-overflowing case. + let next_boundary = (start / PACKAGE_ALIGNMENT) + .checked_add(1) + .and_then(|q| q.checked_mul(PACKAGE_ALIGNMENT)) + .unwrap_or(self.upload_end); + let end = next_boundary.min(self.upload_end); + self.next = end; + Some((start, end)) + } +} + +/// One entry package in an `add-entries` request body. +/// +/// Each package contains the log entries for a `[start, end)` range and a +/// subtree consistency proof from the subtree +/// `[rounded_start + i * 256, end)` to the checkpoint at `upload_end`. +/// +/// Only [`PartialEq`] is implemented because [`tlog_core::Hash`] is +/// `PartialEq` only. +#[derive(Debug, Clone, PartialEq)] +pub struct EntryPackage { + /// Log entries in this package, in order. Each entry's length is + /// represented as a u16 big-endian prefix on the wire (max 65535 + /// bytes). The number of entries equals `end - start` for the + /// package's `(start, end)` range. + pub entries: Vec>, + /// Subtree consistency proof hashes. Maximum 63 elements per + /// [`MAX_HASHES_PER_PROOF`]; an empty vector is valid. + pub proof: Vec, +} + +impl EntryPackage { + /// Read one entry package from `reader`. The caller MUST pass + /// `num_entries = end - start`, where `(start, end)` is the package's + /// range as returned by [`package_ranges`]. + /// + /// # Errors + /// Returns [`ParseError::Io`] on short reads or + /// [`ParseError::TooManyHashes`] if `num_hashes` exceeds 63. + pub fn read_from(mut reader: R, num_entries: u64) -> Result { + let num_entries = usize::try_from(num_entries) + .map_err(|_| io::Error::other("num_entries overflows usize"))?; + let mut entries = Vec::with_capacity(num_entries); + for _ in 0..num_entries { + let entry_size = reader.read_u16::()?; + let mut entry = vec![0u8; usize::from(entry_size)]; + reader.read_exact(&mut entry)?; + entries.push(entry); + } + + let num_hashes = reader.read_u8()?; + if num_hashes > MAX_HASHES_PER_PROOF { + return Err(ParseError::TooManyHashes(num_hashes)); + } + let mut proof = Vec::with_capacity(usize::from(num_hashes)); + for _ in 0..num_hashes { + let mut hash = [0u8; HASH_SIZE]; + reader.read_exact(&mut hash)?; + proof.push(Hash(hash)); + } + + Ok(Self { entries, proof }) + } + + /// Write one entry package to `writer`. + /// + /// # Errors + /// Returns [`io::ErrorKind::InvalidInput`] if the package contains more + /// than 63 proof hashes (per spec) or if any entry exceeds 65535 bytes + /// (per the u16 length prefix). Otherwise propagates the writer's IO + /// errors. + pub fn write_to(&self, mut writer: W) -> io::Result<()> { + if self.proof.len() > usize::from(MAX_HASHES_PER_PROOF) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "proof has {} hashes, max is {MAX_HASHES_PER_PROOF}", + self.proof.len() + ), + )); + } + for entry in &self.entries { + let len = u16::try_from(entry.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("entry of {} bytes exceeds u16 length prefix", entry.len()), + ) + })?; + writer.write_u16::(len)?; + writer.write_all(entry)?; + } + // Length already validated; cast is safe. + #[allow(clippy::cast_possible_truncation)] + writer.write_u8(self.proof.len() as u8)?; + for hash in &self.proof { + writer.write_all(&hash.0)?; + } + Ok(()) + } +} + +/// Body of a `text/x.tlog.mirror-info` 409 Conflict response, returned by +/// the mirror's `add-entries` or `add-checkpoint` endpoint when the client +/// is out of sync. +/// +/// On the wire this is three lines, each terminated by `\n`: +/// +/// 1. `tree_size` of a valid pending checkpoint, in decimal +/// 2. `next_entry`, in decimal +/// 3. an opaque `ticket` value, base64-encoded (may be empty) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MirrorInfo { + /// Tree size of a valid pending checkpoint that the client should + /// retry against. + pub tree_size: u64, + /// Next entry index the mirror is expecting. + pub next_entry: u64, + /// Opaque ticket bytes (already base64-decoded). May be empty. + pub ticket: Vec, +} + +impl MirrorInfo { + /// Parse a `text/x.tlog.mirror-info` 409 response body. + /// + /// # Errors + /// Returns [`ParseError::MalformedMirrorInfo`] if the body does not + /// have exactly three `\n`-terminated lines, [`ParseError::InvalidDecimal`] + /// if the first or second line is not a `u64`, or + /// [`ParseError::InvalidTicketBase64`] if the third line is not valid + /// base64. + pub fn parse(body: &[u8]) -> Result { + let s = std::str::from_utf8(body) + .map_err(|_| ParseError::MalformedMirrorInfo("not valid UTF-8"))?; + let mut lines = s.split_inclusive('\n'); + let tree_size_line = lines + .next() + .ok_or(ParseError::MalformedMirrorInfo("missing tree_size line"))?; + let next_entry_line = lines + .next() + .ok_or(ParseError::MalformedMirrorInfo("missing next_entry line"))?; + let ticket_line = lines + .next() + .ok_or(ParseError::MalformedMirrorInfo("missing ticket line"))?; + if lines.next().is_some() { + return Err(ParseError::MalformedMirrorInfo("unexpected trailing data")); + } + let tree_size_str = + tree_size_line + .strip_suffix('\n') + .ok_or(ParseError::MalformedMirrorInfo( + "tree_size line not newline-terminated", + ))?; + let next_entry_str = + next_entry_line + .strip_suffix('\n') + .ok_or(ParseError::MalformedMirrorInfo( + "next_entry line not newline-terminated", + ))?; + let ticket_str = ticket_line + .strip_suffix('\n') + .ok_or(ParseError::MalformedMirrorInfo( + "ticket line not newline-terminated", + ))?; + + let tree_size = parse_decimal_u64("tree_size", tree_size_str)?; + let next_entry = parse_decimal_u64("next_entry", next_entry_str)?; + let ticket = BASE64 + .decode(ticket_str) + .map_err(|_| ParseError::InvalidTicketBase64)?; + Ok(Self { + tree_size, + next_entry, + ticket, + }) + } + + /// Serialize this `MirrorInfo` as a `text/x.tlog.mirror-info` body. + #[must_use] + pub fn to_body(&self) -> Vec { + let mut out = Vec::with_capacity( + // Worst-case decimals (20 chars each) + 3 newlines + base64 + // (~4/3 expansion, rounded up). + 20 + 1 + 20 + 1 + (self.ticket.len().div_ceil(3) * 4) + 1, + ); + // Writing into Vec is infallible. + let _ = writeln!(out, "{}", self.tree_size); + let _ = writeln!(out, "{}", self.next_entry); + out.extend_from_slice(BASE64.encode(&self.ticket).as_bytes()); + out.push(b'\n'); + out + } +} + +/// Strict decimal-`u64` parser used for the `text/x.tlog.mirror-info` +/// body. Rejects empty input, leading zeros (except for the literal +/// `"0"`), leading `+`, negative numbers, leading whitespace, and any +/// non-ASCII-decimal byte. The spec requires "in decimal" and we want a +/// single canonical encoding for each integer. +fn parse_decimal_u64(field: &'static str, value: &str) -> Result { + // Reject negative signs, leading `+`, leading whitespace, leading + // zeros, and any non-ASCII-decimal byte. The spec says "in decimal" + // and we want a single canonical encoding for each integer. + if value.is_empty() { + return Err(ParseError::InvalidDecimal { + field, + value: value.to_owned(), + }); + } + if value.len() > 1 && value.starts_with('0') { + return Err(ParseError::InvalidDecimal { + field, + value: value.to_owned(), + }); + } + if !value.bytes().all(|b| b.is_ascii_digit()) { + return Err(ParseError::InvalidDecimal { + field, + value: value.to_owned(), + }); + } + value + .parse::() + .map_err(|_| ParseError::InvalidDecimal { + field, + value: value.to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn sample_header() -> AddEntriesRequestHeader { + AddEntriesRequestHeader { + log_origin: "log.example/m1".to_owned(), + upload_start: 12_345, + upload_end: 12_400, + ticket: vec![0xAB, 0xCD, 0xEF], + } + } + + #[test] + fn header_roundtrip() { + let header = sample_header(); + let mut buf = Vec::new(); + header.write_to(&mut buf).unwrap(); + let parsed = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap(); + assert_eq!(parsed, header); + } + + #[test] + fn header_roundtrip_empty_origin_and_ticket() { + let header = AddEntriesRequestHeader { + log_origin: String::new(), + upload_start: 0, + upload_end: 0, + ticket: Vec::new(), + }; + let mut buf = Vec::new(); + header.write_to(&mut buf).unwrap(); + // Exact-bytes pin: u16(0) || u64(0) || u64(0) || u16(0) = 20 zero bytes. + assert_eq!(buf, vec![0u8; 20]); + let parsed = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap(); + assert_eq!(parsed, header); + } + + #[test] + fn header_rejects_upload_range_inverted() { + let mut buf = Vec::new(); + // log_origin_size = 0 + buf.extend_from_slice(&0u16.to_be_bytes()); + // upload_start = 100, upload_end = 50 (inverted) + buf.extend_from_slice(&100u64.to_be_bytes()); + buf.extend_from_slice(&50u64.to_be_bytes()); + // ticket_size = 0 + buf.extend_from_slice(&0u16.to_be_bytes()); + let err = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap_err(); + assert!(matches!( + err, + ParseError::UploadRangeInverted { + start: 100, + end: 50 + } + )); + } + + #[test] + fn header_rejects_non_utf8_origin() { + let mut buf = Vec::new(); + buf.extend_from_slice(&2u16.to_be_bytes()); // log_origin_size = 2 + buf.extend_from_slice(&[0xFF, 0xFE]); // not valid UTF-8 + buf.extend_from_slice(&0u64.to_be_bytes()); + buf.extend_from_slice(&0u64.to_be_bytes()); + buf.extend_from_slice(&0u16.to_be_bytes()); + let err = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap_err(); + assert!(matches!(err, ParseError::LogOriginNotUtf8)); + } + + #[test] + fn header_rejects_truncated_origin() { + let mut buf = Vec::new(); + buf.extend_from_slice(&5u16.to_be_bytes()); // claims 5 origin bytes + buf.extend_from_slice(b"abc"); // but only 3 follow + let err = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap_err(); + assert!(matches!( + err, + ParseError::LogOriginTruncated { advertised: 5 } + )); + } + + #[test] + fn package_ranges_empty_when_start_equals_end() { + let ranges: Vec<_> = package_ranges(1024, 1024).collect(); + assert!(ranges.is_empty()); + } + + #[test] + fn package_ranges_aligned() { + // Both endpoints aligned: exactly two full packages. + let ranges: Vec<_> = package_ranges(0, 512).collect(); + assert_eq!(ranges, vec![(0, 256), (256, 512)]); + } + + #[test] + fn package_ranges_partial_first_only() { + // Start mid-package, end aligned. + let ranges: Vec<_> = package_ranges(100, 256).collect(); + assert_eq!(ranges, vec![(100, 256)]); + } + + #[test] + fn package_ranges_partial_last_only() { + // Start aligned, end mid-package. + let ranges: Vec<_> = package_ranges(256, 300).collect(); + assert_eq!(ranges, vec![(256, 300)]); + } + + #[test] + fn package_ranges_partial_both() { + // Start and end both mid-package, spanning multiple packages. + let ranges: Vec<_> = package_ranges(100, 600).collect(); + assert_eq!(ranges, vec![(100, 256), (256, 512), (512, 600)]); + } + + #[test] + fn package_ranges_all_in_one_partial() { + // Both endpoints inside the same package boundary. + let ranges: Vec<_> = package_ranges(100, 200).collect(); + assert_eq!(ranges, vec![(100, 200)]); + } + + #[test] + fn package_ranges_no_overflow_at_u64_max() { + // Regression: `(start / 256 + 1) * 256` would overflow u64 when + // `start / 256 == u64::MAX / 256`. Reachable from wire input + // because `read_from` accepts any 8-byte upload_end. + let ranges: Vec<_> = package_ranges(u64::MAX - 1, u64::MAX).collect(); + assert_eq!(ranges, vec![(u64::MAX - 1, u64::MAX)]); + + // And just below the overflow boundary: still produces the right + // single-package range and terminates. + let edge = u64::MAX - (u64::MAX % PACKAGE_ALIGNMENT); + let ranges: Vec<_> = package_ranges(edge, u64::MAX).collect(); + assert_eq!(ranges, vec![(edge, u64::MAX)]); + } + + #[test] + fn entry_package_roundtrip() { + let pkg = EntryPackage { + entries: vec![b"hello".to_vec(), b"world".to_vec(), Vec::new()], + proof: vec![Hash([0x11; HASH_SIZE]), Hash([0x22; HASH_SIZE])], + }; + let mut buf = Vec::new(); + pkg.write_to(&mut buf).unwrap(); + let parsed = EntryPackage::read_from(Cursor::new(&buf), 3).unwrap(); + assert_eq!(parsed, pkg); + } + + #[test] + fn entry_package_rejects_too_many_hashes_on_read() { + let mut buf = Vec::new(); + // Zero entries, num_hashes = 64 (exceeds 63). + buf.push(64u8); + buf.extend_from_slice(&[0u8; 64 * HASH_SIZE]); + let err = EntryPackage::read_from(Cursor::new(&buf), 0).unwrap_err(); + assert!(matches!(err, ParseError::TooManyHashes(64))); + } + + #[test] + fn entry_package_rejects_too_many_hashes_on_write() { + let pkg = EntryPackage { + entries: vec![], + proof: (0..64).map(|_| Hash([0u8; HASH_SIZE])).collect(), + }; + let mut buf = Vec::new(); + let err = pkg.write_to(&mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn entry_package_rejects_oversize_entry_on_write() { + let pkg = EntryPackage { + entries: vec![vec![0u8; usize::from(u16::MAX) + 1]], + proof: vec![], + }; + let mut buf = Vec::new(); + let err = pkg.write_to(&mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + fn sample_mirror_info() -> MirrorInfo { + MirrorInfo { + tree_size: 12_400, + next_entry: 12_345, + ticket: vec![0x01, 0x02, 0x03, 0x04, 0x05], + } + } + + #[test] + fn mirror_info_roundtrip() { + let info = sample_mirror_info(); + let body = info.to_body(); + let parsed = MirrorInfo::parse(&body).unwrap(); + assert_eq!(parsed, info); + } + + #[test] + fn mirror_info_pin_exact_body() { + // Pin the exact wire bytes for a known input. Base64("\x01\x02\x03\x04\x05") = "AQIDBAU=". + let info = sample_mirror_info(); + assert_eq!(info.to_body(), b"12400\n12345\nAQIDBAU=\n"); + } + + #[test] + fn mirror_info_roundtrip_empty_ticket() { + let info = MirrorInfo { + tree_size: 0, + next_entry: 0, + ticket: Vec::new(), + }; + assert_eq!(info.to_body(), b"0\n0\n\n"); + assert_eq!(MirrorInfo::parse(b"0\n0\n\n").unwrap(), info); + } + + #[test] + fn mirror_info_rejects_missing_newline_terminators() { + // Missing trailing newline on the ticket line. + let body = b"100\n50\nAQID"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!(err, ParseError::MalformedMirrorInfo(_))); + } + + #[test] + fn mirror_info_rejects_too_many_lines() { + let body = b"100\n50\nAQID\nextra\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!(err, ParseError::MalformedMirrorInfo(_))); + } + + #[test] + fn mirror_info_rejects_leading_zero() { + let body = b"0100\n50\nAQID\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!( + err, + ParseError::InvalidDecimal { + field: "tree_size", + .. + } + )); + } + + #[test] + fn mirror_info_rejects_leading_plus() { + let body = b"+100\n50\nAQID\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!( + err, + ParseError::InvalidDecimal { + field: "tree_size", + .. + } + )); + } + + #[test] + fn mirror_info_rejects_negative() { + let body = b"-100\n50\nAQID\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!( + err, + ParseError::InvalidDecimal { + field: "tree_size", + .. + } + )); + } + + #[test] + fn mirror_info_rejects_bad_base64_ticket() { + let body = b"100\n50\n!!!notbase64!!!\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!(err, ParseError::InvalidTicketBase64)); + } + + #[test] + fn mirror_info_accepts_zero_literal() { + let body = b"0\n0\nAQID\n"; + let info = MirrorInfo::parse(body).unwrap(); + assert_eq!(info.tree_size, 0); + assert_eq!(info.next_entry, 0); + } +}