diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index a6fb6f7..f0afefa 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::{ @@ -35,9 +41,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, 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)] use worker::*; @@ -75,6 +85,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_REQUEST_BODY_SIZE)), + ) .route("/metadata", get(metadata)) .route("/", get(root)) .with_state(env) @@ -101,8 +115,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 +140,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 +153,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,6 +340,137 @@ 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(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() + ))); + } + let subtree = match Subtree::new(subtree_start, subtree_end) { + Ok(s) => s, + Err(e) => { + log::info!("sign-subtree: invalid subtree [{subtree_start}, {subtree_end}): {e:?}"); + return Err(AppError::BadRequest(format!("invalid subtree: {e:?}"))); + } + }; + + // Look up the log by its origin. Subtree DoS-protection cosignatures + // in the request are ignored: this implementation applies no + // pre-screening policy, as the spec leaves their use to the operator. + let origin = cp_text.origin(); + if log_verifiers(origin).is_none() { + log::info!("sign-subtree: unknown log origin {origin:?}"); + return Err(AppError::UnknownLogOrigin); + } + + // Stateless verification: the checkpoint MUST carry one of this + // mirror's own past `subtree/v1` cosignatures. The verifier + // reconstructs the cosigned message from the checkpoint's + // origin/size/hash with start = 0, end = size and rejects anything + // else. + let mirror_verifier: Box = subtree_signer.verifier(); + if let Err(e) = checkpoint.verify(&VerifierList::new(vec![mirror_verifier])) { + match e { + NoteError::UnverifiedNote | NoteError::InvalidSignature { .. } => { + log::info!("sign-subtree: reference checkpoint not cosigned by this mirror: {e:?}"); + return Err(AppError::ReferenceCheckpointNotCosignedByThisMirror); + } + // `MismatchedVerifier`/`AmbiguousKey` mean the verifier list + // we built is malformed, not that the client sent a bad + // request; over a one-element list they are unreachable + // today. Surface them as 500 rather than blaming the client. + _ => { + log::error!("sign-subtree: checkpoint verify failed unexpectedly: {e:?}"); + return Err(AppError::InternalServerError(e.to_string())); + } + } + } + + // Verify the subtree consistency proof against the reference + // checkpoint root. + if verify_subtree_consistency_proof( + &consistency_proof, + cp_text.size(), + *cp_text.hash(), + &subtree, + subtree_hash, + ) + .is_err() + { + log::info!( + "sign-subtree: consistency proof failed for subtree [{subtree_start}, {subtree_end}) \ + against checkpoint size {}", + cp_text.size() + ); + return Err(AppError::UnprocessableEntity( + "subtree consistency proof failed".to_owned(), + )); + } + + // 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 // --------------------------------------------------------------------------- diff --git a/crates/mirror_worker/src/lib.rs b/crates/mirror_worker/src/lib.rs index a839e00..14d84ac 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 \