Skip to content

Commit dbdbd4d

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 dbdbd4d

File tree

17 files changed

+1625
-1
lines changed

17 files changed

+1625
-1
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.dev.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"submission_url": "http://localhost:8787/logs/dev2/",
1616
"location_hint": "enam",
1717
"max_certificate_lifetime_secs": 100,
18-
"landmark_interval_secs": 10
18+
"landmark_interval_secs": 10,
19+
"enable_sct_validation": true
1920
}
2021
}
2122
}

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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
/// KV namespace binding for CT logs.
10+
/// Must be configured in wrangler.jsonc.
11+
pub(crate) const CT_LOGS_NAMESPACE: &str = "ct_logs";
12+
13+
/// Key for the CT log list in KV.
14+
pub(crate) const CT_LOGS_FILENAME: &str = "ct_log_list.json";
15+
16+
/// URL for Chrome's CT log list.
17+
const CT_LOG_LIST_URL: &str = "https://www.gstatic.com/ct/log_list/v3/log_list.json";
18+
19+
/// Fetches and stores the CT log list in KV.
20+
pub(crate) async fn update_ct_logs(kv: &KvStore) -> Result<()> {
21+
log::info!("Fetching CT log list from Google");
22+
23+
let headers = Headers::new();
24+
headers.set("User-Agent", "Cloudflare MTCA (ct-logs@cloudflare.com)")?;
25+
26+
let req = Request::new_with_init(
27+
CT_LOG_LIST_URL,
28+
&RequestInit {
29+
method: Method::Get,
30+
headers,
31+
..Default::default()
32+
},
33+
)?;
34+
35+
let resp_bytes = Fetch::Request(req).send().await?.bytes().await?;
36+
37+
// Validate that we can parse the log list before storing
38+
let log_list = CtLogList::from_chrome_log_list(&resp_bytes)
39+
.map_err(|e| format!("Failed to parse CT log list: {e}"))?;
40+
41+
log::info!(
42+
"Parsed {} CT logs from log list (timestamp: {})",
43+
log_list.logs.len(),
44+
log_list.log_list_timestamp
45+
);
46+
47+
// Store the raw JSON bytes (we'll parse them again on load for freshness check)
48+
kv.put_bytes(CT_LOGS_FILENAME, &resp_bytes)?.execute().await?;
49+
50+
log::info!("Successfully stored CT log list in KV");
51+
52+
Ok(())
53+
}
54+
55+
/// Loads CT log list from KV, fetching if not present.
56+
pub(crate) async fn load_ct_logs(env: &Env) -> Result<CtLogList> {
57+
let kv = env.kv(CT_LOGS_NAMESPACE)?;
58+
59+
let json_bytes = if let Some(bytes) = kv.get(CT_LOGS_FILENAME).bytes().await? {
60+
bytes
61+
} else {
62+
// Log list doesn't exist, fetch it now
63+
log::info!("CT log list not found in KV, fetching...");
64+
update_ct_logs(&kv).await?;
65+
kv.get(CT_LOGS_FILENAME)
66+
.bytes()
67+
.await?
68+
.ok_or("CT log list not found after update")?
69+
};
70+
71+
CtLogList::from_chrome_log_list(&json_bytes)
72+
.map_err(|e| format!("Failed to parse CT log list from KV: {e}").into())
73+
}

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[0];
331+
let issuer_der = req.chain.get(1).ok_or("Chain must have at least 2 certificates for SCT validation")?;
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

0 commit comments

Comments
 (0)