Skip to content

Commit aa88673

Browse files
committed
ccadb: add crl_hosts() API
1 parent bb89979 commit aa88673

5 files changed

Lines changed: 294 additions & 4 deletions

File tree

‎Cargo.lock‎

Lines changed: 25 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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@ repository = "https://github.com/rustls/webpki-roots"
1010

1111
[workspace.dependencies]
1212
aws-lc-rs = "1.15.2"
13-
chrono = { version = "0.4.26", default-features = false, features = ["clock"] }
13+
chrono = { version = "0.4.26", default-features = false, features = ["clock", "serde"] }
1414
csv = "1.2.2"
1515
hex = "0.4.3"
1616
num-bigint = "0.4.3"
1717
percent-encoding = "2.3"
1818
pki-types = { package = "rustls-pki-types", version = "1.8", default-features = false }
1919
rcgen = "0.14.3"
20-
reqwest = { version = "0.13", default-features = false, features = ["default-tls"] }
20+
reqwest = { version = "0.13", default-features = false, features = ["default-tls", "json"] }
2121
rustls = "0.23"
2222
serde = { version = "1.0.183", features = ["derive"] }
23+
serde_json = "1"
2324
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
25+
url = "2.4"
2426
webpki = { package = "rustls-webpki", version = "0.103", features = ["alloc"] }
2527
x509-parser = "0.18"
2628
yasna = "0.6"

‎webpki-ccadb/Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ num-bigint = { workspace = true }
1616
pki-types = { workspace = true }
1717
reqwest = { workspace = true }
1818
serde = { workspace = true }
19+
serde_json = { workspace = true }
20+
url = { workspace = true }
1921
webpki = { workspace = true }
2022
x509-parser = { workspace = true }
2123
yasna = { workspace = true }
24+
25+
[dev-dependencies]
26+
tokio = { workspace = true }
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIEYDCCAkigAwIBAgIQB55JKIY3b9QISMI/xjHkYzANBgkqhkiG9w0BAQsFADBP
3+
MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
4+
Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yMDA5MDQwMDAwMDBa
5+
Fw0yNTA5MTUxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5l
6+
dCBTZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgy
7+
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0H
8+
ttwW+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7
9+
AlF9ItgKbppbd9/w+kHsOdx1ymgHDB/qo4HlMIHiMA4GA1UdDwEB/wQEAwIBBjAP
10+
BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR8Qpau3ktIO/qS+J6Mz22LqXI3lTAf
11+
BgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcBAQQmMCQw
12+
IgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wJwYDVR0fBCAwHjAc
13+
oBqgGIYWaHR0cDovL3gxLmMubGVuY3Iub3JnLzAiBgNVHSAEGzAZMAgGBmeBDAEC
14+
ATANBgsrBgEEAYLfEwEBATANBgkqhkiG9w0BAQsFAAOCAgEAG38lK5B6CHYAdxjh
15+
wy6KNkxBfr8XS+Mw11sMfpyWmG97sGjAJETM4vL80erb0p8B+RdNDJ1V/aWtbdIv
16+
P0tywC6uc8clFlfCPhWt4DHRCoSEbGJ4QjEiRhrtekC/lxaBRHfKbHtdIVwH8hGR
17+
Ib/hL8Lvbv0FIOS093nzLbs3KvDGsaysUfUfs1oeZs5YBxg4f3GpPIO617yCnpp2
18+
D56wKf3L84kHSBv+q5MuFCENX6+Ot1SrXQ7UW0xx0JLqPaM2m3wf4DtVudhTU8yD
19+
ZrtK3IEGABiL9LPXSLETQbnEtp7PLHeOQiALgH6fxatI27xvBI1sRikCDXCKHfES
20+
c7ZGJEKeKhcY46zHmMJyzG0tdm3dLCsmlqXPIQgb5dovy++fc5Ou+DZfR4+XKM6r
21+
4pgmmIv97igyIintTJUJxCD6B+GGLET2gUfA5GIy7R3YPEiIlsNekbave1mk7uOG
22+
nMeIWMooKmZVm4WAuR3YQCvJHBM8qevemcIWQPb1pK4qJWxSuscETLQyu/w4XKAM
23+
YXtX7HdOUM+vBqIPN4zhDtLTLxq9nHE+zOH40aijvQT2GcD5hq/1DhqqlWvvykdx
24+
S2McTZbbVSMKnQ+BdaDmQPVkRgNuzvpqfQbspDQGdNpT2Lm4xiN9qfgqLaSCpi4t
25+
EcrmzTFYeYXmchynn9NM0GbQp7s=
26+
-----END CERTIFICATE-----

‎webpki-ccadb/src/lib.rs‎

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
use std::borrow::Cow;
12
use std::cmp::Ordering;
23
use std::collections::{BTreeMap, HashSet};
34
use std::ops::Add;
45

