Skip to content

Commit 6db3196

Browse files
RTG-3686: Add WASM-compatible SCT validation with Chrome CT policy
support - Add `sct_validator` crate for validating Signed Certificate Timestamps (SCTs) embedded in certificates - Implement Chrome's Certificate Transparency policy requirements - Integrate optional SCT validation into the mtc_worker submission flow - Add cron job to fetch and cache Google's CT log list A WASM-compatible SCT validation library that implements Chrome's CT policy: - Certificates ≤ 180 days require 2 SCTs from unique logs - Certificates > 180 days require 3 SCTs from unique logs - Always requires SCTs from 2 different log operators - Handles log states: Qualified, Usable, ReadOnly (rejects Pending/Retired) - Auto-succeeds if log list is stale (> 70 days old) (based on concert: https://gitlab.cfdata.org/cloudflare/crypto/concert) - New `ct_logs_cron.rs`: Fetches Chrome's CT log list and caches in KV - New config option `enable_sct_validation` (default: false) - Frontend validates embedded SCTs before accepting certificates when enabled - Added `ct_logs` KV namespace binding in wrangler.jsonc - Replace unstable `is_multiple_of()` with `% 32 != 0` in generic_log_worker for stable Rust compatibility
1 parent 43b909d commit 6db3196

File tree

16 files changed

+1461
-0
lines changed

16 files changed

+1461
-0
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ async fn main(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
3737
} else {
3838
log::info!("Successfully updated CCADB roots");
3939
}
40+
41+
// Update CT logs for SCT validation
42+
log::info!("Updating CT logs");
43+
let ct_kv = match env.kv(crate::ct_logs_cron::CT_LOGS_NAMESPACE) {
44+
Ok(kv) => kv,
45+
Err(e) => {
46+
log::warn!(
47+
"Failed to get KV namespace '{}': {e}",
48+
crate::ct_logs_cron::CT_LOGS_NAMESPACE
49+
);
50+
return;
51+
}
52+
};
53+
match crate::ct_logs_cron::update_ct_logs(&ct_kv).await {
54+
Ok(_) => log::info!("Successfully updated CT logs"),
55+
Err(e) => log::warn!("Failed to update CT logs: {e}"),
56+
}
4057
}
4158

4259
/// Update CCADB roots at each of the provided keys. Roots are pruned to match
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,41 @@ async fn add_entry(mut req: Request, env: &Env, name: &str) -> Result<Response>
317317
}
318318
};
319319

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+
let leaf_der = req.chain.first().ok_or("Chain is empty")?;
331+
let issuer_der = req.chain.get(1).ok_or("Chain must have at least 2 certificates")?;
332+
333+
let validation_time_secs = now_millis() / 1000;
334+
335+
match validator.validate_embedded_scts(leaf_der, issuer_der, validation_time_secs) {
336+
Ok(SctValidationResult::Valid) => {
337+
log::info!("{name}: SCT validation passed");
338+
}
339+
Ok(SctValidationResult::ValidWithWarnings(warnings)) => {
340+
log::info!("{name}: SCT validation passed with {} warnings", warnings.len());
341+
for warning in &warnings {
342+
log::debug!("{name}: SCT warning: {:?}", warning);
343+
}
344+
}
345+
Ok(SctValidationResult::StaleLogList) => {
346+
log::warn!("{name}: SCT validation skipped (stale log list)");
347+
}
348+
Err(e) => {
349+
log::warn!("{name}: SCT validation failed: {e}");
350+
return Response::error(format!("SCT validation failed: {e}"), 400);
351+
}
352+
}
353+
}
354+
320355
// Retrieve the sequenced entry for this pending log entry by sending a request to the DO to
321356
// sequence the entry.
322357
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/mtc_worker/wrangler.jsonc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@
8585
{
8686
"id": "e4aab0c89ebe492c9c81c444c78a31ff",
8787
"binding": "ccadb_roots"
88+
},
89+
{
90+
// TODO: Create KV namespace for CT logs and add ID here
91+
// Required for SCT validation when enable_sct_validation is true
92+
"id": "PLACEHOLDER_CT_LOGS_KV_ID",
93+
"binding": "ct_logs"
8894
}
8995
],
9096
"r2_buckets": [

0 commit comments

Comments
 (0)