Skip to content

Commit 4c9bc66

Browse files
RTG-3686: Add WASM-compatible SCT validation with Chrome CT policy support
Adds sct_validator crate for validating Signed Certificate Timestamps (SCTs) embedded in X.509 certificates, targeting WASM environments. Key features: - Chrome CT policy compliance (2-3 logs based on cert lifetime, 2 operators) - ECDSA P-256 and RSA signature verification - CT log list parsing from Google's JSON format - Stale log list handling (auto-succeed after 70 days per Chrome policy) Integration: - New cron job fetches CT log list from Google - Frontend validates SCTs when enable_sct_validation=true 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7de093e commit 4c9bc66

File tree

15 files changed

+1464
-26
lines changed

15 files changed

+1464
-26
lines changed

Cargo.lock

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ x509-verify = { version = "0.4.4", features = [
9090
"pem",
9191
] }
9292
x509_util = { path = "crates/x509_util" }
93+
sct_validator = { path = "crates/sct_validator" }
94+
95+
# sct_validator dependencies
96+
rsa = { version = "0.9", default-features = false, features = ["sha2"] }
97+
hashbrown = "0.15"
98+
spki = "0.7"
99+
const-oid = "0.9.6"
93100

94101
[patch.crates-io]
95102
der = { git = "https://github.com/lukevalenta/formats", branch = "relative-oid-tag-v0.7.10" }

crates/mtc_worker/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ worker.workspace = true
6464
x509-cert.workspace = true
6565
x509_util.workspace = true
6666
mtc_api.workspace = true
67+
sct_validator.workspace = true
6768

