use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail, ensure};
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa, Issuer,
KeyPair, KeyUsagePurpose, NameConstraints, SanType, date_time_ymd,
};
const AUTHORITY_DAYS: i64 = 3650;
const LEAF_DAYS: i64 = 90;
pub const AUTHORITY_NAME: &str = "ssh-browser local CA";
pub fn common_name(suffix: &str) -> String {
format!("{AUTHORITY_NAME} ({suffix})")
}
pub struct Authority {
issuer: Issuer<'static, KeyPair>,
certificate_pem: String,
suffix: String,
}
impl Authority {
pub fn create(suffix: &str) -> Result<Self> {
ensure!(
crate::origin::pac::is_suffix(suffix),
"suffix {suffix:?} cannot go in a certificate: it must be lowercase letters, digits, hyphens and dots"
);
let mut params = CertificateParams::default();
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, common_name(suffix));
name.push(DnType::OrganizationName, "ssh-browser");
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.name_constraints = Some(NameConstraints {
permitted_subtrees: vec![GeneralSubtree::DnsName(suffix.to_string())],
excluded_subtrees: Vec::new(),
});
set_validity(&mut params, AUTHORITY_DAYS)?;
let key = KeyPair::generate().context("generating a key for the local authority")?;
let certificate_pem = params
.self_signed(&key)
.context("signing the local authority")?
.pem();
Ok(Self {
issuer: Issuer::new(params, key),
certificate_pem,
suffix: suffix.to_string(),
})
}
pub fn certificate_pem(&self) -> &str {
&self.certificate_pem
}
pub fn suffix(&self) -> &str {
&self.suffix
}
pub fn leaf_for(&self, name: &str) -> Result<Leaf> {
ensure!(
name == self.suffix
|| name
.strip_suffix(&self.suffix)
.and_then(|head| head.strip_suffix('.'))
.is_some_and(crate::origin::guard::is_label),
"{name:?} is not a single label under {:?}, so this authority cannot vouch for it",
self.suffix
);
self.leaf_named(&[name])
}
fn leaf_named(&self, names: &[&str]) -> Result<Leaf> {
let first = names
.first()
.context("a certificate needs at least one name")?;
let mut params = CertificateParams::default();
let mut subject = DistinguishedName::new();
subject.push(DnType::CommonName, (*first).to_string());
params.distinguished_name = subject;
params.subject_alt_names = names
.iter()
.map(|name| {
Ok(SanType::DnsName(
(*name)
.to_string()
.try_into()
.with_context(|| format!("{name:?} is not a valid DNS name"))?,
))
})
.collect::<Result<Vec<_>>>()?;
params.use_authority_key_identifier_extension = true;
set_validity(&mut params, LEAF_DAYS)?;
let key = KeyPair::generate().context("generating a key for the serving certificate")?;
let cert = params
.signed_by(&key, &self.issuer)
.context("signing the serving certificate")?;
Ok(Leaf {
certificate_pem: cert.pem(),
key_pem: key.serialize_pem(),
})
}
}
pub struct Leaf {
pub certificate_pem: String,
pub key_pem: String,
}
fn set_validity(params: &mut CertificateParams, days: i64) -> Result<()> {
use std::time::{SystemTime, UNIX_EPOCH};
let now = i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("the system clock is before 1970")?
.as_secs(),
)
.context("the system clock is implausibly far in the future")?;
let today = now / 86_400;
let (y, m, d) = civil_from_days(today - 1);
params.not_before = date_time_ymd(y, m, d);
let (y, m, d) = civil_from_days(today + days);
params.not_after = date_time_ymd(y, m, d);
Ok(())
}
fn civil_from_days(z: i64) -> (i32, u8, u8) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = u64::try_from(z - era * 146_097).unwrap_or(0);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = i64::try_from(yoe).unwrap_or(0) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = u8::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1);
let m = u8::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
(
i32::try_from(if m <= 2 { y + 1 } else { y }).unwrap_or(1970),
m,
d,
)
}
#[derive(Debug, PartialEq, Eq)]
pub struct Limits {
pub permitted: Vec<String>,
pub excluded: Vec<String>,
pub constraints_critical: bool,
pub path_len: Option<u32>,
pub is_ca: bool,
pub signs_only_certificates: bool,
}
pub fn limits_of(certificate_pem: &str) -> Result<Limits> {
use x509_parser::extensions::{GeneralName, ParsedExtension};
use x509_parser::prelude::*;
let (_, pem) = x509_parser::pem::parse_x509_pem(certificate_pem.as_bytes())
.context("the certificate is not PEM")?;
let (_, cert) =
X509Certificate::from_der(&pem.contents).context("the certificate is not X.509")?;
let mut limits = Limits {
permitted: Vec::new(),
excluded: Vec::new(),
constraints_critical: false,
path_len: None,
is_ca: false,
signs_only_certificates: false,
};
for ext in cert.extensions() {
match ext.parsed_extension() {
ParsedExtension::NameConstraints(nc) => {
limits.constraints_critical = ext.critical;
for tree in nc.permitted_subtrees.iter().flatten() {
if let GeneralName::DNSName(name) = tree.base {
limits.permitted.push(name.to_string());
}
}
for tree in nc.excluded_subtrees.iter().flatten() {
if let GeneralName::DNSName(name) = tree.base {
limits.excluded.push(name.to_string());
}
}
}
ParsedExtension::BasicConstraints(bc) => {
limits.is_ca = bc.ca;
limits.path_len = bc.path_len_constraint;
}
ParsedExtension::KeyUsage(ku) => {
limits.signs_only_certificates = ku.key_cert_sign()
&& !ku.digital_signature()
&& !ku.key_encipherment()
&& !ku.key_agreement()
&& !ku.data_encipherment();
}
_ => {}
}
}
Ok(limits)
}
pub fn permits_only(certificate_pem: &str, suffix: &str) -> bool {
let Ok(limits) = limits_of(certificate_pem) else {
return false;
};
limits.permitted == [suffix]
&& limits.excluded.is_empty()
&& limits.constraints_critical
&& limits.is_ca
&& limits.path_len == Some(0)
&& limits.signs_only_certificates
}
pub fn spki_pin(certificate_pem: &str) -> Result<String> {
use x509_parser::prelude::*;
let (_, pem) = x509_parser::pem::parse_x509_pem(certificate_pem.as_bytes())
.context("the certificate is not PEM")?;
let (_, cert) =
X509Certificate::from_der(&pem.contents).context("the certificate is not X.509")?;
let spki = cert.tbs_certificate.subject_pki.raw;
let digest = ring::digest::digest(&ring::digest::SHA256, spki);
Ok(base64(digest.as_ref()))
}
fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
chunk.get(1).copied().unwrap_or(0),
chunk.get(2).copied().unwrap_or(0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(char::from(ALPHABET[(n >> 18) as usize & 63]));
out.push(char::from(ALPHABET[(n >> 12) as usize & 63]));
out.push(if chunk.len() > 1 {
char::from(ALPHABET[(n >> 6) as usize & 63])
} else {
'='
});
out.push(if chunk.len() > 2 {
char::from(ALPHABET[n as usize & 63])
} else {
'='
});
}
out
}
pub fn authority_dir() -> Option<PathBuf> {
Some(crate::control::state_dir()?.join("ca"))
}
pub fn certificate_path() -> Option<PathBuf> {
Some(authority_dir()?.join("authority.pem"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Found {
Existing,
Created,
}
pub fn load_or_create(suffix: &str) -> Result<Authority> {
Ok(load_or_create_reporting(suffix)?.0)
}
pub fn load_or_create_reporting(suffix: &str) -> Result<(Authority, Found)> {
let Some(dir) = authority_dir() else {
bail!("no state directory to keep a local certificate authority in");
};
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let key_path = dir.join("authority.key");
let cert_path = dir.join("authority.pem");
if let Some(found) = load(&key_path, &cert_path, suffix) {
return Ok((found, Found::Existing));
}
let authority = Authority::create(suffix)?;
crate::control::write_private(&key_path, authority.issuer.key().serialize_pem().as_bytes())
.with_context(|| format!("writing {}", key_path.display()))?;
std::fs::write(&cert_path, authority.certificate_pem())
.with_context(|| format!("writing {}", cert_path.display()))?;
Ok((authority, Found::Created))
}
fn load(key_path: &Path, cert_path: &Path, suffix: &str) -> Option<Authority> {
let (Ok(key_pem), Ok(certificate_pem)) = (
std::fs::read_to_string(key_path),
std::fs::read_to_string(cert_path),
) else {
return None;
};
let key = match KeyPair::from_pem(&key_pem) {
Ok(key) => key,
Err(e) => {
eprintln!(" the stored authority key could not be read ({e}); making a new one");
return None;
}
};
if !permits_only(&certificate_pem, suffix) {
eprintln!(" the stored authority is not an authority constrained to {suffix:?} alone;");
eprintln!(" making one that is. The old certificate can be removed from your trust");
eprintln!(" store: see `ssh-browser trust`.");
return None;
}
match Issuer::from_ca_cert_pem(&certificate_pem, key) {
Ok(issuer) => Some(Authority {
issuer,
certificate_pem,
suffix: suffix.to_string(),
}),
Err(e) => {
eprintln!(" the stored authority could not be loaded ({e}); making a new one");
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Store {
Windows,
MacOs,
Other,
}
impl Store {
pub fn here() -> Self {
if cfg!(windows) {
Self::Windows
} else if cfg!(target_os = "macos") {
Self::MacOs
} else {
Self::Other
}
}
}
pub fn trust_instructions(suffix: &str, cert_path: &Path) -> String {
instructions_for(Store::here(), suffix, cert_path)
}
pub fn instructions_for(store: Store, suffix: &str, cert_path: &Path) -> String {
let path = cert_path.display();
let name = common_name(suffix);
let preamble = format!(
"The certificate to trust is\n {path}\n\n\
It is an authority constrained to one suffix: if its key leaks, it can vouch for that\n\
suffix and nothing else. Nothing here installs it — the command below is yours to run,\n\
and the one after it undoes this.\n\n"
);
match store {
Store::Windows => format!(
"{preamble}Trust it for this account only, no administrator rights needed:\n\
\x20 certutil -addstore -user Root \"{path}\"\n\n\
Undo:\n\
\x20 certutil -delstore -user Root \"{name}\"\n\n\
Check what is there:\n\
\x20 certutil -store -user Root | findstr /C:\"{name}\"\n"
),
Store::MacOs => format!(
"{preamble}Trust it in your login keychain (it will ask for your password):\n\
\x20 security add-trusted-cert -k ~/Library/Keychains/login.keychain-db \"{path}\"\n\n\
Undo:\n\
\x20 security delete-certificate -c \"{name}\" ~/Library/Keychains/login.keychain-db\n"
),
Store::Other => format!(
"{preamble}Where this goes depends on the distribution. On Debian and Ubuntu:\n\
\x20 sudo cp \"{path}\" /usr/local/share/ca-certificates/ssh-browser.crt\n\
\x20 sudo update-ca-certificates\n\n\
Undo:\n\
\x20 sudo rm /usr/local/share/ca-certificates/ssh-browser.crt\n\
\x20 sudo update-ca-certificates --fresh\n\n\
Firefox keeps its own store and does not read that one. Import it under Settings,\n\
Privacy & Security, Certificates, View Certificates, Authorities, Import — and to\n\
remove it again, find \"{name}\" in that same list and delete it.\n"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_authority_reaches_exactly_its_suffix_and_no_further() {
let ca = Authority::create("ssh-browser").expect("an authority");
let limits = limits_of(ca.certificate_pem()).expect("its own output parses");
assert_eq!(limits.permitted, ["ssh-browser"], "{limits:?}");
assert!(limits.excluded.is_empty(), "{limits:?}");
assert!(
limits.constraints_critical,
"a name constraint that is not critical may be skipped by a verifier: {limits:?}"
);
assert!(limits.is_ca, "{limits:?}");
assert_eq!(
limits.path_len,
Some(0),
"without pathLen 0 a leaked key can mint an intermediate: {limits:?}"
);
assert!(
limits.signs_only_certificates,
"the authority key must not be usable to serve TLS: {limits:?}"
);
}
#[test]
fn an_authority_for_one_suffix_does_not_permit_another() {
let ca = Authority::create("dev").expect("an authority");
assert!(permits_only(ca.certificate_pem(), "dev"));
assert!(!permits_only(ca.certificate_pem(), "ssh-browser"));
assert!(!permits_only(ca.certificate_pem(), "de"));
assert!(!permits_only(ca.certificate_pem(), ""));
}
#[test]
fn an_unconstrained_authority_is_not_adopted() {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let key = KeyPair::generate().expect("a key");
let pem = params.self_signed(&key).expect("self signed").pem();
let limits = limits_of(&pem).expect("parses");
assert!(limits.permitted.is_empty(), "{limits:?}");
assert_eq!(limits.path_len, None, "{limits:?}");
assert!(
!permits_only(&pem, "ssh-browser"),
"an unconstrained authority must never be treated as constrained"
);
}
#[test]
fn a_second_permitted_subtree_is_refused() {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.name_constraints = Some(NameConstraints {
permitted_subtrees: vec![
GeneralSubtree::DnsName("ssh-browser".to_string()),
GeneralSubtree::DnsName("example.com".to_string()),
],
excluded_subtrees: Vec::new(),
});
let key = KeyPair::generate().expect("a key");
let pem = params.self_signed(&key).expect("self signed").pem();
assert_eq!(
limits_of(&pem).expect("parses").permitted,
["ssh-browser", "example.com"]
);
assert!(!permits_only(&pem, "ssh-browser"));
}
#[test]
fn a_suffix_that_is_not_a_hostname_is_refused() {
for bad in ["Has Caps", "with space", "with/slash", "", "under_score"] {
assert!(
Authority::create(bad).is_err(),
"{bad:?} should not have produced an authority"
);
}
}
fn names_in(certificate_pem: &str) -> Vec<String> {
use x509_parser::prelude::*;
let (_, pem) =
x509_parser::pem::parse_x509_pem(certificate_pem.as_bytes()).expect("the leaf is PEM");
let (_, cert) = X509Certificate::from_der(&pem.contents).expect("the leaf is X.509");
cert.subject_alternative_name()
.ok()
.flatten()
.map(|san| {
san.value
.general_names
.iter()
.filter_map(|n| match n {
x509_parser::extensions::GeneralName::DNSName(d) => Some(d.to_string()),
_ => None,
})
.collect()
})
.unwrap_or_default()
}
#[test]
fn the_leaf_names_one_alias_and_is_not_itself_an_authority() {
let ca = Authority::create("ssh-browser").expect("an authority");
let leaf = ca.leaf_for("alias.ssh-browser").expect("a leaf");
assert!(leaf.key_pem.contains("PRIVATE KEY"));
let names = names_in(&leaf.certificate_pem);
assert_eq!(names, ["alias.ssh-browser"], "{names:?}");
assert!(
!names.iter().any(|n| n.starts_with('*')),
"a wildcard under a suffix that is not a real registry is refused by browsers: \
{names:?}"
);
assert!(
!limits_of(&leaf.certificate_pem).expect("parses").is_ca,
"the serving certificate must not be a CA"
);
}
#[test]
fn the_authority_signs_only_a_single_label_under_its_suffix() {
let ca = Authority::create("ssh-browser").expect("an authority");
assert!(ca.leaf_for("alias.ssh-browser").is_ok());
assert!(ca.leaf_for("ssh-browser").is_ok());
for bad in [
"evil.example",
"deep.nested.ssh-browser",
".ssh-browser",
"ssh-browser.evil.example",
"*.ssh-browser",
"",
] {
assert!(
ca.leaf_for(bad).is_err(),
"{bad:?} should not have been signed"
);
}
}
#[test]
fn every_platforms_instructions_say_what_to_install_and_how_to_undo_it() {
for store in [Store::Windows, Store::MacOs, Store::Other] {
let said = instructions_for(store, "ssh-browser", Path::new("/tmp/authority.pem"));
assert!(said.contains("authority.pem"), "{store:?}: {said}");
assert!(
said.contains("Undo:"),
"{store:?}: telling somebody to install a root without saying how to remove it \
is half an instruction: {said}"
);
assert!(
said.contains(&common_name("ssh-browser")),
"{store:?}: nothing names the authority, so it cannot be found to remove: {said}"
);
}
}
#[test]
fn the_instructions_printed_here_are_for_this_platform() {
let said = trust_instructions("ssh-browser", Path::new("/tmp/authority.pem"));
let expect = if cfg!(windows) {
"certutil"
} else if cfg!(target_os = "macos") {
"security add-trusted-cert"
} else {
"update-ca-certificates"
};
assert!(
said.contains(expect),
"expected {expect:?} for this platform: {said}"
);
}
#[test]
fn an_independent_verifier_refuses_a_name_outside_the_constraint() {
use std::process::Command;
let Ok(version) = Command::new("openssl").arg("version").output() else {
println!(
" SKIPPED an_independent_verifier_refuses_a_name_outside_the_constraint: \
no openssl on PATH"
);
return;
};
assert!(
version.status.success(),
"openssl is on PATH but would not run"
);
let ca = Authority::create("ssh-browser").expect("an authority");
let inside = ca
.leaf_named(&["alias.ssh-browser"])
.expect("a name inside the constraint");
let outside = ca
.leaf_named(&["evil.example"])
.expect("the signer does not police this; the verifier does");
let dir = std::env::temp_dir().join(format!("ssh-browser-nc-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("a temporary directory");
let ca_path = dir.join("ca.pem");
let inside_path = dir.join("inside.pem");
let outside_path = dir.join("outside.pem");
std::fs::write(&ca_path, ca.certificate_pem()).expect("write the authority");
std::fs::write(&inside_path, &inside.certificate_pem).expect("write the good leaf");
std::fs::write(&outside_path, &outside.certificate_pem).expect("write the bad leaf");
let verify = |leaf: &Path| {
let out = Command::new("openssl")
.arg("verify")
.arg("-CAfile")
.arg(&ca_path)
.arg(leaf)
.output()
.expect("openssl verify runs");
let said = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(out.status.success(), said)
};
let (ok, said) = verify(&inside_path);
assert!(ok, "a name under the suffix should verify: {said}");
let (ok, said) = verify(&outside_path);
assert!(
!ok,
"openssl accepted a certificate for evil.example from an authority constrained to \
ssh-browser, which means the constraint is buying nothing: {said}"
);
assert!(
said.to_lowercase().contains("subtree")
|| said.to_lowercase().contains("name constraint")
|| said.to_lowercase().contains("excluded"),
"refused, but not for the constraint -- so this test is not measuring it: {said}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn base64_matches_the_rfc_vectors() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foob"), "Zm9vYg==");
assert_eq!(base64(b"fooba"), "Zm9vYmE=");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
assert_eq!(base64(&[0xff, 0xff, 0xff]), "////");
assert_eq!(base64(&[0xfb, 0xff, 0xbf]), "+/+/");
}
#[test]
fn the_pin_follows_the_key_and_not_the_certificate() {
let ca = Authority::create("ssh-browser").expect("an authority");
let one = ca.leaf_for("a.ssh-browser").expect("a leaf");
let two = ca.leaf_for("b.ssh-browser").expect("another leaf");
let pin_one = spki_pin(&one.certificate_pem).expect("a pin");
let pin_two = spki_pin(&two.certificate_pem).expect("a pin");
let pin_ca = spki_pin(ca.certificate_pem()).expect("a pin");
assert_ne!(pin_one, pin_two);
assert_ne!(pin_one, pin_ca);
for pin in [&pin_one, &pin_two, &pin_ca] {
assert_eq!(pin.len(), 44, "{pin}");
assert!(pin.ends_with('='), "{pin}");
}
}
#[test]
fn days_since_the_epoch_become_the_right_date() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(1), (1970, 1, 2));
assert_eq!(civil_from_days(11017), (2000, 3, 1));
assert_eq!(civil_from_days(11016), (2000, 2, 29));
}
#[test]
fn the_authority_is_already_valid_and_the_leaf_expires_sooner() {
use x509_parser::prelude::*;
let read = |pem: &str| {
let (_, p) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).expect("PEM");
let (_, c) = X509Certificate::from_der(&p.contents).expect("X.509");
(
c.validity().not_before.timestamp(),
c.validity().not_after.timestamp(),
)
};
let now = i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after 1970")
.as_secs(),
)
.expect("a plausible clock");
let ca = Authority::create("ssh-browser").expect("an authority");
let (ca_from, ca_until) = read(ca.certificate_pem());
assert!(
ca_from < now,
"the authority is not valid yet: {ca_from} > {now}"
);
assert!(ca_until > now, "the authority has already expired");
let (leaf_from, leaf_until) = read(
&ca.leaf_for("alias.ssh-browser")
.expect("a leaf")
.certificate_pem,
);
assert!(leaf_from < now, "the leaf is not valid yet");
assert!(
leaf_until < ca_until,
"the leaf must not outlive the authority that signed it"
);
}
}