Skip to content
Closed
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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 49 additions & 16 deletions crates/integration_tests/tests/tlog_mirror.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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<String>,
#[serde_as(as = "Base64")]
mirror_public_key: Vec<u8>,
mirror_algorithm: String,
submission_prefix: String,
#[allow(dead_code)]
monitoring_prefix: String,
logs: Vec<LogMetadata>,
}

#[serde_as]
#[derive(Deserialize, Debug)]
struct LogMetadata {
#[allow(dead_code)]
description: Option<String>,
origin: String,
#[serde_as(as = "Vec<Base64>")]
log_public_keys: Vec<Vec<u8>>,
}

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()
Expand All @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions crates/mirror_worker/.dev.vars
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
MIRROR_SIGNING_KEY="-----BEGIN PRIVATE KEY-----\nMDQCAQAwCwYJYIZIAWUDBAMRBCKAIEJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJC\nQkJCQkJC\n-----END PRIVATE KEY-----\n"
MIRROR_TICKET_KEY="Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc="
8 changes: 5 additions & 3 deletions crates/mirror_worker/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,25 @@ 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
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

Expand Down
194 changes: 193 additions & 1 deletion crates/mirror_worker/src/frontend_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@
//! [`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};
#[allow(clippy::wildcard_imports)]
use worker::*;

use crate::{
log_verifiers,
load_mirror_public_key_der, load_mirror_signer, log_verifiers,
mirror_state_do::{state_stub, PendingCheckpoint, UpdatePendingRequest},
CONFIG,
};
Expand All @@ -62,6 +64,7 @@ async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
.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",
Expand All @@ -72,6 +75,86 @@ async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
.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<LogMetadata<'a>>,
}

#[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<Base64As>")]
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<String, config::LogParams>,
) -> Vec<LogMetadata<'_>> {
let mut out: Vec<LogMetadata> = 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<Response> {
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
Expand Down Expand Up @@ -296,3 +379,112 @@ fn tlog_size_conflict(current: &PendingCheckpoint) -> Result<Response> {
.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"]
);
}
}
Loading