6869
[lints.rust]
6970
unexpected_cfgs = { level = "warn", check-cfg = [

crates/mtc_worker/config.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@
9999
"minimum": 1,
100100
"default": 60,
101101
"description": "How long to wait in between runs of the partial tile cleaner."
102+
},
103+
"enable_sct_validation": {
104+
"type": "boolean",
105+
"default": false,
106+
"description": "Enable SCT (Signed Certificate Timestamp) validation for bootstrap certificates. When enabled, submitted certificates must have valid embedded SCTs compliant with Chrome's CT policy."
102107
}
103108
},
104109
"required": [

crates/mtc_worker/config/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pub struct LogParams {
3737
pub max_batch_entries: usize,
3838
#[serde(default = "default_u64::<60>")]
3939
pub clean_interval_secs: u64,
40+
/// Enable SCT validation for bootstrap certificates
41+
#[serde(default)]
42+
pub enable_sct_validation: bool,
4043
}
4144

4245
impl LogParams {

crates/mtc_worker/src/ccadb_roots_cron.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,30 @@ pub(crate) const CCADB_ROOTS_FILENAME: &str = "mtc_bootstrap_roots.pem";
2424

2525
#[event(scheduled)]
2626
async fn main(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
27+
// Update CCADB roots
2728
log::info!("Updating CCADB roots");
28-
let kv = match env.kv(CCADB_ROOTS_NAMESPACE) {
29-
Ok(kv) => kv,
30-
Err(e) => {
31-
log::warn!("Failed to get KV namespace '{CCADB_ROOTS_NAMESPACE}': {e}");
32-
return;
29+
match env.kv(CCADB_ROOTS_NAMESPACE) {
30+
Ok(kv) => {
31+
if let Err(e) = update_ccadb_roots(&kv).await {
32+
log::warn!("Failed to update CCADB roots: {e}");
33+
} else {
34+
log::info!("Successfully updated CCADB roots");
35+
}
3336
}
34-
};
35-
if let Err(e) = update_ccadb_roots(&kv).await {
36-
log::warn!("Failed to update CCADB roots: {e}");
37-
} else {
38-
log::info!("Successfully updated CCADB roots");
37+
Err(e) => log::warn!("Failed to get KV namespace '{CCADB_ROOTS_NAMESPACE}': {e}"),
38+
}
39+
40+
// Update CT logs for SCT validation
41+
log::info!("Updating CT logs");
42+
match env.kv(crate::ct_logs_cron::CT_LOGS_NAMESPACE) {
43+
Ok(kv) => match crate::ct_logs_cron::update_ct_logs(&kv).await {
44+
Ok(_) => log::info!("Successfully updated CT logs"),
45+
Err(e) => log::warn!("Failed to update CT logs: {e}"),
46+
},
47+
Err(e) => log::warn!(
48+
"Failed to get KV namespace '{}': {e}",
49+
crate::ct_logs_cron::CT_LOGS_NAMESPACE
50+
),
3951
}
4052
}
4153

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2025 Cloudflare, Inc.
2+
// Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
3+
4+
//! Cron job to fetch CT log list from Google. Same pattern as ccadb_roots_cron.
5+
6+
use sct_validator::CtLogList;
7+
use worker::{kv::KvStore, Env, Fetch, Headers, Method, Request, RequestInit, Result};
8+
9+
pub(crate) const CT_LOGS_NAMESPACE: &str = "ct_logs";
10+
pub(crate) const CT_LOGS_FILENAME: &str = "ct_log_list.json";
11+
const CT_LOG_LIST_URL: &str = "https://www.gstatic.com/ct/log_list/v3/log_list.json";
12+
13+
pub(crate) async fn update_ct_logs(kv: &KvStore) -> Result<()> {
14+
log::info!("Fetching CT log list from Google");
15+
16+
let headers = Headers::new();
17+
headers.set("User-Agent", "Cloudflare MTCA (ct-logs@cloudflare.com)")?;
18+
19+
let req = Request::new_with_init(
20+
CT_LOG_LIST_URL,
21+
&RequestInit {
22+
method: Method::Get,
23+
headers,
24+
..Default::default()
25+
},
26+
)?;
27+
28+
let resp_bytes = Fetch::Request(req).send().await?.bytes().await?;
29+
30+
// Validate before storing
31+
let log_list = CtLogList::from_chrome_log_list(&resp_bytes)
32+
.map_err(|e| format!("Failed to parse CT log list: {e}"))?;
33+
34+
log::info!(
35+
"Parsed {} CT logs (timestamp: {})",
36+
log_list.logs.len(),
37+
log_list.log_list_timestamp
38+
);
39+
40+
kv.put_bytes(CT_LOGS_FILENAME, &resp_bytes)?.execute().await?;
41+
Ok(())
42+
}
43+
44+
pub(crate) async fn load_ct_logs(env: &Env) -> Result<CtLogList> {
45+
let kv = env.kv(CT_LOGS_NAMESPACE)?;
46+
47+
let json_bytes = if let Some(bytes) = kv.get(CT_LOGS_FILENAME).bytes().await? {
48+
bytes
49+
} else {
50+
log::info!("CT log list not found in KV, fetching...");
51+
update_ct_logs(&kv).await?;
52+
kv.get(CT_LOGS_FILENAME)
53+
.bytes()
54+
.await?
55+
.ok_or("CT log list not found after update")?
56+
};
57+
58+
CtLogList::from_chrome_log_list(&json_bytes)
59+
.map_err(|e| format!("Failed to parse CT log list from KV: {e}").into())
60+
}

crates/mtc_worker/src/frontend_worker.rs

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ use generic_log_worker::{
1212
batcher_id_from_lookup_key, deserialize, get_durable_object_stub, init_logging,
1313
load_public_bucket,
1414
log_ops::{prove_subtree_inclusion, read_leaf, ProofError, CHECKPOINT_KEY},
15+
obs::Wshim,
1516
serialize,
1617
util::now_millis,
17-
ObjectBackend, ObjectBucket, ENTRY_ENDPOINT, METRICS_ENDPOINT,
18+
ObjectBackend, ObjectBucket, ENTRY_ENDPOINT,
1819
};
1920
use mtc_api::{
2021
serialize_signatureless_cert, AddEntryRequest, AddEntryResponse, BootstrapMtcLogEntry,
@@ -86,9 +87,10 @@ fn start() {
8687
///
8788
/// Panics if there are issues parsing route parameters, which should never happen.
8889
#[event(fetch, respond_with_errors)]
89-
async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
90+
async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
91+
let wshim = Wshim::from_env(&env);
9092
// Use an outer router as middleware to check that the log name is valid.
91-
Router::new()
93+
let response = Router::new()
9294
.or_else_any_method_async("/logs/:log/*route", |req, ctx| async move {
9395
let name = if let Some(name) = ctx.param("log") {
9496
if CONFIG.logs.contains_key(name) {
@@ -216,18 +218,6 @@ async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
216218
},
217219
})
218220
})
219-
.get_async("/logs/:log/metrics", |_req, ctx| async move {
220-
let name = ctx.data;
221-
let stub = get_durable_object_stub(
222-
&ctx.env,
223-
name,
224-
None,
225-
"SEQUENCER",
226-
CONFIG.logs[name].location_hint.as_deref(),
227-
)?;
228-
stub.fetch_with_str(&format!("http://fake_url.com{METRICS_ENDPOINT}"))
229-
.await
230-
})
231221
.get("/logs/:log/sequencer_id", |_req, ctx| {
232222
// Print out the Durable Object ID of the sequencer to allow
233223
// looking it up in internal Cloudflare dashboards. This
@@ -280,7 +270,11 @@ async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
280270
log::warn!("Internal error: {e}");
281271
Response::error("Internal error", 500)
282272
}
283-
})
273+
});
274+
if let Ok(wshim) = wshim {
275+
ctx.wait_until(async move { wshim.flush(&generic_log_worker::obs::logs::LOGGER).await });
276+
}
277+
response
284278
}
285279

286280
#[allow(clippy::too_many_lines)]
@@ -323,6 +317,42 @@ async fn add_entry(mut req: Request, env: &Env, name: &str) -> Result<Response>
323317
}
324318
};
325319

