#[path = "../build_support/mod.rs"]
mod build_support;
use build_support::{curation, octal, pem, sha1, subject_hash};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.first().map(String::as_str) {
Some("import") => cmd_import(args.get(1)),
Some("verify") => cmd_verify(),
Some("diff") => cmd_diff(args.get(1)),
Some("hash") => cmd_hash(args.get(1)),
_ => {
eprintln!("usage: cacrt-tool <import|verify|diff|hash> [path]");
std::process::exit(2);
}
};
if let Err(e) = result {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn roots_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("roots")
}
#[derive(Default, Debug)]
struct CertObj {
label: String,
value: Vec<u8>, distrust_after: bool, }
#[derive(Default, Debug)]
struct TrustObj {
sha1: Vec<u8>, server_auth: String, }
struct Certdata {
certs: Vec<CertObj>,
trust: Vec<TrustObj>,
}
fn parse_certdata(text: &str) -> Result<Certdata, String> {
let mut certs = Vec::new();
let mut trust = Vec::new();
let mut lines = text.lines().peekable();
let mut class = String::new();
let mut attrs: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut str_attrs: BTreeMap<String, String> = BTreeMap::new();
let flush = |class: &str,
attrs: &BTreeMap<String, Vec<u8>>,
str_attrs: &BTreeMap<String, String>,
certs: &mut Vec<CertObj>,
trust: &mut Vec<TrustObj>| {
match class {
"CKO_CERTIFICATE" => certs.push(CertObj {
label: str_attrs.get("CKA_LABEL").cloned().unwrap_or_default(),
value: attrs.get("CKA_VALUE").cloned().unwrap_or_default(),
distrust_after: attrs.contains_key("CKA_NSS_SERVER_DISTRUST_AFTER"),
}),
"CKO_NSS_TRUST" => trust.push(TrustObj {
sha1: attrs.get("CKA_CERT_SHA1_HASH").cloned().unwrap_or_default(),
server_auth: str_attrs
.get("CKA_TRUST_SERVER_AUTH")
.cloned()
.unwrap_or_default(),
}),
_ => {}
}
};
while let Some(line) = lines.next() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "BEGINDATA" {
continue;
}
let mut it = trimmed.splitn(3, ' ');
let name = it.next().unwrap_or("");
if !name.starts_with("CKA_") {
continue;
}
let ty = it.next().unwrap_or("");
let rest = it.next().unwrap_or("");
if name == "CKA_CLASS" {
if !class.is_empty() {
flush(&class, &attrs, &str_attrs, &mut certs, &mut trust);
}
class = rest.trim().to_string();
attrs.clear();
str_attrs.clear();
continue;
}
if ty == "MULTILINE_OCTAL" {
let mut body = String::new();
for l in lines.by_ref() {
if l.trim() == "END" {
break;
}
body.push_str(l);
body.push('\n');
}
attrs.insert(name.to_string(), octal::decode(&body)?);
} else if ty == "UTF8" || ty.starts_with("CK_") {
let v = rest.trim();
let v = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(v);
str_attrs.insert(name.to_string(), v.to_string());
}
}
if !class.is_empty() {
flush(&class, &attrs, &str_attrs, &mut certs, &mut trust);
}
Ok(Certdata { certs, trust })
}
fn cmd_import(path: Option<&String>) -> Result<(), String> {
let path = path.ok_or("import needs a path to certdata.txt")?;
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let data = parse_certdata(&text)?;
let mut trust_by_sha1: BTreeMap<Vec<u8>, &TrustObj> = BTreeMap::new();
for t in &data.trust {
if !t.sha1.is_empty() {
trust_by_sha1.insert(t.sha1.clone(), t);
}
}
let now = curation::now_yyyymmddhhmmss();
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("import-staging");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
eprintln!("note: roots/ is a frozen baseline; writing to import-staging/ for reference\n");
let mut used_names: BTreeMap<String, u32> = BTreeMap::new();
let (mut written, mut skipped) = (0u32, 0u32);
for cert in &data.certs {
let fp = sha1::sha1(&cert.value);
let server_auth = trust_by_sha1
.get(fp.as_slice())
.map(|t| t.server_auth.as_str());
match curation::evaluate(
cert.distrust_after,
&cert.label,
server_auth,
&cert.value,
now,
) {
curation::Decision::Accept => {}
curation::Decision::Reject(reason) => {
skipped += 1;
eprintln!("skip {:<45} {reason}", truncate(&cert.label, 45));
continue;
}
}
let hash = subject_hash(&cert.value).map_err(|e| e.to_string())?;
let mut base = sanitize(&cert.label);
let n = used_names.entry(base.clone()).or_insert(0);
if *n > 0 {
base = format!("{base}-{n}");
}
*used_names.get_mut(&sanitize(&cert.label)).unwrap() += 1;
let file = dir.join(format!("{base}.pem"));
std::fs::write(&file, render_pem(&cert.label, hash, &fp, &cert.value))
.map_err(|e| e.to_string())?;
written += 1;
println!("ok {:08x} {}", hash, cert.label);
}
eprintln!("\nimported {written} roots, skipped {skipped} (see reasons above)");
Ok(())
}
fn render_pem(label: &str, hash: u32, fp_sha1: &[u8; 20], der: &[u8]) -> String {
let fp = fp_sha1
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(":");
let mut out = String::new();
out.push_str(&format!("# Label: {label}\n"));
out.push_str(&format!("# OpenSSL subject hash: {hash:08x}\n"));
out.push_str(&format!("# SHA1 fingerprint: {fp}\n"));
out.push_str("# Source: Mozilla NSS certdata.txt (CKT_NSS_TRUSTED_DELEGATOR, server auth)\n");
out.push_str(&pem::write_certificate(der));
out
}
fn cmd_verify() -> Result<(), String> {
let mut problems = 0u32;
let mut count = 0u32;
for (path, der) in load_roots()? {
count += 1;
if let Err(reason) = curation::check_cert(&der, curation::now_yyyymmddhhmmss()) {
problems += 1;
eprintln!("FAIL {}: {reason}", path.display());
}
match subject_hash(&der) {
Err(_) => {
problems += 1;
eprintln!("FAIL {}: cannot compute subject hash", path.display());
}
Ok(hash) => {
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
if let Some(declared) = parse_header_hash(&text) {
if declared != hash {
problems += 1;
eprintln!(
"FAIL {}: header subject hash {declared:08x} != computed {hash:08x}",
path.display()
);
}
}
}
}
}
if problems > 0 {
return Err(format!("{problems} problem(s) across {count} roots"));
}
println!("verified {count} roots, all pass curation rules");
Ok(())
}
fn parse_header_hash(text: &str) -> Option<u32> {
let line = text
.lines()
.find_map(|l| l.trim_start().strip_prefix("# OpenSSL subject hash:"))?;
u32::from_str_radix(line.trim(), 16).ok()
}
fn cmd_diff(path: Option<&String>) -> Result<(), String> {
let path = path.ok_or("diff needs a path to certdata.txt")?;
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let data = parse_certdata(&text)?;
let mut trust_by_sha1: BTreeMap<Vec<u8>, &TrustObj> = BTreeMap::new();
for t in &data.trust {
trust_by_sha1.insert(t.sha1.clone(), t);
}
let now = curation::now_yyyymmddhhmmss();
let mut incoming: BTreeMap<[u8; 20], String> = BTreeMap::new();
for cert in &data.certs {
let fp = sha1::sha1(&cert.value);
let server_auth = trust_by_sha1
.get(fp.as_slice())
.map(|t| t.server_auth.as_str());
if matches!(
curation::evaluate(
cert.distrust_after,
&cert.label,
server_auth,
&cert.value,
now
),
curation::Decision::Accept
) {
incoming.insert(fp, cert.label.clone());
}
}
let mut current: BTreeMap<[u8; 20], PathBuf> = BTreeMap::new();
for (path, der) in load_roots()? {
current.insert(sha1::sha1(&der), path);
}
for (fp, label) in &incoming {
if !current.contains_key(fp) {
println!("+ {label}");
}
}
for (fp, path) in ¤t {
if !incoming.contains_key(fp) {
println!("- {}", path.display());
}
}
Ok(())
}
fn cmd_hash(path: Option<&String>) -> Result<(), String> {
let path = path.ok_or("hash needs a path to a cert (PEM or DER)")?;
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let der = match std::str::from_utf8(&bytes) {
Ok(text) if text.contains("-----BEGIN CERTIFICATE-----") => {
pem::read_one_certificate(text)?
}
_ => bytes,
};
println!("{:08x}", subject_hash(&der).map_err(|e| e.to_string())?);
Ok(())
}
fn load_roots() -> Result<Vec<(PathBuf, Vec<u8>)>, String> {
let mut paths = Vec::new();
collect_pems(&roots_dir(), &mut paths)?;
paths.sort();
let mut out = Vec::new();
for path in paths {
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let der =
pem::read_one_certificate(&text).map_err(|e| format!("{}: {e}", path.display()))?;
out.push((path, der));
}
Ok(out)
}
fn collect_pems(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Ok(()),
};
for e in entries {
let path = e.map_err(|e| e.to_string())?.path();
if path.is_dir() {
collect_pems(&path, out)?;
} else if path.extension().and_then(|s| s.to_str()) == Some("pem") {
out.push(path);
}
}
Ok(())
}
fn sanitize(label: &str) -> String {
let mut s: String = label
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
while s.contains("__") {
s = s.replace("__", "_");
}
s.trim_matches('_').to_string()
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
let prefix: String = s.chars().take(n.saturating_sub(1)).collect();
format!("{prefix}…")
}
}