Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 160 additions & 3 deletions crates/mirror_worker/src/frontend_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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::{
Expand All @@ -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::*;
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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()
}
Expand All @@ -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(),
}
}
}
Expand Down Expand Up @@ -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`.
Comment thread
lukevalenta marked this conversation as resolved.
///
/// [spec]: https://c2sp.org/tlog-witness#sign-subtree
#[allow(clippy::too_many_lines)]
#[worker::send]
async fn sign_subtree(State(env): State<Env>, body: Bytes) -> ApiResult<axum::response::Response> {
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<dyn NoteVerifier> = 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.
_ => {
Comment thread
lukevalenta marked this conversation as resolved.
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(
Comment thread
lukevalenta marked this conversation as resolved.
"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(&note_sig)),
)
.into_response())
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
32 changes: 26 additions & 6 deletions crates/mirror_worker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 _};
Expand All @@ -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::*;

Expand Down Expand Up @@ -124,12 +126,15 @@ pub(crate) fn log_verifiers(origin: &str) -> Option<VerifierList> {
///
/// 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<SubtreeV1CheckpointSigner>,
public_key_der: Vec<u8>,
}

Expand All @@ -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
Expand Down Expand Up @@ -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<MirrorSigner> {
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())
Expand All @@ -193,7 +210,10 @@ fn build_mirror_signer(pem: &str) -> Result<MirrorSigner> {
.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 \
Expand Down
Loading