320+
// SCT validation (if enabled for this log shard)
321+
if params.enable_sct_validation {
322+
use crate::ct_logs_cron::load_ct_logs;
323+
use sct_validator::{SctValidationResult, SctValidator};
324+
325+
// Load the CT log list from KV
326+
let ct_logs = load_ct_logs(env).await?;
327+
let validator = SctValidator::new(ct_logs);
328+
329+
// Get leaf and issuer DER for SCT validation
330+
// TODO: Handle single-cert chains where root directly signs leaf (issuer not in chain)
331+
let leaf_der = req.chain.first().ok_or("Chain is empty")?;
332+
let issuer_der = req.chain.get(1).ok_or("Chain must have at least 2 certificates")?;
333+
334+
let validation_time_secs = now_millis() / 1000;
335+
336+
match validator.validate_embedded_scts(leaf_der, issuer_der, validation_time_secs) {
337+
Ok(SctValidationResult::Valid) => {
338+
log::info!("{name}: SCT validation passed");
339+
}
340+
Ok(SctValidationResult::ValidWithWarnings(warnings)) => {
341+
log::info!("{name}: SCT validation passed with {} warnings", warnings.len());
342+
for warning in &warnings {
343+
log::debug!("{name}: SCT warning: {:?}", warning);
344+
}
345+
}
346+
Ok(SctValidationResult::StaleLogList) => {
347+
log::warn!("{name}: SCT validation skipped (stale log list)");
348+
}
349+
Err(e) => {
350+
log::warn!("{name}: SCT validation failed: {e}");
351+
return Response::error(format!("SCT validation failed: {e}"), 400);
352+
}
353+
}
354+
}
355+
326356
// Retrieve the sequenced entry for this pending log entry by sending a request to the DO to
327357
// sequence the entry.
328358
let lookup_key = pending_entry.lookup_key();

crates/mtc_worker/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use x509_util::CertPool;
2121
mod batcher_do;
2222
mod ccadb_roots_cron;
2323
mod cleaner_do;
24+
mod ct_logs_cron;
2425
mod frontend_worker;
2526
mod sequencer_do;
2627

crates/sct_validator/Cargo.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright (c) 2025 Cloudflare, Inc. All rights reserved.
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
4+
[package]
5+
name = "sct_validator"
6+
version.workspace = true
7+
authors.workspace = true
8+
edition.workspace = true
9+
license.workspace = true
10+
description = "WASM-compatible SCT (Signed Certificate Timestamp) validation for CT compliance"
11+
12+
[dependencies]
13+
base64.workspace = true
14+
chrono.workspace = true
15+
const-oid = "0.9.6"
16+
der.workspace = true
17+
hashbrown = "0.15"
18+
log.workspace = true
19+
p256.workspace = true
20+
rsa = { version = "0.9", default-features = false, features = ["sha2"] }
21+
serde.workspace = true
22+
serde_json.workspace = true
23+
sha2.workspace = true
24+
signature.workspace = true
25+
spki = "0.7"
26+
thiserror.workspace = true
27+
x509-cert = { version = "0.2.5", features = ["sct"] }

0 commit comments

Comments
 (0)