From 99e2b2064f912dd5428b41d89014e24b71dc70da Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Thu, 23 Jul 2026 12:58:30 -0400 Subject: [PATCH 1/4] mirror_worker: add the optional sign-subtree endpoint Implement the tlog-witness sign-subtree cosigning path: verify the requested subtree is consistent with a reference checkpoint this mirror has cosigned, then countersign the subtree with the concrete SubtreeV1CheckpointSigner (the algorithm-agnostic CheckpointSigner trait object cannot reach sign_subtree). Give MirrorSigner an actual signing capability for this: it now holds a boxed SubtreeV1CheckpointSigner built at load, since the foundation only stored the public key (the mirror never cosigned before). Later cosigning endpoints (add-entries) reuse this. Adds the /sign-subtree route, a request-body size cap, the UnprocessableEntity (422) and ReferenceCheckpointNotCosignedByThisMirror errors. --- crates/mirror_worker/src/frontend_worker.rs | 148 +++++++++++++++++++- crates/mirror_worker/src/lib.rs | 32 ++++- 2 files changed, 171 insertions(+), 9 deletions(-) diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index a6fb6f77..05e52c00 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -35,9 +35,13 @@ use axum::{ }; use serde::Serialize; use serde_with::{base64::Base64 as Base64As, serde_as}; -use signed_note::NoteError; -use tlog_checkpoint::CheckpointText; -use tlog_witness::{AddCheckpointRequest, CONTENT_TYPE_TLOG_SIZE, parse_add_checkpoint_request}; +use signed_note::{NoteError, NoteVerifier, VerifierList}; +use tlog_checkpoint::{CheckpointSigner as _, CheckpointText}; +use tlog_core::{Subtree, verify_subtree_consistency_proof}; +use tlog_witness::{ + AddCheckpointRequest, CONTENT_TYPE_TLOG_SIZE, SignSubtreeRequest, parse_add_checkpoint_request, + parse_sign_subtree_request, serialize_sign_subtree_response, +}; use tower_service::Service as _; #[allow(clippy::wildcard_imports)] use worker::*; @@ -75,6 +79,10 @@ async fn fetch( "/add-checkpoint", post(add_checkpoint).layer(DefaultBodyLimit::max(MAX_ADD_CHECKPOINT_BODY_SIZE)), ) + .route( + "/sign-subtree", + post(sign_subtree).layer(DefaultBodyLimit::max(MAX_SIGN_SUBTREE_BODY_SIZE)), + ) .route("/metadata", get(metadata)) .route("/", get(root)) .with_state(env) @@ -101,8 +109,10 @@ async fn root() -> impl IntoResponse { enum AppError { InternalServerError(String), BadRequest(String), + UnprocessableEntity(String), UnknownLogOrigin, NoValidSignatures, + ReferenceCheckpointNotCosignedByThisMirror, } /// Result type for the mirror's axum handlers. @@ -124,6 +134,11 @@ impl IntoResponse for AppError { AppError::BadRequest(e) => { (StatusCode::BAD_REQUEST, format!("Bad request: {e}")).into_response() } + AppError::UnprocessableEntity(e) => ( + StatusCode::UNPROCESSABLE_ENTITY, + format!("Unprocessable Entity: {e}"), + ) + .into_response(), AppError::UnknownLogOrigin => { (StatusCode::NOT_FOUND, "Unknown log origin").into_response() } @@ -132,6 +147,11 @@ impl IntoResponse for AppError { "No valid signatures from trusted log keys", ) .into_response(), + AppError::ReferenceCheckpointNotCosignedByThisMirror => ( + StatusCode::FORBIDDEN, + "Reference checkpoint not cosigned by this mirror", + ) + .into_response(), } } } @@ -314,10 +334,132 @@ async fn add_checkpoint( Ok(StatusCode::OK.into_response()) } +/// `POST /sign-subtree` handler. +/// +/// OPTIONAL endpoint per [c2sp.org/tlog-witness#sign-subtree][spec], which +/// the mirror inherits (the same cosigner emitted by `add-entries` signs +/// the subtree). The mirror's cosigner is always ML-DSA-44 / `subtree/v1`, +/// so this endpoint is always available. +/// +/// Verification of the reference checkpoint is stateless: the submitted +/// checkpoint MUST carry one of the mirror's own past `subtree/v1` +/// cosignatures (the whole-tree cosignature it emits on a successful +/// `add-entries`). This is safe for the same reason as in the witness: the +/// mirror only cosigns a checkpoint after fully ingesting and verifying +/// every entry up to that size, so a checkpoint bearing the mirror's +/// cosignature proves the mirror holds that tree. `/sign-subtree` therefore +/// inherits the trust window of `/add-entries`. +/// +/// [spec]: https://c2sp.org/tlog-witness#sign-subtree +#[allow(clippy::too_many_lines)] +#[worker::send] +async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult { + let subtree_signer = load_mirror_signer(&env)?.as_subtree_signer(); + + let SignSubtreeRequest { + subtree_start, + subtree_end, + subtree_hash, + subtree_cosignatures: _, + consistency_proof, + checkpoint, + } = match parse_sign_subtree_request(&body) { + Ok(r) => r, + Err(e) => { + log::warn!("sign-subtree: malformed request: {e}"); + return Err(AppError::BadRequest(e.to_string())); + } + }; + + // Parse the reference checkpoint and bound-check the subtree. + // `Subtree::new` enforces `start < end` and the power-of-two + // alignment; `subtree_end <= size` is checked explicitly. Empty + // subtrees (`start == end`) are rejected for now; MTC draft-06 will + // permit them, at which point the mirror should cosign them too. + let cp_text = match CheckpointText::from_bytes(checkpoint.text()) { + Ok(t) => t, + Err(e) => { + log::warn!("sign-subtree: malformed checkpoint text: {e:?}"); + return Err(AppError::BadRequest(format!("{e:?}"))); + } + }; + if subtree_end > cp_text.size() { + return Err(AppError::BadRequest(format!( + "subtree end {subtree_end} > checkpoint size {}", + cp_text.size() + ))); + } + let subtree = match Subtree::new(subtree_start, subtree_end) { + Ok(s) => s, + Err(e) => return Err(AppError::BadRequest(format!("invalid subtree: {e:?}"))), + }; + + // Look up the log by its origin. Subtree DoS-protection cosignatures + // in the request are ignored: this implementation applies no + // pre-screening policy, as the spec leaves their use to the operator. + let origin = cp_text.origin(); + if log_verifiers(origin).is_none() { + return Err(AppError::UnknownLogOrigin); + } + + // Stateless verification: the checkpoint MUST carry one of this + // mirror's own past `subtree/v1` cosignatures. The verifier + // reconstructs the cosigned message from the checkpoint's + // origin/size/hash with start = 0, end = size and rejects anything + // else. + let mirror_verifier: Box = subtree_signer.verifier(); + if let Err(e) = checkpoint.verify(&VerifierList::new(vec![mirror_verifier])) { + match e { + NoteError::UnverifiedNote | NoteError::InvalidSignature { .. } => { + log::info!("sign-subtree: reference checkpoint not cosigned by this mirror: {e:?}"); + return Err(AppError::ReferenceCheckpointNotCosignedByThisMirror); + } + _ => { + log::warn!("sign-subtree: checkpoint verify failed: {e:?}"); + return Err(AppError::BadRequest(e.to_string())); + } + } + } + + // Verify the subtree consistency proof against the reference + // checkpoint root. + if verify_subtree_consistency_proof( + &consistency_proof, + cp_text.size(), + *cp_text.hash(), + &subtree, + subtree_hash, + ) + .is_err() + { + return Err(AppError::UnprocessableEntity( + "subtree consistency proof failed".to_owned(), + )); + } + + // Sign the subtree. Per the spec the timestamp on a subtree + // cosignature MUST be zero; we use zero uniformly. + let note_sig = subtree_signer.sign_subtree(0, origin, &subtree, &subtree_hash); + Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + serialize_sign_subtree_response(std::slice::from_ref(¬e_sig)), + ) + .into_response()) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- +/// Maximum `sign-subtree` request body, enforced by the route's +/// [`DefaultBodyLimit`] layer. A well-formed request is a `subtree` range +/// line, a base64 hash line, up to 8 subtree cosignature lines (~3.3 KiB +/// each for ML-DSA-44), up to 63 base64 consistency-proof hash lines, and +/// a reference checkpoint of up to `signed_note::MAX_NOTE_SIZE` (1 MiB); +/// 1 MiB plus 64 KiB of headroom covers that. +const MAX_SIGN_SUBTREE_BODY_SIZE: usize = 1_024 * 1_024 + 64 * 1_024; + /// Maximum `add-checkpoint` request body, enforced by the route's /// [`DefaultBodyLimit`] layer. A well-formed request is an `old ` /// line, up to 63 base64 hash lines, and a checkpoint note of up to diff --git a/crates/mirror_worker/src/lib.rs b/crates/mirror_worker/src/lib.rs index a839e009..14d84acb 100644 --- a/crates/mirror_worker/src/lib.rs +++ b/crates/mirror_worker/src/lib.rs @@ -5,8 +5,9 @@ //! Cloudflare Workers, specialized for MTC issuance logs. //! //! This worker handles the [`add-checkpoint`][add-cp] submission endpoint, -//! which updates the pending checkpoint for a log origin, and publishes -//! the mirror's identity and per-log configuration at `/metadata`. +//! which updates the pending checkpoint for a log origin, and the OPTIONAL +//! [`sign-subtree`][signsub] endpoint, and publishes the mirror's identity +//! and per-log configuration at `/metadata`. //! //! Per-origin persistent state lives in a `MirrorState` Durable Object, //! one per log origin. Its single-threaded execution model provides the @@ -15,6 +16,7 @@ //! //! [mirror]: https://c2sp.org/tlog-mirror //! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint +//! [signsub]: https://c2sp.org/tlog-witness#sign-subtree use config::AppConfig; use ml_dsa::pkcs8::{DecodePrivateKey as _, EncodePublicKey as _}; @@ -23,7 +25,7 @@ use pkcs8::{PrivateKeyInfoRef, SecretDocument, der::oid::db::fips204::ID_ML_DSA_ use signed_note::{KeyName, NoteVerifier, VerifierList}; use std::collections::HashMap; use std::sync::{Arc, LazyLock, OnceLock}; -use tlog_cosignature::SubtreeV1NoteVerifier; +use tlog_cosignature::{SubtreeV1CheckpointSigner, SubtreeV1NoteVerifier}; #[allow(clippy::wildcard_imports)] use worker::*; @@ -124,12 +126,15 @@ pub(crate) fn log_verifiers(origin: &str) -> Option { /// /// The mirror is an MTC cosigner, which per [c2sp.org/mtc-tlog][mtc] MUST /// use an ML-DSA-44 key and produce [`subtree/v1`][cosig] messages, so -/// this worker supports only that algorithm. Holds the DER-encoded -/// `SubjectPublicKeyInfo` computed once at load and served by `/metadata`. +/// this worker supports only that algorithm. Holds the signer plus the +/// DER-encoded `SubjectPublicKeyInfo` computed once at load and served by +/// `/metadata`. The signer is boxed because the expanded ML-DSA-44 key is +/// large (~64 KiB). /// /// [mtc]: https://c2sp.org/mtc-tlog /// [cosig]: https://c2sp.org/tlog-cosignature pub(crate) struct MirrorSigner { + signer: Box, public_key_der: Vec, } @@ -147,6 +152,16 @@ impl MirrorSigner { pub(crate) fn algorithm(&self) -> &'static str { "subtree/v1" } + + /// The concrete [`SubtreeV1CheckpointSigner`], used by `sign-subtree`, + /// which needs [`SubtreeV1CheckpointSigner::sign_subtree`] and the + /// matching verifier, neither reachable through the algorithm-agnostic + /// [`CheckpointSigner`] trait object. + /// + /// [`CheckpointSigner`]: tlog_checkpoint::CheckpointSigner + pub(crate) fn as_subtree_signer(&self) -> &SubtreeV1CheckpointSigner { + &self.signer + } } /// Cached mirror signer, so the PKCS#8 parse happens at most once per @@ -178,6 +193,8 @@ pub(crate) fn load_mirror_signer(env: &Env) -> Result<&'static MirrorSigner> { /// any other algorithm is rejected (the mirror's cosigner must be an MTC /// cosigner, see [`MirrorSigner`]). fn build_mirror_signer(pem: &str) -> Result { + let name = KeyName::new(CONFIG.mirror_name.clone()) + .map_err(|e| Error::from(format!("invalid mirror_name: {e:?}")))?; let (_label, doc) = SecretDocument::from_pem(pem).map_err(|e| Error::from(format!("PEM parse: {e}")))?; let pk_info = PrivateKeyInfoRef::try_from(doc.as_bytes()) @@ -193,7 +210,10 @@ fn build_mirror_signer(pem: &str) -> Result { .to_public_key_der() .map_err(|e| Error::from(format!("ML-DSA-44 SPKI encode: {e}")))? .to_vec(); - Ok(MirrorSigner { public_key_der }) + Ok(MirrorSigner { + signer: Box::new(SubtreeV1CheckpointSigner::new(name, expanded)), + public_key_der, + }) } oid => Err(Error::from(format!( "unsupported MIRROR_SIGNING_KEY algorithm OID {oid}: expected id-ml-dsa-44 \ From 5a8ae2898554a48df6383d16b0c169f4fe72eb49 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Fri, 31 Jul 2026 20:41:46 -0400 Subject: [PATCH 2/4] mirror_worker: list /sign-subtree in the module route header Add the POST /sign-subtree route to the frontend_worker module doc's route list with a [signsub] link, matching lib.rs. Addresses bonk #273 finding 2. --- crates/mirror_worker/src/frontend_worker.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 05e52c00..37bfb9b1 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -11,6 +11,11 @@ //! with one spec-mandated exception: the mirror MUST NOT cosign in //! this process. Successful responses have an empty body and HTTP //! status 200. +//! - `POST /sign-subtree`: [c2sp.org/tlog-witness#sign-subtree][signsub]. +//! Countersigns a subtree of a checkpoint this mirror has previously +//! cosigned. OPTIONAL in the spec but always available here, since the +//! mirror's cosigner is ML-DSA-44 / `subtree/v1`. Success returns the +//! `subtree/v1` cosignature line(s) as `text/plain`. //! - `GET /metadata`: mirror identity, ML-DSA-44 SPKI, //! `mirror_algorithm`, prefixes, and the per-log configuration. //! - `GET /`: root status string. @@ -19,6 +24,7 @@ //! Durable Object; see [`crate::mirror_state_do`] for details. //! //! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint +//! [signsub]: https://c2sp.org/tlog-witness#sign-subtree //! [`MirrorState`]: crate::mirror_state_do use crate::{ From 64698ad06b54ed53ad1cacba644295703126cb5a Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Thu, 6 Aug 2026 09:14:12 -0400 Subject: [PATCH 3/4] mirror_worker: address lbaquerofierro review on PR 273 - Return the Display form of a malformed reference-checkpoint parse error, matching add-checkpoint, so the Rust type name stays out of the 400 body. - Reuse tlog_witness::MAX_REQUEST_BODY_SIZE for the sign-subtree body cap instead of duplicating the constant the parser already enforces. - Log the previously-silent rejections (unknown origin 404, subtree bounds 400s, consistency-proof 422) with the subtree range and checkpoint size, so operators can tell which check failed. - Map the unexpected verify errors (MismatchedVerifier/AmbiguousKey, unreachable over a one-element verifier list) to 500 rather than blaming the client with 400. --- crates/mirror_worker/src/frontend_worker.rs | 39 +++++++++++++-------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 37bfb9b1..f0afefa3 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -45,8 +45,8 @@ use signed_note::{NoteError, NoteVerifier, VerifierList}; use tlog_checkpoint::{CheckpointSigner as _, CheckpointText}; use tlog_core::{Subtree, verify_subtree_consistency_proof}; use tlog_witness::{ - AddCheckpointRequest, CONTENT_TYPE_TLOG_SIZE, SignSubtreeRequest, parse_add_checkpoint_request, - parse_sign_subtree_request, serialize_sign_subtree_response, + AddCheckpointRequest, CONTENT_TYPE_TLOG_SIZE, MAX_REQUEST_BODY_SIZE, SignSubtreeRequest, + parse_add_checkpoint_request, parse_sign_subtree_request, serialize_sign_subtree_response, }; use tower_service::Service as _; #[allow(clippy::wildcard_imports)] @@ -87,7 +87,7 @@ async fn fetch( ) .route( "/sign-subtree", - post(sign_subtree).layer(DefaultBodyLimit::max(MAX_SIGN_SUBTREE_BODY_SIZE)), + post(sign_subtree).layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_SIZE)), ) .route("/metadata", get(metadata)) .route("/", get(root)) @@ -386,10 +386,14 @@ async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult t, Err(e) => { log::warn!("sign-subtree: malformed checkpoint text: {e:?}"); - return Err(AppError::BadRequest(format!("{e:?}"))); + return Err(AppError::BadRequest(e.to_string())); } }; if subtree_end > cp_text.size() { + log::info!( + "sign-subtree: subtree end {subtree_end} exceeds checkpoint size {}", + cp_text.size() + ); return Err(AppError::BadRequest(format!( "subtree end {subtree_end} > checkpoint size {}", cp_text.size() @@ -397,7 +401,10 @@ async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult s, - Err(e) => return Err(AppError::BadRequest(format!("invalid subtree: {e:?}"))), + Err(e) => { + log::info!("sign-subtree: invalid subtree [{subtree_start}, {subtree_end}): {e:?}"); + return Err(AppError::BadRequest(format!("invalid subtree: {e:?}"))); + } }; // Look up the log by its origin. Subtree DoS-protection cosignatures @@ -405,6 +412,7 @@ async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult, body: Bytes) -> ApiResult { - log::warn!("sign-subtree: checkpoint verify failed: {e:?}"); - return Err(AppError::BadRequest(e.to_string())); + log::error!("sign-subtree: checkpoint verify failed unexpectedly: {e:?}"); + return Err(AppError::InternalServerError(e.to_string())); } } } @@ -438,6 +450,11 @@ async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult, body: Bytes) -> ApiResult` /// line, up to 63 base64 hash lines, and a checkpoint note of up to From e6312bb54db65a2f43c468b929f0c7252c7bd568 Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Fri, 21 Aug 2026 11:43:47 -0400 Subject: [PATCH 4/4] mirror_worker: clarify sign-subtree timestamp comment The zero-timestamp rule only binds non-zero-start subtrees; for a whole-tree (start = 0) subtree the spec permits a non-zero timestamp. We still sign with zero uniformly. Comment-only, matching the witness_worker wording. --- crates/mirror_worker/src/frontend_worker.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index f0afefa3..b1fe2cfa 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -460,8 +460,8 @@ async fn sign_subtree(State(env): State, body: Bytes) -> ApiResult