Skip to content

Commit de2185d

Browse files
lbaquerofierroclaude
authored andcommitted
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 a5bcac3 commit de2185d

File tree

21 files changed

+3187
-162
lines changed

21 files changed

+3187
-162
lines changed

Cargo.lock

Lines changed: 49 additions & 4 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/generic_log_worker/src/lib.rs

Lines changed: 34 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@ use std::cell::RefCell;
2828
use std::collections::btree_map::Entry;
2929
use std::collections::{BTreeMap, HashMap, VecDeque};
3030
use std::io::Write;
31-
use std::time::Duration;
3231
use tlog_tiles::{LookupKey, PendingLogEntry, SequenceMetadata};
3332
use tokio::sync::Mutex;
3433
use util::now_millis;
3534
use worker::{
36-
js_sys, kv, kv::KvStore, wasm_bindgen, Bucket, Delay, Env, Error, HttpMetadata, Result, State,
35+
js_sys, kv, kv::KvStore, wasm_bindgen, Bucket, Env, Error, HttpMetadata, Result, State,
3736
Storage, Stub,
3837
};
3938

@@ -514,36 +513,6 @@ impl LockBackend for State {
514513
}
515514
}
516515

517-
// R2 retry config: 3 retries with exponential backoff (100ms -> 200ms -> 400ms).
518-
pub const R2_MAX_RETRIES: u32 = 3;
519-
pub const R2_BASE_DELAY_MS: u64 = 100;
520-
521-
/// Retries an async operation with exponential backoff.
522-
///
523-
/// # Errors
524-
///
525-
/// Returns the last error if all retry attempts fail.
526-
pub async fn with_retry<T, F, Fut>(max_retries: u32, base_delay_ms: u64, operation: F) -> Result<T>
527-
where
528-
F: Fn() -> Fut,
529-
Fut: std::future::Future<Output = Result<T>>,
530-
{
531-
let mut last_error = None;
532-
for attempt in 0..=max_retries {
533-
match operation().await {
534-
Ok(result) => return Ok(result),
535-
Err(e) => {
536-
last_error = Some(e);
537-
if attempt < max_retries {
538-
let delay_ms = base_delay_ms * (1 << attempt);
539-
Delay::from(Duration::from_millis(delay_ms)).await;
540-
}
541-
}
542-
}
543-
}
544-
Err(last_error.expect("with_retry: at least one attempt should have been made"))
545-
}
546-
547516
pub trait ObjectBackend {
548517
/// Upload the object with the given key and data to the object backend,
549518
/// adding additional HTTP metadata headers based on the provided options.
@@ -592,39 +561,31 @@ impl ObjectBackend for ObjectBucket {
592561
opts: &UploadOptions,
593562
) -> Result<()> {
594563
let start = now_millis();
595-
let content_type = opts
596-
.content_type
597-
.clone()
598-
.unwrap_or_else(|| "application/octet-stream".into());
599-
let cache_control = if opts.immutable {
600-
"public, max-age=604800, immutable"
564+
let mut metadata = HttpMetadata::default();
565+
if let Some(content_type) = &opts.content_type {
566+
metadata.content_type = Some(content_type.to_string());
601567
} else {
602-
"no-store"
603-
};
568+
metadata.content_type = Some("application/octet-stream".into());
569+
}
570+
if opts.immutable {
571+
metadata.cache_control = Some("public, max-age=604800, immutable".into());
572+
} else {
573+
metadata.cache_control = Some("no-store".into());
574+
}
604575
let value: Vec<u8> = data.into();
605-
let key_str = key.as_ref();
606576
self.metrics
607577
.as_ref()
608578
.inspect(|&m| m.upload_size_bytes.observe(value.len().as_f64()));
609-
610-
with_retry(R2_MAX_RETRIES, R2_BASE_DELAY_MS, || async {
611-
let metadata = HttpMetadata {
612-
content_type: Some(content_type.clone()),
613-
cache_control: Some(cache_control.into()),
614-
..Default::default()
615-
};
616-
self.bucket
617-
.put(key_str, value.clone())
618-
.http_metadata(metadata)
619-
.execute()
620-
.await
621-
})
622-
.await
623-
.inspect_err(|_| {
624-
self.metrics
625-
.as_ref()
626-
.inspect(|&m| m.errors.with_label_values(&["put"]).inc());
627-
})?;
579+
self.bucket
580+
.put(key.as_ref(), value.clone())
581+
.http_metadata(metadata)
582+
.execute()
583+
.await
584+
.inspect_err(|_| {
585+
self.metrics
586+
.as_ref()
587+
.inspect(|&m| m.errors.with_label_values(&["put"]).inc());
588+
})?;
628589

629590
self.metrics.as_ref().inspect(|&m| {
630591
m.duration
@@ -637,25 +598,20 @@ impl ObjectBackend for ObjectBucket {
637598

638599
async fn fetch<S: AsRef<str>>(&self, key: S) -> Result<Option<Vec<u8>>> {
639600
let start = now_millis();
640-
let key_str = key.as_ref();
641-
let res = with_retry(R2_MAX_RETRIES, R2_BASE_DELAY_MS, || async {
642-
match self.bucket.get(key_str).execute().await? {
643-
Some(obj) => {
644-
let body = obj
645-
.body()
646-
.ok_or_else(|| format!("missing object body: {}", key_str))?;
647-
let bytes = body.bytes().await?;
648-
Ok(Some(bytes))
649-
}
650-
None => Ok(None),
601+
let res = match self.bucket.get(key.as_ref()).execute().await? {
602+
Some(obj) => {
603+
let body = obj
604+
.body()
605+
.ok_or_else(|| format!("missing object body: {}", key.as_ref()))?;
606+
let bytes = body.bytes().await.inspect_err(|_| {
607+
self.metrics.as_ref().inspect(|&m| {
608+
m.errors.with_label_values(&["get"]).inc();
609+
});
610+
})?;
611+
Ok(Some(bytes))
651612
}
652-
})
653-
.await
654-
.inspect_err(|_| {
655-
self.metrics
656-
.as_ref()
657-
.inspect(|&m| m.errors.with_label_values(&["get"]).inc());
658-
});
613+
None => Ok(None),
614+
};
659615
self.metrics.as_ref().inspect(|&m| {
660616
m.duration
661617
.with_label_values(&["get"])

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

0 commit comments

Comments
 (0)