5-
use chrono::{Duration, NaiveDate, Utc};
6+
use chrono::{Datelike, Duration, NaiveDate, Utc};
67
use num_bigint::BigUint;
78
use pki_types::pem::PemObject;
89
use pki_types::CertificateDer;
9-
use serde::Deserialize;
10+
use serde::de::DeserializeOwned;
11+
use serde::{Deserialize, Serialize};
12+
use url::Url;
1013

1114
// Fetch root certificate data from the CCADB server.
1215
//
@@ -259,6 +262,224 @@ impl From<&str> for TrustBits {
259262
}
260263
}
261264

265+
pub async fn crl_hosts(store: RootStore) -> Result<HashSet<String>, Box<dyn core::error::Error>> {
266+
let ccadb_url = "https://ccadb.my.site.com/services/apexrest/v1/allcertificaterecords";
267+
let client = build_client(ISRG_ROOT_X2)?;
268+
269+
let mut records = HashSet::default();
270+
let mut page_number = 1;
271+
let mut decade = 2000;
272+
let last_decade = (Utc::now().year() / 10 * 10) as u16;
273+
let today = Utc::now().naive_utc().date();
274+
loop {
275+
let input = AllCertificateRecordsRequest {
276+
filters: Some(AllCertificateRecordsRequestFilters {
277+
not_before_decade: Some(decade),
278+
page_number,
279+
}),
280+
field_sets: vec![
281+
AllCertificateRecordsRequestFieldSet::PertainingToCertificatesIssued,
282+
AllCertificateRecordsRequestFieldSet::Capabilities,
283+
],
284+
};
285+
286+
let req = client.post(ccadb_url).json(&input).build()?;
287+
let rsp = client.execute(req).await?.error_for_status()?;
288+
let json = rsp.text().await?;
289+
let data = serde_json::from_str::<AllCertificateRecordsResponse>(&json)?;
290+
for info in data.data {
291+
if info.certificate_data.valid_to < today
292+
|| !matches!(
293+
info.trusted(store),
294+
StoreStatus::Trusted | StoreStatus::Included
295+
)
296+
|| !info.for_tls()
297+
{
298+
continue;
299+
}
300+
301+
let Some(pertaining) = info.pertaining_to_certificates_issued else {
302+
continue;
303+
};
304+
305+
let iter = pertaining
306+
.all_full_crl_urls
307+
.iter()
308+
.chain(pertaining.partitioned_crls.iter());
309+
for url in iter {
310+
if url.trim().is_empty() {
311+
continue;
312+
}
313+
314+
let Ok(url) = Url::parse(url) else {
315+
println!("invalid URL: {url}");
316+
continue;
317+
};
318+
319+
if let Some(host) = url.host_str() {
320+
records.insert(host.to_string());
321+
}
322+
}
323+
}
324+
325+
page_number += 1;
326+
if data.meta.pagination.current_page_number < data.meta.pagination.total_pages {
327+
continue;
328+
}
329+
330+
if decade >= last_decade {
331+
break;
332+
}
333+
334+
decade += 10;
335+
page_number = 1;
336+
}
337+
338+
Ok(records)
339+
}
340+
341+
#[derive(Debug, Deserialize)]
342+
#[serde(rename_all = "PascalCase")]
343+
struct AllCertificateRecordsResponse {
344+
meta: AllCertificateRecordsResponseMeta,
345+
data: Vec<RecordData>,
346+
}
347+
348+
#[derive(Debug, Deserialize)]
349+
#[serde(rename_all = "PascalCase")]
350+
struct RecordData {
351+
root_store_status: RootStoreStatus,
352+
certificate_data: CertificateData,
353+
pertaining_to_certificates_issued: Option<PertainingToCertificatesIssued>,
354+
capabilities: Option<Capabilities>,
355+
}
356+
357+
impl RecordData {
358+
fn trusted(&self, store: RootStore) -> StoreStatus {
359+
match store {
360+
RootStore::Apple => self.root_store_status.apple_status,
361+
RootStore::Chrome => self.root_store_status.chrome_status,
362+
RootStore::Microsoft => self.root_store_status.microsoft_status,
363+
RootStore::Mozilla => self.root_store_status.mozilla_status,
364+
}
365+
}
366+
367+
fn for_tls(&self) -> bool {
368+
match &self.capabilities {
369+
Some(capabilities) => capabilities.tls_capable || capabilities.tls_ev_capable,
370+
None => false,
371+
}
372+
}
373+
}
374+
375+
#[derive(Debug, Deserialize)]
376+
#[serde(rename_all = "PascalCase")]
377+
struct RootStoreStatus {
378+
apple_status: StoreStatus,
379+
chrome_status: StoreStatus,
380+
microsoft_status: StoreStatus,
381+
mozilla_status: StoreStatus,
382+
}
383+
384+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
385+
enum StoreStatus {
386+
Trusted,
387+
#[serde(rename = "Not Trusted")]
388+
NotTrusted,
389+
#[serde(rename = "Not Included")]
390+
NotIncluded,
391+
#[serde(rename = "Not Yet Included")]
392+
NotYetIncluded,
393+
Disabled,
394+
Included,
395+
NotBefore,
396+
Removed,
397+
Blocked,
398+
}
399+
400+
#[derive(Debug, Deserialize)]
401+
#[serde(rename_all = "PascalCase")]
402+
struct CertificateData {
403+
valid_to: NaiveDate,
404+
}
405+
406+
#[derive(Debug, Deserialize)]
407+
struct PertainingToCertificatesIssued {
408+
#[serde(rename = "JSONArrayOfAllFullCRLURLs", deserialize_with = "json_string")]
409+
all_full_crl_urls: Vec<String>,
410+
#[serde(
411+
rename = "JSONArrayOfPartitionedCRLs",
412+
deserialize_with = "json_string"
413+
)]
414+
partitioned_crls: Vec<String>,
415+
}
416+
417+
fn json_string<'de, D: serde::Deserializer<'de>, T: DeserializeOwned + Default>(
418+
deserializer: D,
419+
) -> Result<T, D::Error> {
420+
let s = <Cow<'de, str>>::deserialize(deserializer)?;
421+
if s.is_empty() || s == "\"\"" {
422+
return Ok(T::default());
423+
}
424+
425+
serde_json::from_str(s.as_ref()).map_err(serde::de::Error::custom)
426+
}
427+
428+
#[derive(Debug, Deserialize)]
429+
#[serde(rename_all = "PascalCase")]
430+
struct Capabilities {
431+
#[serde(rename = "TLSCapable")]
432+
tls_capable: bool,
433+
#[serde(rename = "TLSEVCapable")]
434+
tls_ev_capable: bool,
435+
}
436+
437+
#[derive(Debug, Deserialize)]
438+
#[serde(rename_all = "PascalCase")]
439+
struct AllCertificateRecordsResponseMeta {
440+
pagination: AllCertificateRecordsResponsePagination,
441+
}
442+
443+
#[derive(Debug, Deserialize)]
444+
#[serde(rename_all = "PascalCase")]
445+
struct AllCertificateRecordsResponsePagination {
446+
total_pages: u16,
447+
current_page_number: u16,
448+
}
449+
450+
#[derive(Debug, Serialize)]
451+
struct AllCertificateRecordsRequest {
452+
filters: Option<AllCertificateRecordsRequestFilters>,
453+
#[serde(rename = "fieldSets")]
454+
field_sets: Vec<AllCertificateRecordsRequestFieldSet>,
455+
}
456+
457+
#[derive(Debug, Serialize)]
458+
struct AllCertificateRecordsRequestFilters {
459+
#[serde(rename = "notBeforeDecade")]
460+
not_before_decade: Option<u16>,
461+
#[serde(rename = "PageNumber")]
462+
page_number: u16,
463+
}
464+
465+
/// Field sets that can be requested as part of an [`AllCertificateRecordsRequest`].
466+
///
467+
/// https://github.com/mozilla/CCADB-Tools/blob/master/API_AllCertificateRecords/README.md#3-dynamic-field-sets
468+
#[derive(Debug, Serialize)]
469+
enum AllCertificateRecordsRequestFieldSet {
470+
Capabilities,
471+
PertainingToCertificatesIssued,
472+
}
473+
474+
#[non_exhaustive]
475+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476+
pub enum RootStore {
477+
Apple,
478+
Chrome,
479+
Microsoft,
480+
Mozilla,
481+
}
482+
262483
/// Build a reqwest client that only trusts the CA certificate authenticating the CCADB server
263484
fn build_client(root_pem: &str) -> Result<reqwest::Client, reqwest::Error> {
264485
// If we see Unknown CA TLS validation failures from the Reqwest client in the future it
@@ -280,6 +501,8 @@ fn build_client(root_pem: &str) -> Result<reqwest::Client, reqwest::Error> {
280501
.build()
281502
}
282503

504+
// https://letsencrypt.org/certs/isrg-root-x2-cross-signed.pem
505+
const ISRG_ROOT_X2: &str = include_str!("data/isrg-root-x2-cross-signed.pem");
283506
const DIGI_CERT_GLOBAL_ROOT_G2: &str = include_str!("data/DigiCertGlobalRootG2.pem");
284507

285508
static EXCLUDED_FINGERPRINTS: &[&str] = &[
@@ -294,6 +517,15 @@ static EXCLUDED_FINGERPRINTS: &[&str] = &[
294517
mod tests {
295518
use super::*;
296519

520+
#[tokio::test]
521+
async fn test_crl_hosts() {
522+
let hosts = crl_hosts(RootStore::Chrome).await.unwrap();
523+
dbg!(&hosts);
524+
assert!(hosts.contains("x2.c.lencr.org"));
525+
assert!(hosts.contains("crl.apple.com"));
526+
assert!(hosts.contains("crl.pki.goog"));
527+
}
528+
297529
#[test]
298530
fn test_trusted_for_tls() {
299531
let mut metadata = CertificateMetadata {

0 commit comments

Comments
 (0)