diff --git a/Cargo.lock b/Cargo.lock index 42a243e..e58fe87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1719,12 +1719,16 @@ dependencies = [ "jsonschema", "log", "mirror_worker_config", + "ml-dsa", + "pkcs8", "serde", "serde_json", "serde_with", "signed_note", "tlog_checkpoint", "tlog_core", + "tlog_cosignature", + "tlog_mirror", "tlog_witness", "worker", ] diff --git a/crates/integration_tests/tests/tlog_mirror.rs b/crates/integration_tests/tests/tlog_mirror.rs index 9e22f9b..ef5788d 100644 --- a/crates/integration_tests/tests/tlog_mirror.rs +++ b/crates/integration_tests/tests/tlog_mirror.rs @@ -25,7 +25,8 @@ //! //! Steps covered (happy-path + basic error codes per the spec): //! -//! 1. `GET /` returns the configured mirror identity. +//! 1. `GET /metadata` returns the mirror's identity, ML-DSA-44 SPKI, +//! `mirror_algorithm = "subtree/v1"`, and the configured log list. //! 2. First `add-checkpoint` (`old=0`, no proof) → 200 with empty body. //! 3. Second `add-checkpoint` with consistency proof → 200, advancing //! pending state. @@ -55,6 +56,8 @@ use ed25519_dalek::{pkcs8::DecodePrivateKey, SigningKey as Ed25519SigningKey}; use rand::rng; +use serde::Deserialize; +use serde_with::{base64::Base64, serde_as}; use signed_note::{KeyName, Note, NoteSignature}; use std::time::Duration; use tlog_checkpoint::{CheckpointSigner, Ed25519CheckpointSigner, TreeWithTimestamp}; @@ -212,21 +215,46 @@ async fn post_add_checkpoint(body: &[u8]) -> AddCheckpointResult { } } -async fn fetch_root() -> String { +#[serde_as] +#[derive(Deserialize, Debug)] +struct MetadataResponse { + mirror_name: String, + #[allow(dead_code)] + description: Option, + #[serde_as(as = "Base64")] + mirror_public_key: Vec, + mirror_algorithm: String, + submission_prefix: String, + #[allow(dead_code)] + monitoring_prefix: String, + logs: Vec, +} + +#[serde_as] +#[derive(Deserialize, Debug)] +struct LogMetadata { + #[allow(dead_code)] + description: Option, + origin: String, + #[serde_as(as = "Vec")] + log_public_keys: Vec>, +} + +async fn fetch_metadata() -> MetadataResponse { let client = reqwest::Client::new(); let resp = client - .get(format!("{}/", base_url())) + .get(format!("{}/metadata", base_url())) .send() .await - .expect("root request"); - assert_eq!(resp.status().as_u16(), 200, "root status"); - resp.text().await.expect("root text") + .expect("metadata request"); + assert_eq!(resp.status().as_u16(), 200, "metadata status"); + resp.json().await.expect("metadata json") } async fn wait_for_mirror() { for _ in 0..30 { if reqwest::Client::new() - .get(format!("{}/", base_url())) + .get(format!("{}/metadata", base_url())) .send() .await .is_ok() @@ -250,16 +278,21 @@ async fn wait_for_mirror() { 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:?}", + // ----------------------- (1) GET /metadata ----------------------- + let meta = fetch_metadata().await; + assert_eq!(meta.mirror_name, "dev.mirror.example"); + assert!(!meta.mirror_public_key.is_empty()); + assert_eq!( + meta.mirror_algorithm, "subtree/v1", + "dev mirror loads ML-DSA-44 from .dev.vars; algorithm must surface as subtree/v1", ); + assert!(meta.submission_prefix.starts_with("http")); + let log_meta = meta + .logs + .iter() + .find(|l| l.origin == LOG_ORIGIN) + .unwrap_or_else(|| panic!("metadata does not list the {LOG_ORIGIN} origin")); + assert_eq!(log_meta.log_public_keys.len(), 1); let signer = log_signer(); let mut log = ToyLog::new(); diff --git a/crates/mirror_worker/.dev.vars b/crates/mirror_worker/.dev.vars new file mode 100644 index 0000000..2150b9d --- /dev/null +++ b/crates/mirror_worker/.dev.vars @@ -0,0 +1,2 @@ +MIRROR_SIGNING_KEY="-----BEGIN PRIVATE KEY-----\nMDQCAQAwCwYJYIZIAWUDBAMRBCKAIEJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJC\nQkJCQkJC\n-----END PRIVATE KEY-----\n" +MIRROR_TICKET_KEY="Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc=" diff --git a/crates/mirror_worker/Cargo.toml b/crates/mirror_worker/Cargo.toml index f30a7ef..821d33b 100644 --- a/crates/mirror_worker/Cargo.toml +++ b/crates/mirror_worker/Cargo.toml @@ -27,10 +27,8 @@ config = { path = "./config", package = "mirror_worker_config" } jsonschema.workspace = true serde_json.workspace = true -[dev-dependencies] -base64.workspace = true - [dependencies] +base64.workspace = true config = { path = "./config", package = "mirror_worker_config" } console_error_panic_hook.workspace = true console_log.workspace = true @@ -38,12 +36,16 @@ ed25519-dalek.workspace = true getrandom.workspace = true hex.workspace = true log.workspace = true +ml-dsa.workspace = true +pkcs8.workspace = true serde.workspace = true serde_json.workspace = true serde_with.workspace = true signed_note.workspace = true tlog_checkpoint.workspace = true tlog_core.workspace = true +tlog_cosignature.workspace = true +tlog_mirror.workspace = true tlog_witness.workspace = true worker.workspace = true diff --git a/crates/mirror_worker/src/frontend_worker.rs b/crates/mirror_worker/src/frontend_worker.rs index 9c8d010..517e32b 100644 --- a/crates/mirror_worker/src/frontend_worker.rs +++ b/crates/mirror_worker/src/frontend_worker.rs @@ -29,6 +29,8 @@ //! [`MirrorState`]: crate::mirror_state_do //! [tiles]: https://c2sp.org/tlog-tiles +use serde::Serialize; +use serde_with::{base64::Base64 as Base64As, serde_as}; use signed_note::NoteError; use tlog_checkpoint::CheckpointText; use tlog_witness::{parse_add_checkpoint_request, AddCheckpointRequest, CONTENT_TYPE_TLOG_SIZE}; @@ -36,7 +38,7 @@ use tlog_witness::{parse_add_checkpoint_request, AddCheckpointRequest, CONTENT_T use worker::*; use crate::{ - log_verifiers, + load_mirror_public_key_der, load_mirror_signer, log_verifiers, mirror_state_do::{state_stub, PendingCheckpoint, UpdatePendingRequest}, CONFIG, }; @@ -62,6 +64,7 @@ async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { .post_async("/add-checkpoint", |req, ctx| async move { add_checkpoint(req, ctx.env).await }) + .get("/metadata", |_req, ctx| metadata(&ctx.env)) .get("/", |_req, _ctx| { Response::ok(format!( "{} — c2sp.org/tlog-mirror mirror\n", @@ -72,6 +75,86 @@ async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { .await } +/// Response body for the `/metadata` endpoint. +/// +/// Publishes the mirror's identity and the per-log configuration so +/// clients can learn what logs this mirror mirrors and what URL +/// prefixes to use. Symmetric with the witness's `/metadata` shape, +/// with a `mirror_algorithm` field added so clients know whether to +/// expect `cosignature/v1` or `subtree/v1` cosignatures. +#[serde_as] +#[derive(Serialize)] +struct MetadataResponse<'a> { + mirror_name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, + /// DER-encoded `SubjectPublicKeyInfo` for the mirror's verifying + /// key. Algorithm is identified by `mirror_algorithm`. + #[serde_as(as = "Base64As")] + mirror_public_key: &'a [u8], + /// `"cosignature/v1"` (Ed25519) or `"subtree/v1"` (ML-DSA-44). + /// See [c2sp.org/tlog-cosignature](https://c2sp.org/tlog-cosignature). + mirror_algorithm: &'a str, + submission_prefix: &'a str, + monitoring_prefix: &'a str, + logs: Vec>, +} + +#[serde_as] +#[derive(Serialize)] +struct LogMetadata<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + description: Option<&'a str>, + origin: &'a str, + /// DER-encoded `SubjectPublicKeyInfo` blobs for the log's trusted + /// keys. + #[serde_as(as = "Vec")] + log_public_keys: Vec<&'a [u8]>, +} + +/// Build the per-log metadata entries from the worker's `CONFIG` +/// `logs` map, sorted by origin so the result has a deterministic +/// order regardless of `HashMap` iteration order. The deterministic +/// order matters for diff-based monitoring of `/metadata` and for any +/// client that hashes the response (cache keys, etc.). +/// +/// Split out from [`metadata`] so the sort can be unit-tested without +/// a `worker::Env`. +fn metadata_logs( + logs: &std::collections::HashMap, +) -> Vec> { + let mut out: Vec = logs + .iter() + .map(|(origin, p)| LogMetadata { + description: p.description.as_deref(), + origin, + log_public_keys: p.log_public_keys.iter().map(Vec::as_slice).collect(), + }) + .collect(); + out.sort_by_key(|l| l.origin); + out +} + +/// `GET /metadata` handler. +fn metadata(env: &Env) -> Result { + let mirror_public_key = load_mirror_public_key_der(env)?; + let mirror_algorithm = load_mirror_signer(env)?.algorithm(); + let logs = metadata_logs(&CONFIG.logs); + let body = MetadataResponse { + mirror_name: &CONFIG.mirror_name, + description: CONFIG.description.as_deref(), + mirror_public_key, + mirror_algorithm, + submission_prefix: &CONFIG.submission_prefix, + monitoring_prefix: CONFIG + .monitoring_prefix + .as_deref() + .unwrap_or(&CONFIG.submission_prefix), + logs, + }; + Response::from_json(&body) +} + /// Handle `POST /add-checkpoint`. /// /// Per [spec][add-cp]: "The request is handled identically to that of a @@ -296,3 +379,112 @@ fn tlog_size_conflict(current: &PendingCheckpoint) -> Result { .with_status(409) .with_headers(headers)) } + +#[cfg(test)] +mod tests { + use super::{LogMetadata, MetadataResponse}; + + /// `description` is optional in the `/metadata` response. When + /// absent it MUST be omitted from the JSON body (not serialized as + /// `null`) so the wire shape matches what clients expect. + #[test] + fn metadata_description_omitted_when_none() { + let log = LogMetadata { + description: None, + origin: "example.com/log", + log_public_keys: vec![b"spki".as_slice()], + }; + let body = MetadataResponse { + mirror_name: "example.com/mirror", + description: None, + mirror_public_key: b"mirror-spki", + mirror_algorithm: "subtree/v1", + submission_prefix: "https://mirror.example.com/", + monitoring_prefix: "https://mirror.example.com/", + logs: vec![log], + }; + let json = serde_json::to_string(&body).unwrap(); + assert!( + !json.contains("\"description\""), + "description should be omitted when None, got: {json}" + ); + } + + #[test] + fn metadata_description_present_when_some() { + let log = LogMetadata { + description: Some("a log"), + origin: "example.com/log", + log_public_keys: vec![b"spki".as_slice()], + }; + let body = MetadataResponse { + mirror_name: "example.com/mirror", + description: Some("a mirror"), + mirror_public_key: b"mirror-spki", + mirror_algorithm: "subtree/v1", + submission_prefix: "https://mirror.example.com/", + monitoring_prefix: "https://mirror.example.com/", + logs: vec![log], + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"description\":\"a mirror\""), "{json}"); + assert!(json.contains("\"description\":\"a log\""), "{json}"); + } + + /// Pin the `mirror_algorithm` field shape — it's a stable string + /// that clients use to pick a verifier. Must be exactly + /// `cosignature/v1` or `subtree/v1`. + #[test] + fn metadata_includes_mirror_algorithm() { + let body = MetadataResponse { + mirror_name: "example.com/mirror", + description: None, + mirror_public_key: b"k", + mirror_algorithm: "subtree/v1", + submission_prefix: "https://m.example/", + monitoring_prefix: "https://m.example/", + logs: vec![], + }; + let json = serde_json::to_string(&body).unwrap(); + assert!( + json.contains("\"mirror_algorithm\":\"subtree/v1\""), + "missing or misnamed mirror_algorithm: {json}" + ); + } + + /// `metadata_logs` returns entries sorted by origin so the + /// `/metadata` response body is deterministic across worker + /// isolates (regardless of `HashMap` iteration order). + #[test] + fn metadata_logs_sorted_by_origin() { + use std::collections::HashMap; + let mut logs = HashMap::new(); + logs.insert( + "z.example/log".to_owned(), + config::LogParams { + description: None, + log_public_keys: vec![b"z-spki".to_vec()], + }, + ); + logs.insert( + "a.example/log".to_owned(), + config::LogParams { + description: None, + log_public_keys: vec![b"a-spki".to_vec()], + }, + ); + logs.insert( + "m.example/log".to_owned(), + config::LogParams { + description: None, + log_public_keys: vec![b"m-spki".to_vec()], + }, + ); + let out = super::metadata_logs(&logs); + let origins: Vec<&str> = out.iter().map(|l| l.origin).collect(); + assert_eq!( + origins, + vec!["a.example/log", "m.example/log", "z.example/log"] + ); + } +} diff --git a/crates/mirror_worker/src/lib.rs b/crates/mirror_worker/src/lib.rs index 7265cde..fc2f95d 100644 --- a/crates/mirror_worker/src/lib.rs +++ b/crates/mirror_worker/src/lib.rs @@ -23,10 +23,24 @@ //! [tiles]: https://c2sp.org/tlog-tiles //! [`MirrorState`]: mirror_state_do::MirrorState +use base64::Engine as _; use config::AppConfig; +use ed25519_dalek::{ + pkcs8::{DecodePrivateKey as _, EncodePublicKey as _}, + SigningKey as Ed25519SigningKey, +}; +use ml_dsa::MlDsa44; +use pkcs8::{ + der::oid::db::{fips204::ID_ML_DSA_44, rfc8410::ID_ED_25519}, + PrivateKeyInfoRef, SecretDocument, +}; use signed_note::{Ed25519NoteVerifier, KeyName, NoteVerifier, VerifierList}; use std::collections::HashMap; -use std::sync::LazyLock; +use std::sync::{LazyLock, OnceLock}; +use tlog_cosignature::{CosignatureV1CheckpointSigner, SubtreeV1CheckpointSigner}; +use tlog_mirror::TicketMacer; +#[allow(clippy::wildcard_imports)] +use worker::*; mod frontend_worker; mod mirror_state_do; @@ -109,18 +123,310 @@ pub(crate) fn log_verifiers(origin: &str) -> Option { Some(VerifierList::new(verifiers)) } +// --------------------------------------------------------------------------- +// Mirror cosigner key +// --------------------------------------------------------------------------- + +/// The mirror's signing material plus the cosignature algorithm +/// derived from the OID embedded in the loaded PKCS#8 PEM. +/// +/// One variant per supported cosignature format. Both the Ed25519 +/// `cosignature/v1` and the ML-DSA-44 `subtree/v1` variants are valid +/// [tlog-cosignatures][cosig]; the spec does not constrain mirrors to +/// a single algorithm. Operators choose by which key they generate +/// and load — the OID in the PKCS#8 PEM is the source of truth. +/// +/// Each variant carries the signer plus the DER-encoded +/// `SubjectPublicKeyInfo` for the matching verifying key. The SPKI is +/// computed once at construction and reused by `/metadata` so we don't +/// re-encode the verifying key on every request, and so the signer +/// types themselves don't need to expose their internal verifying keys. +/// +/// Both signer fields are boxed so the enum has a small, balanced +/// stack footprint — the expanded ML-DSA-44 key is ~64 KiB and even +/// the Ed25519 path's signer is several hundred bytes, both large +/// enough that indirection is worth the single allocation at load +/// time. +/// +/// [cosig]: https://c2sp.org/tlog-cosignature +pub(crate) enum MirrorSigner { + /// Ed25519 / [`cosignature/v1`][spec]. + /// + /// [spec]: https://c2sp.org/tlog-cosignature + CosignatureV1 { + signer: Box, + public_key_der: Vec, + }, + /// ML-DSA-44 / [`subtree/v1`][spec]. + /// + /// [spec]: https://c2sp.org/tlog-cosignature + SubtreeV1 { + signer: Box, + public_key_der: Vec, + }, +} + +impl MirrorSigner { + /// DER-encoded `SubjectPublicKeyInfo` for the mirror's verifying + /// key, in whatever algorithm this signer was loaded with. + pub(crate) fn public_key_der(&self) -> &[u8] { + match self { + Self::CosignatureV1 { public_key_der, .. } | Self::SubtreeV1 { public_key_der, .. } => { + public_key_der + } + } + } + + /// Stable string identifying the cosignature algorithm. Published + /// in `/metadata` so clients know whether to expect + /// `cosignature/v1` or `subtree/v1` cosignatures. + pub(crate) fn algorithm(&self) -> &'static str { + match self { + Self::CosignatureV1 { .. } => "cosignature/v1", + Self::SubtreeV1 { .. } => "subtree/v1", + } + } + + /// The inner [`CheckpointSigner`] trait object, used by the + /// `add-entries` handler when emitting the mirror cosignature on a + /// successful upload (a future slice). + /// + /// [`CheckpointSigner`]: tlog_checkpoint::CheckpointSigner + #[allow(dead_code)] // Wired up in slice C4 (add-entries handler). + pub(crate) fn as_checkpoint_signer(&self) -> &dyn tlog_checkpoint::CheckpointSigner { + match self { + Self::CosignatureV1 { signer, .. } => &**signer, + Self::SubtreeV1 { signer, .. } => &**signer, + } + } +} + +/// Cached mirror signer, built lazily on first request. +/// +/// Held as a `OnceLock` so the PKCS#8 parse + algorithm +/// dispatch happens at most once per worker instance. Subsequent +/// requests reuse the parsed key. Concurrent cold-start requests will +/// each parse the PEM and (for ML-DSA-44) expand the ~64 KiB +/// `ExpandedSigningKey`, dropping the loser's result; deduplication +/// would need [`OnceLock::get_or_try_init`] (unstable, see +/// `rust-lang/rust#109737`). +static MIRROR_SIGNER: OnceLock = OnceLock::new(); + +/// Load (or return the already-cached) mirror signer. +/// +/// The signing algorithm is derived from the OID in the +/// `MIRROR_SIGNING_KEY` PKCS#8 PEM secret: +/// +/// - `id-Ed25519` → [`MirrorSigner::CosignatureV1`]. +/// - `id-ml-dsa-44` → [`MirrorSigner::SubtreeV1`]. +/// +/// # Errors +/// +/// Returns an error if the `MIRROR_SIGNING_KEY` secret is missing, +/// the PEM is malformed, the OID is neither Ed25519 nor ML-DSA-44, or +/// the configured `mirror_name` is not a valid signed-note key name. +pub(crate) fn load_mirror_signer(env: &Env) -> Result<&'static MirrorSigner> { + if let Some(s) = MIRROR_SIGNER.get() { + return Ok(s); + } + let pem = env.secret("MIRROR_SIGNING_KEY")?.to_string(); + let signer = build_mirror_signer(&pem)?; + Ok(MIRROR_SIGNER.get_or_init(|| signer)) +} + +/// Build a [`MirrorSigner`] from a PKCS#8 PEM string. +/// +/// Split out from [`load_mirror_signer`] so unit tests can exercise +/// the algorithm dispatch without a `worker::Env`. +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()) + .map_err(|e| Error::from(format!("PrivateKeyInfo parse: {e}")))?; + match pk_info.algorithm.oid { + ID_ED_25519 => { + let key = Ed25519SigningKey::from_pkcs8_pem(pem) + .map_err(|e| Error::from(format!("Ed25519 PKCS#8 parse: {e}")))?; + let public_key_der = key + .verifying_key() + .to_public_key_der() + .map_err(|e| Error::from(format!("Ed25519 SPKI encode: {e}")))? + .to_vec(); + Ok(MirrorSigner::CosignatureV1 { + signer: Box::new(CosignatureV1CheckpointSigner::new(name, key)), + public_key_der, + }) + } + ID_ML_DSA_44 => { + // ml-dsa's PKCS#8 stores only the 32-byte seed; the + // `ExpandedSigningKey` `TryFrom` impl + // (used by `from_pkcs8_pem`) expands it on the way in. The + // expanded key never leaves this worker. + let expanded = ml_dsa::ExpandedSigningKey::::from_pkcs8_pem(pem) + .map_err(|e| Error::from(format!("ML-DSA-44 PKCS#8 parse: {e}")))?; + let public_key_der = expanded + .verifying_key() + .to_public_key_der() + .map_err(|e| Error::from(format!("ML-DSA-44 SPKI encode: {e}")))? + .to_vec(); + Ok(MirrorSigner::SubtreeV1 { + signer: Box::new(SubtreeV1CheckpointSigner::new(name, expanded)), + public_key_der, + }) + } + oid => Err(Error::from(format!( + "unsupported MIRROR_SIGNING_KEY algorithm OID {oid}: \ + expected id-Ed25519 ({ID_ED_25519}) or id-ml-dsa-44 ({ID_ML_DSA_44})" + ))), + } +} + +/// Return the DER-encoded `SubjectPublicKeyInfo` for the mirror's own +/// verifying key. Used by the `/metadata` endpoint. +/// +/// # Errors +/// +/// Returns an error if the signing key is not available. +pub(crate) fn load_mirror_public_key_der(env: &Env) -> Result<&'static [u8]> { + Ok(load_mirror_signer(env)?.public_key_der()) +} + +// --------------------------------------------------------------------------- +// Ticket key +// --------------------------------------------------------------------------- + +/// Cached ticket authenticator, built lazily on first request. +/// +/// The mirror's ticket scheme — base64-encoded blobs returned in the +/// `text/x.tlog.mirror-info` 409 response body and round-tripped via +/// the `add-entries` request — is authenticated with HMAC-SHA-256 +/// truncated to 128 bits. See [`tlog_mirror::TicketMacer`] for the +/// construction; this static holds a single instance keyed off the +/// `MIRROR_TICKET_KEY` secret. +#[allow(dead_code)] // Wired up in slice C4 (add-entries handler). +static TICKET_MACER: OnceLock = OnceLock::new(); + +/// Load (or return the already-cached) ticket authenticator. +/// +/// The `MIRROR_TICKET_KEY` secret is **32 raw bytes encoded as +/// standard base64** (RFC 4648 §4, no URL-safe variant, no padding +/// stripping). Operators can generate one with: +/// +/// ```sh +/// head -c 32 /dev/urandom | base64 +/// ``` +/// +/// and load it via `wrangler secret put MIRROR_TICKET_KEY`. +/// +/// # Errors +/// +/// Returns an error if the `MIRROR_TICKET_KEY` secret is missing, is +/// not valid base64, or does not decode to exactly 32 bytes. +#[allow(dead_code)] // Wired up in slice C4 (add-entries handler). +pub(crate) fn load_ticket_macer(env: &Env) -> Result<&'static TicketMacer> { + if let Some(t) = TICKET_MACER.get() { + return Ok(t); + } + let b64 = env.secret("MIRROR_TICKET_KEY")?.to_string(); + let raw = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .map_err(|e| Error::from(format!("MIRROR_TICKET_KEY base64 decode: {e}")))?; + let key: [u8; 32] = raw.try_into().map_err(|v: Vec| { + Error::from(format!( + "MIRROR_TICKET_KEY must decode to exactly 32 bytes; got {}", + v.len() + )) + })?; + Ok(TICKET_MACER.get_or_init(|| TicketMacer::new(&key))) +} + +#[cfg(test)] +mod signer_tests { + //! Unit tests for the OID-dispatching signer loader. + //! + //! `build_mirror_signer` consumes a PKCS#8 PEM and returns a + //! [`MirrorSigner`] whose variant is dictated entirely by the OID + //! in the PEM's `AlgorithmIdentifier`. These tests cover both + //! supported algorithms and the error path for an unsupported OID. + + use super::{build_mirror_signer, MirrorSigner}; + use ed25519_dalek::pkcs8::EncodePrivateKey as _; + + /// Generate a deterministic Ed25519 PEM from a seed byte. + fn ed25519_pem(seed: u8) -> String { + let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]); + sk.to_pkcs8_pem(pkcs8::LineEnding::LF) + .expect("encode PEM") + .to_string() + } + + /// Generate a deterministic ML-DSA-44 PEM from a seed byte. + /// + /// Uses the seed-only PKCS#8 encoding (the same format the + /// `RustCrypto` `ml-dsa` crate emits and that an operator would + /// produce with `openssl genpkey -algorithm ML-DSA-44`) so this + /// exercises the real load path. + fn ml_dsa_44_pem(seed: u8) -> String { + use ml_dsa::KeyGen as _; + use pkcs8::EncodePrivateKey as _; + let sk = ml_dsa::MlDsa44::from_seed(&ml_dsa::B32::from([seed; 32])); + sk.to_pkcs8_pem(pkcs8::LineEnding::LF) + .expect("encode ML-DSA-44 PEM") + .to_string() + } + + #[test] + fn ed25519_pem_dispatches_to_cosignature_v1() { + let signer = build_mirror_signer(&ed25519_pem(1)).expect("build Ed25519 signer"); + assert!(matches!(signer, MirrorSigner::CosignatureV1 { .. })); + assert_eq!(signer.algorithm(), "cosignature/v1"); + assert!( + !signer.public_key_der().is_empty(), + "Ed25519 SPKI must be non-empty", + ); + } + + #[test] + fn ml_dsa_44_pem_dispatches_to_subtree_v1() { + let signer = build_mirror_signer(&ml_dsa_44_pem(2)).expect("build ML-DSA-44 signer"); + assert!(matches!(signer, MirrorSigner::SubtreeV1 { .. })); + assert_eq!(signer.algorithm(), "subtree/v1"); + assert!( + !signer.public_key_der().is_empty(), + "ML-DSA-44 SPKI must be non-empty", + ); + } + + #[test] + fn malformed_pem_is_rejected() { + let Err(err) = build_mirror_signer("not-a-pem") else { + panic!("malformed PEM must not parse") + }; + let msg = err.to_string(); + assert!( + msg.contains("PEM parse") || msg.contains("PrivateKeyInfo"), + "unexpected error: {msg}", + ); + } +} + #[cfg(test)] mod dev_config_tests { - //! Tests that pin invariants between `config.dev.json` and the - //! integration-test fixtures that mirror it. + //! Tests that pin invariants between `config.dev.json` / + //! `.dev.vars` and the integration-test fixtures that mirror them. //! - //! If either of these tests fails, the dev keypair embedded in + //! If any of these tests fails, the dev secrets in + //! `crates/mirror_worker/.dev.vars` or 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/mirror_worker/config.dev.json` have drifted; rotate + //! 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. + //! rotation rolls both worker configs forward. The mirror's own + //! ML-DSA-44 cosigner key in `.dev.vars` is independent and is + //! pinned only to "parses cleanly". use base64::prelude::*; use ed25519_dalek::pkcs8::{DecodePrivateKey as _, EncodePublicKey as _}; @@ -131,6 +437,11 @@ mod dev_config_tests { /// `$DEPLOY_ENV`, which may not be `dev` during `cargo test`. const DEV_CONFIG: &str = include_str!("../config.dev.json"); + /// The raw contents of `.dev.vars`. Read at test time so we can + /// pin that the keys load cleanly via the same code path as + /// production secrets. + const DEV_VARS: &str = include_str!("../.dev.vars"); + /// 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` @@ -140,6 +451,22 @@ mod dev_config_tests { MC4CAQAwBQYDK2VwBCIEIA2VCmSeCNVJTboEACcXvVahZHSHEJDxSl94aej1Q8hQ\n\ -----END PRIVATE KEY-----\n"; + /// Extract a `.dev.vars` value by key. The format is + /// `KEY="value"` per line, with embedded `\n` as literal + /// backslash-n (un-escaped on read by `wrangler dev`). For our + /// pin tests we re-escape `\n` ourselves so the parsed PEM round-trips. + fn dev_var(key: &str) -> String { + let line = DEV_VARS + .lines() + .find(|l| l.starts_with(&format!("{key}="))) + .unwrap_or_else(|| panic!(".dev.vars must define {key}")); + let rhs = line + .strip_prefix(&format!("{key}=")) + .unwrap() + .trim_matches('"'); + rhs.replace("\\n", "\n") + } + #[test] fn dev_config_spki_matches_embedded_pem() { // Extract the first (and only) log's first public key from @@ -162,6 +489,40 @@ mod dev_config_tests { a future integration-test run will 403", ); } + + /// `MIRROR_SIGNING_KEY` in `.dev.vars` parses cleanly through the + /// same `build_mirror_signer` code path that production uses. Pins + /// that an operator-typo in `.dev.vars` is caught at unit-test + /// time, not at the first request to a running `wrangler dev`. + #[test] + fn dev_vars_mirror_signing_key_parses() { + let pem = dev_var("MIRROR_SIGNING_KEY"); + let signer = super::build_mirror_signer(&pem) + .expect("MIRROR_SIGNING_KEY in .dev.vars must parse via build_mirror_signer"); + // The dev key is ML-DSA-44 (seed [0x42; 32]); pin that. + assert!( + matches!(signer, super::MirrorSigner::SubtreeV1 { .. }), + "dev MIRROR_SIGNING_KEY must dispatch to subtree/v1; \ + rotating to a different algorithm is intentional but requires \ + updating this test and the integration tests", + ); + } + + /// `MIRROR_TICKET_KEY` in `.dev.vars` is base64 of exactly 32 + /// bytes, the precondition for [`crate::load_ticket_macer`]. + #[test] + fn dev_vars_ticket_key_is_32_bytes_base64() { + let b64 = dev_var("MIRROR_TICKET_KEY"); + let raw = BASE64_STANDARD + .decode(b64.trim()) + .expect("MIRROR_TICKET_KEY must be valid base64"); + assert_eq!( + raw.len(), + 32, + "MIRROR_TICKET_KEY must decode to exactly 32 bytes; got {}", + raw.len() + ); + } } // 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 index b2d61df..0dcd8ae 100644 --- a/crates/mirror_worker/src/mirror_state_do.rs +++ b/crates/mirror_worker/src/mirror_state_do.rs @@ -15,13 +15,14 @@ //! 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`. +//! - `committed`: the latest *mirror checkpoint* — the state for which +//! the mirror has fully ingested entries and emitted a cosignature. +//! Always at-or-behind `pending`. Advanced by `add-entries` once a +//! round of entry packages catches up to a pending checkpoint; +//! advancement is monotone (the DO refuses to roll back). //! -//! For now, only `pending` is updated. The DO exposes a single internal -//! RPC consumed by the frontend handler in the same worker: +//! The DO exposes three internal RPCs consumed by the frontend handler +//! in the same worker: //! //! - `POST /update-pending` — body is a JSON //! [`UpdatePendingRequest`] carrying the client-claimed `old_size`, @@ -37,9 +38,24 @@ //! `text/x.tlog.size` response. On proof verification failure it //! returns 422. //! -//! Atomicity of the read-verify-compare-write sequence is provided by +//! - `POST /get-state` — read-only snapshot of both `pending` and +//! `committed`. Used by the `add-entries` handler (future slice) to +//! early-reject 409 / 404 cases before reading the streaming +//! request body. +//! +//! - `POST /commit` — body is a JSON [`CommitRequest`] carrying a new +//! `(size, hash, signed_note_bytes)` tuple. The DO atomically +//! advances `committed` to that tuple iff the proposed `size` is +//! `>= committed.size` and `<= pending.size`. If `size < +//! committed.size`, a concurrent `add-entries` already advanced past +//! us; the DO returns 200 with the *current* committed state and +//! does not write (the caller treats this as a no-op success). If +//! `size > pending.size`, the request is malformed (cannot commit +//! beyond pending); 400. +//! +//! Atomicity of the read-verify-compare-write sequences is provided by //! Cloudflare Durable Objects' input/output gates (see the inline -//! commentary in the handler). +//! commentary in the handlers). //! //! [spec]: https://c2sp.org/tlog-mirror //! [add-cp]: https://c2sp.org/tlog-mirror#add-checkpoint @@ -53,6 +69,7 @@ use worker::*; use crate::MIRROR_STATE_BINDING; const PENDING_KEY: &str = "pending"; +const COMMITTED_KEY: &str = "committed"; /// The persisted *pending checkpoint* for a single log origin. /// @@ -80,6 +97,67 @@ pub struct PendingCheckpoint { pub signed_note_bytes: Vec, } +/// The persisted *committed checkpoint* (a.k.a. the *mirror +/// checkpoint*) for a single log origin. +/// +/// This is the state for which the mirror has fully ingested entries +/// and is willing to emit a cosignature on. Always at-or-behind +/// [`PendingCheckpoint`]. Advanced monotonically by `/commit`. +/// +/// We store the full signed-note bytes here too so the mirror can +/// serve a cosigned checkpoint at `//checkpoint` +/// without needing to look up historic pending state. The bytes match +/// what the log signed for this `(size, hash)` — i.e. they are a +/// historic value of [`PendingCheckpoint::signed_note_bytes`] (or the +/// current one, when committed has caught up to pending). +#[serde_as] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct CommittedCheckpoint { + /// Tree size of the latest committed (mirror) checkpoint. Zero if + /// the mirror has not yet committed any entries for this origin. + pub size: u64, + /// Root hash of the latest committed checkpoint. All-zero if + /// `size` is 0. + #[serde(with = "hash_hex")] + pub hash: Hash, + /// The full signed-note bytes for the committed `(size, hash)`, + /// as the log originally signed them. Empty if `size` is 0. The + /// mirror's `//checkpoint` serves + /// these bytes plus the mirror's own cosignature lines. + #[serde_as(as = "Base64As")] + pub signed_note_bytes: Vec, +} + +/// Snapshot of both the pending and committed state, returned by +/// `/get-state`. Used by the `add-entries` handler (future slice) to +/// early-reject 409/404 cases before reading the streaming request +/// body. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct MirrorStateSnapshot { + pub pending: PendingCheckpoint, + pub committed: CommittedCheckpoint, +} + +/// Body of the internal `/commit` RPC. The DO atomically advances +/// `committed` to `(size, hash, signed_note_bytes)` iff `size` is at +/// least the current committed size and at most the current pending +/// size. See the module-level comment for the full state-machine. +#[serde_as] +#[derive(Serialize, Deserialize, Debug)] +pub struct CommitRequest { + /// Proposed new committed tree size. + pub size: u64, + /// Proposed new committed root hash. + #[serde(with = "hash_hex")] + pub hash: Hash, + /// Full signed-note bytes for `(size, hash)`. Persisted alongside + /// size+hash so the mirror can serve them at + /// `//checkpoint` along with its + /// cosignature. + #[serde_as(as = "Base64As")] + pub signed_note_bytes: Vec, +} + /// Body of the internal `/update-pending` RPC. #[serde_as] #[derive(Serialize, Deserialize, Debug)] @@ -119,6 +197,66 @@ impl DurableObject for MirrorState { async fn fetch(&self, mut req: Request) -> Result { let path = req.path(); match (req.method(), path.as_str()) { + (Method::Post, "/get-state") => { + // Read-only snapshot of both pending and committed. + // Atomicity comes for free from the DO input gate: no + // other handler is running concurrently for this DO, + // so the pair is consistent. + let snapshot = self.read_snapshot().await; + Response::from_json(&snapshot) + } + (Method::Post, "/commit") => { + // Atomic mirror-checkpoint advance. Compare-and-swap + // semantics: + // + // * If `body.size < current_committed.size`, a + // concurrent `add-entries` for the same origin + // already advanced past us. The spec is explicit + // that the mirror MUST NOT roll back the mirror + // checkpoint in this case; we treat the call as a + // no-op success and return the *current* committed + // state so the caller knows the mirror is already + // ahead. + // + // * If `body.size > current_pending.size`, the + // caller is trying to commit beyond what the + // mirror has accepted as a pending checkpoint + // (programmer error in the frontend or stale + // pending state); 400. + // + // * Otherwise (committed.size <= body.size <= + // pending.size), advance committed to the + // proposed `(size, hash, signed_note_bytes)`. + // + // The DO input gate serializes commits for this + // origin, so two concurrent `add-entries` calls each + // see a consistent view and the higher one wins. + let body: CommitRequest = req.json().await?; + let snapshot = self.read_snapshot().await; + if body.size > snapshot.pending.size { + return Response::error( + format!( + "commit beyond pending: requested size {} > pending size {}", + body.size, snapshot.pending.size + ), + 400, + ); + } + if body.size < snapshot.committed.size { + // Already ahead; no-op success. + return Response::from_json(&snapshot.committed); + } + let new_committed = CommittedCheckpoint { + size: body.size, + hash: body.hash, + signed_note_bytes: body.signed_note_bytes, + }; + self.state + .storage() + .put(COMMITTED_KEY, &new_committed) + .await?; + Response::from_json(&new_committed) + } (Method::Post, "/update-pending") => { // Atomicity of the read-verify-compare-write sequence // below relies on Cloudflare Durable Objects' input/output @@ -204,6 +342,30 @@ impl DurableObject for MirrorState { } } +impl MirrorState { + /// Read both `pending` and `committed` from DO storage. Missing + /// keys are treated as `Default::default()` (size 0, all-zero + /// hash, empty bytes), representing "this origin has no state + /// yet". + async fn read_snapshot(&self) -> MirrorStateSnapshot { + let pending: PendingCheckpoint = self + .state + .storage() + .get(PENDING_KEY) + .await + .unwrap_or(None) + .unwrap_or_default(); + let committed: CommittedCheckpoint = self + .state + .storage() + .get(COMMITTED_KEY) + .await + .unwrap_or(None) + .unwrap_or_default(); + MirrorStateSnapshot { pending, committed } + } +} + /// 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 { @@ -278,7 +440,10 @@ mod hash_vec_hex { #[cfg(test)] mod tests { - use super::{PendingCheckpoint, UpdatePendingRequest}; + use super::{ + CommitRequest, CommittedCheckpoint, MirrorStateSnapshot, PendingCheckpoint, + UpdatePendingRequest, + }; use tlog_core::{Hash, HASH_SIZE}; /// Pin the on-disk JSON layout of `PendingCheckpoint`. Changing @@ -367,4 +532,83 @@ mod tests { assert_eq!(pc.hash.0, [0u8; HASH_SIZE]); assert!(pc.signed_note_bytes.is_empty()); } + + /// Pin the on-disk JSON layout of `CommittedCheckpoint`. Same + /// migration considerations as `PendingCheckpoint`: deployed + /// mirrors must keep parsing this after a worker upgrade. + #[test] + fn committed_checkpoint_json_format_unchanged() { + let mut bytes = [0u8; HASH_SIZE]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = u8::try_from(i).unwrap(); + } + let cc = CommittedCheckpoint { + size: 42, + hash: Hash(bytes), + signed_note_bytes: b"signed-note-bytes".to_vec(), + }; + let json = serde_json::to_string(&cc).unwrap(); + assert_eq!( + json, + r#"{"size":42,"hash":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","signed_note_bytes":"c2lnbmVkLW5vdGUtYnl0ZXM="}"# + ); + let decoded: CommittedCheckpoint = 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"); + } + + /// The default `CommittedCheckpoint` represents "never committed + /// any entries for this origin". + #[test] + fn committed_checkpoint_default_is_zero() { + let cc = CommittedCheckpoint::default(); + assert_eq!(cc.size, 0); + assert_eq!(cc.hash.0, [0u8; HASH_SIZE]); + assert!(cc.signed_note_bytes.is_empty()); + } + + /// Pin the wire shape of the `/get-state` response. + #[test] + fn mirror_state_snapshot_json_format() { + let snap = MirrorStateSnapshot { + pending: PendingCheckpoint { + size: 5, + hash: Hash([0xaa; HASH_SIZE]), + signed_note_bytes: b"p".to_vec(), + }, + committed: CommittedCheckpoint { + size: 3, + hash: Hash([0xbb; HASH_SIZE]), + signed_note_bytes: b"c".to_vec(), + }, + }; + let json = serde_json::to_string(&snap).unwrap(); + assert!( + json.contains(r#""pending":{"#) && json.contains(r#""committed":{"#), + "snapshot must include both pending and committed: {json}" + ); + let decoded: MirrorStateSnapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.pending.size, 5); + assert_eq!(decoded.committed.size, 3); + } + + /// Pin the wire shape of the `/commit` request body. + #[test] + fn commit_request_json_format_unchanged() { + let req = CommitRequest { + size: 7, + hash: Hash([0xcc; HASH_SIZE]), + signed_note_bytes: b"cm".to_vec(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert_eq!( + json, + r#"{"size":7,"hash":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","signed_note_bytes":"Y20="}"# + ); + let decoded: CommitRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.size, 7); + assert_eq!(decoded.hash.0, [0xcc; HASH_SIZE]); + assert_eq!(decoded.signed_note_bytes, b"cm"); + } }