ssh_browser/tls/mod.rs
1//! A certificate authority that can only ever vouch for one suffix.
2//!
3//! The https mode needs a certificate the browser accepts for `<alias>.<suffix>`, and nobody
4//! will issue one: the suffix is not a real TLD and there is no way to prove control of it. So
5//! the daemon makes its own authority, and the whole question is how much damage that authority
6//! could do if its key leaked.
7//!
8//! A stock local CA — what `mkcert` installs — could impersonate any site on the internet. The
9//! key sits in a file on a laptop, and trusting it means trusting that file more than the web
10//! PKI. This one carries `nameConstraints` with a single permitted subtree, the configured
11//! suffix, so a leaked key can mint certificates for `*.ssh-browser` and for nothing else.
12//! RFC 5280 §4.2.1.10 requires that extension to be marked critical, which is what stops a
13//! conforming verifier from quietly ignoring it.
14//!
15//! Two more limits, for the same reason:
16//!
17//! - `basicConstraints` carries `pathLenConstraint: 0`, so this authority cannot sign another
18//! authority. Without it a leaked key could mint an intermediate; the name constraint would
19//! still hold, but the blast radius would grow to whatever that intermediate signed.
20//! - `keyUsage` is `keyCertSign` and `crlSign` only, so the key cannot serve TLS itself.
21//!
22//! Every one of those three is read back out of the encoded certificate by `x509-parser`, which
23//! is not the code that wrote it. Asserting against `rcgen`'s own view would only say that the
24//! builder remembered what it was told; a browser reads bytes, so the tests read bytes.
25//!
26//! **And the constraint is enforced, measured rather than assumed.** A name constraint is worth
27//! exactly what the verifier reading it chooses to do, and "required by the RFC" and "honoured
28//! by the browser on your desk" are different claims. With a CA of this shape in the current
29//! user's root store, two leaves signed by it, and a browser that was not told to ignore
30//! certificate errors:
31//!
32//! | | `openssl s_client` | Chromium |
33//! | --- | --- | --- |
34//! | a name under the suffix | `Verify return code: 0 (ok)` | loaded, `isSecureContext: true` |
35//! | `evil.example` | `47 (permitted subtree violation)` | refused, `net::ERR_CERT_INVALID` |
36//!
37//! And what the mode is *for* is measured too. Through the daemon, against a real SSH host, an
38//! https alias origin reports `isSecureContext: true` with `navigator.serviceWorker`,
39//! `crypto.subtle` and `caches` all present — the three things an http alias origin does not
40//! have, and the reason this exists.
41//!
42//! The same run is what ruled out a wildcard certificate: see `Authority::leaf_for`.
43//!
44//! Firefox and Safari are unmeasured. Firefox keeps its own store and does not read the system
45//! one, which `trust_instructions` says; whether it honours the constraint is the same question
46//! again, and not one to answer by assuming.
47//!
48//! **The daemon never installs this.** Putting a root into a trust store changes how the whole
49//! machine treats the internet, is not undone by uninstalling a Rust binary, and is not a
50//! decision a background process should make. `ssh-browser trust` prints the command for the
51//! platform and stops; running it is the reader's, with the command in front of them.
52
53use std::path::{Path, PathBuf};
54
55use anyhow::{Context, Result, bail, ensure};
56use rcgen::{
57 BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa, Issuer,
58 KeyPair, KeyUsagePurpose, NameConstraints, SanType, date_time_ymd,
59};
60
61/// How long the authority is good for.
62///
63/// Ten years, because re-trusting a root is a manual step with an alarming dialog in front of
64/// it, and making somebody repeat it yearly is how they learn to click through such dialogs
65/// without reading them. The serving certificate is short-lived instead, which is where a short
66/// lifetime actually buys something.
67const AUTHORITY_DAYS: i64 = 3650;
68
69/// How long a serving certificate is good for.
70///
71/// Reissued from the authority whenever it has expired, which costs no interaction at all — so
72/// this can be short without being a nuisance.
73const LEAF_DAYS: i64 = 90;
74
75/// The stem of every authority's name.
76///
77/// Not the whole name: see `common_name`. Kept separate so that a reader scanning a trust store
78/// can recognise the family, and so the two places that build the full name agree.
79pub const AUTHORITY_NAME: &str = "ssh-browser local CA";
80
81/// The exact common name of the authority for `suffix`.
82///
83/// **The uninstall command needs this and not the stem.** `certutil -delstore -user Root
84/// "ssh-browser local CA"` reports success and deletes nothing, because the stored name is
85/// `ssh-browser local CA (ssh-browser)`. Found by running the instructions this module prints
86/// and then checking the store: it said the command completed, and the root was still trusted.
87/// An uninstall that claims to have removed a root it has not removed is the worst failure
88/// available here.
89pub fn common_name(suffix: &str) -> String {
90 format!("{AUTHORITY_NAME} ({suffix})")
91}
92
93/// The authority's certificate and the key that signs with it.
94pub struct Authority {
95 issuer: Issuer<'static, KeyPair>,
96 certificate_pem: String,
97 suffix: String,
98}
99
100impl Authority {
101 /// Create an authority permitted to vouch for `suffix` and nothing else.
102 pub fn create(suffix: &str) -> Result<Self> {
103 // The same rule the PAC and every alias label are held to, asked rather than restated.
104 // A suffix that cannot be a hostname produces a constraint no verifier can match, and
105 // the failure arrives as a TLS error nowhere near its cause.
106 ensure!(
107 crate::origin::pac::is_suffix(suffix),
108 "suffix {suffix:?} cannot go in a certificate: it must be lowercase letters, digits, hyphens and dots"
109 );
110
111 let mut params = CertificateParams::default();
112
113 // Named for what it is and what it is limited to, because this string is what somebody
114 // reads in a trust-store list a year from now while deciding whether to remove it.
115 // The bare product name would not say which suffix it covers.
116 let mut name = DistinguishedName::new();
117 name.push(DnType::CommonName, common_name(suffix));
118 name.push(DnType::OrganizationName, "ssh-browser");
119 params.distinguished_name = name;
120
121 params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
122 params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
123
124 // The whole point of the module. RFC 5280's DNS rule is that a constraint is satisfied
125 // by adding zero or more labels on the left, so `ssh-browser` permits `ssh-browser`
126 // itself and `alias.ssh-browser`, and permits nothing else at all.
127 //
128 // Written without a leading dot deliberately. The dotted form is a convention some
129 // implementations accept and the RFC does not describe, and a constraint another
130 // verifier reads as "nothing is permitted" would be a certificate that works here and
131 // fails on somebody else's machine.
132 params.name_constraints = Some(NameConstraints {
133 permitted_subtrees: vec![GeneralSubtree::DnsName(suffix.to_string())],
134 excluded_subtrees: Vec::new(),
135 });
136
137 set_validity(&mut params, AUTHORITY_DAYS)?;
138
139 let key = KeyPair::generate().context("generating a key for the local authority")?;
140 let certificate_pem = params
141 .self_signed(&key)
142 .context("signing the local authority")?
143 .pem();
144 Ok(Self {
145 issuer: Issuer::new(params, key),
146 certificate_pem,
147 suffix: suffix.to_string(),
148 })
149 }
150
151 /// The authority's certificate, as PEM. The half that is safe to hand out.
152 pub fn certificate_pem(&self) -> &str {
153 &self.certificate_pem
154 }
155
156 pub fn suffix(&self) -> &str {
157 &self.suffix
158 }
159
160 /// A certificate for one name under the suffix.
161 ///
162 /// One concrete name, **not** a wildcard, and that is a measured decision rather than a
163 /// preference. `*.ssh-browser` is refused by Chromium with `ERR_CERT_COMMON_NAME_INVALID`:
164 /// the suffix is not a known registry, so a wildcard directly beneath it reads as one
165 /// spanning an entire top-level domain, which no browser will accept. A certificate naming
166 /// `e2e.ssh-browser` outright, from the same authority, loads — with `isSecureContext`,
167 /// service workers and `crypto.subtle` all present, which is the whole point of the mode.
168 ///
169 /// So there is one certificate per alias, minted when a handshake first asks for that name.
170 pub fn leaf_for(&self, name: &str) -> Result<Leaf> {
171 // The label is held to `guard::is_label`, the same function that decides whether an
172 // arriving request's label is acceptable — rather than a third copy of the rule here.
173 // Without it `*.ssh-browser` satisfies "one label, no dots" and gets signed, which is
174 // precisely the certificate a browser refuses.
175 ensure!(
176 name == self.suffix
177 || name
178 .strip_suffix(&self.suffix)
179 .and_then(|head| head.strip_suffix('.'))
180 .is_some_and(crate::origin::guard::is_label),
181 "{name:?} is not a single label under {:?}, so this authority cannot vouch for it",
182 self.suffix
183 );
184 self.leaf_named(&[name])
185 }
186
187 /// Sign a certificate for whatever names are asked for.
188 ///
189 /// Takes the names rather than deriving them, so that a test can ask for a name *outside*
190 /// the constraint and check that an independent verifier refuses it. That is the only way to
191 /// test the claim this module makes: the constraint is enforced by whoever validates the
192 /// chain, not by the code that writes it, so a signer that happily produces such a
193 /// certificate is expected — being refused downstream is the property.
194 fn leaf_named(&self, names: &[&str]) -> Result<Leaf> {
195 let first = names
196 .first()
197 .context("a certificate needs at least one name")?;
198
199 let mut params = CertificateParams::default();
200 let mut subject = DistinguishedName::new();
201 subject.push(DnType::CommonName, (*first).to_string());
202 params.distinguished_name = subject;
203 params.subject_alt_names = names
204 .iter()
205 .map(|name| {
206 Ok(SanType::DnsName(
207 (*name)
208 .to_string()
209 .try_into()
210 .with_context(|| format!("{name:?} is not a valid DNS name"))?,
211 ))
212 })
213 .collect::<Result<Vec<_>>>()?;
214 params.use_authority_key_identifier_extension = true;
215
216 set_validity(&mut params, LEAF_DAYS)?;
217
218 let key = KeyPair::generate().context("generating a key for the serving certificate")?;
219 let cert = params
220 .signed_by(&key, &self.issuer)
221 .context("signing the serving certificate")?;
222 Ok(Leaf {
223 certificate_pem: cert.pem(),
224 key_pem: key.serialize_pem(),
225 })
226 }
227}
228
229/// A serving certificate and its key, both as PEM.
230pub struct Leaf {
231 pub certificate_pem: String,
232 pub key_pem: String,
233}
234
235/// `not_before` a day ago, `not_after` `days` from now.
236///
237/// Backdated because a certificate stamped with this instant is not yet valid on a machine whose
238/// clock is a minute behind, and that failure arrives as a TLS error with nothing in it to
239/// suggest a clock. `date_time_ymd` takes whole days, so a day is the smallest slack available.
240/// Assigned into the params rather than returned, so the date type never has to be named here.
241/// It belongs to `rcgen`'s own `time` dependency, and taking a direct dependency on that crate
242/// to write one signature would be a dependency for a type name.
243fn set_validity(params: &mut CertificateParams, days: i64) -> Result<()> {
244 use std::time::{SystemTime, UNIX_EPOCH};
245
246 let now = i64::try_from(
247 SystemTime::now()
248 .duration_since(UNIX_EPOCH)
249 .context("the system clock is before 1970")?
250 .as_secs(),
251 )
252 .context("the system clock is implausibly far in the future")?;
253 let today = now / 86_400;
254
255 let (y, m, d) = civil_from_days(today - 1);
256 params.not_before = date_time_ymd(y, m, d);
257 let (y, m, d) = civil_from_days(today + days);
258 params.not_after = date_time_ymd(y, m, d);
259 Ok(())
260}
261
262/// Howard Hinnant's `civil_from_days`, for days since 1970-01-01.
263fn civil_from_days(z: i64) -> (i32, u8, u8) {
264 let z = z + 719_468;
265 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
266 let doe = u64::try_from(z - era * 146_097).unwrap_or(0);
267 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
268 let y = i64::try_from(yoe).unwrap_or(0) + era * 400;
269 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
270 let mp = (5 * doy + 2) / 153;
271 let d = u8::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1);
272 let m = u8::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
273 (
274 i32::try_from(if m <= 2 { y + 1 } else { y }).unwrap_or(1970),
275 m,
276 d,
277 )
278}
279
280/// What the encoded certificate says about how far it may reach.
281///
282/// Read with `x509-parser` rather than with `rcgen`, deliberately. The point of checking is that
283/// the bytes carry the limits, and asking the library that wrote them would only establish that
284/// it remembered its own input.
285#[derive(Debug, PartialEq, Eq)]
286pub struct Limits {
287 /// Permitted DNS subtrees, in the order the certificate lists them.
288 pub permitted: Vec<String>,
289 /// Excluded DNS subtrees. Expected to be empty: this design permits, it does not exclude.
290 pub excluded: Vec<String>,
291 /// Whether `nameConstraints` is marked critical, which RFC 5280 requires and which is what
292 /// stops a verifier from skipping it.
293 pub constraints_critical: bool,
294 /// `pathLenConstraint`, if `basicConstraints` gives one. `Some(0)` means it cannot sign
295 /// another authority.
296 pub path_len: Option<u32>,
297 pub is_ca: bool,
298 /// Whether `keyUsage` allows anything beyond signing certificates and CRLs.
299 pub signs_only_certificates: bool,
300}
301
302/// Read the limits out of a PEM certificate.
303pub fn limits_of(certificate_pem: &str) -> Result<Limits> {
304 use x509_parser::extensions::{GeneralName, ParsedExtension};
305 use x509_parser::prelude::*;
306
307 let (_, pem) = x509_parser::pem::parse_x509_pem(certificate_pem.as_bytes())
308 .context("the certificate is not PEM")?;
309 let (_, cert) =
310 X509Certificate::from_der(&pem.contents).context("the certificate is not X.509")?;
311
312 let mut limits = Limits {
313 permitted: Vec::new(),
314 excluded: Vec::new(),
315 constraints_critical: false,
316 path_len: None,
317 is_ca: false,
318 signs_only_certificates: false,
319 };
320
321 for ext in cert.extensions() {
322 match ext.parsed_extension() {
323 ParsedExtension::NameConstraints(nc) => {
324 limits.constraints_critical = ext.critical;
325 // Only DNS subtrees are collected. A constraint on some other name form is not
326 // something this design writes, and silently counting it as a DNS permission
327 // would make a certificate look narrower than it is.
328 for tree in nc.permitted_subtrees.iter().flatten() {
329 if let GeneralName::DNSName(name) = tree.base {
330 limits.permitted.push(name.to_string());
331 }
332 }
333 for tree in nc.excluded_subtrees.iter().flatten() {
334 if let GeneralName::DNSName(name) = tree.base {
335 limits.excluded.push(name.to_string());
336 }
337 }
338 }
339 ParsedExtension::BasicConstraints(bc) => {
340 limits.is_ca = bc.ca;
341 limits.path_len = bc.path_len_constraint;
342 }
343 ParsedExtension::KeyUsage(ku) => {
344 limits.signs_only_certificates = ku.key_cert_sign()
345 && !ku.digital_signature()
346 && !ku.key_encipherment()
347 && !ku.key_agreement()
348 && !ku.data_encipherment();
349 }
350 _ => {}
351 }
352 }
353 Ok(limits)
354}
355
356/// Is this certificate an authority that can vouch for `suffix` and nothing else?
357///
358/// Every clause is a separate way the answer could be yes when it should be no, so they are
359/// written out rather than folded into one expression: no constraint at all, a constraint on a
360/// different suffix, a second permitted subtree beside the right one, an exclusion that changes
361/// what the permission means, a constraint a verifier may skip because it is not critical, or an
362/// authority that can sign a further authority.
363pub fn permits_only(certificate_pem: &str, suffix: &str) -> bool {
364 let Ok(limits) = limits_of(certificate_pem) else {
365 return false;
366 };
367 limits.permitted == [suffix]
368 && limits.excluded.is_empty()
369 && limits.constraints_critical
370 && limits.is_ca
371 && limits.path_len == Some(0)
372 && limits.signs_only_certificates
373}
374
375/// Base64 of the SHA-256 of a certificate's `SubjectPublicKeyInfo`.
376///
377/// The form a browser wants for a one-launch key pin: Chromium's
378/// `--ignore-certificate-errors-spki-list` takes exactly this. Computed here rather than left to
379/// the caller so that nobody has to know that it is the *public key info* being hashed and not
380/// the certificate — which is the mistake that makes a pin silently never match.
381///
382/// Measured, and the measurement corrected a guess: pinning the *authority's* key does not work,
383/// because Chromium compares against the certificate it was actually served. The leaf's pin does.
384pub fn spki_pin(certificate_pem: &str) -> Result<String> {
385 use x509_parser::prelude::*;
386
387 let (_, pem) = x509_parser::pem::parse_x509_pem(certificate_pem.as_bytes())
388 .context("the certificate is not PEM")?;
389 let (_, cert) =
390 X509Certificate::from_der(&pem.contents).context("the certificate is not X.509")?;
391 let spki = cert.tbs_certificate.subject_pki.raw;
392 let digest = ring::digest::digest(&ring::digest::SHA256, spki);
393 Ok(base64(digest.as_ref()))
394}
395
396/// Standard base64, which is what the browser flag expects.
397///
398/// Hand-rolled because the alternative is a dependency for twelve lines, and the alphabet is
399/// fixed by RFC 4648 rather than being a thing to get opinions about.
400fn base64(bytes: &[u8]) -> String {
401 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
402 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
403 for chunk in bytes.chunks(3) {
404 let b = [
405 chunk[0],
406 chunk.get(1).copied().unwrap_or(0),
407 chunk.get(2).copied().unwrap_or(0),
408 ];
409 let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
410 out.push(char::from(ALPHABET[(n >> 18) as usize & 63]));
411 out.push(char::from(ALPHABET[(n >> 12) as usize & 63]));
412 out.push(if chunk.len() > 1 {
413 char::from(ALPHABET[(n >> 6) as usize & 63])
414 } else {
415 '='
416 });
417 out.push(if chunk.len() > 2 {
418 char::from(ALPHABET[n as usize & 63])
419 } else {
420 '='
421 });
422 }
423 out
424}
425
426/// Where the authority lives between runs.
427///
428/// Beside the control token, so it is under the same directory and the same permissions. Not in
429/// the configuration directory: a key is state a reader may delete to start again, and
430/// configuration is something they wrote and expect to keep.
431pub fn authority_dir() -> Option<PathBuf> {
432 Some(crate::control::state_dir()?.join("ca"))
433}
434
435/// Where the certificate to be trusted goes. Named so `ssh-browser trust` can print it without
436/// creating an authority.
437pub fn certificate_path() -> Option<PathBuf> {
438 Some(authority_dir()?.join("authority.pem"))
439}
440
441/// Whether the authority was already there, which decides how loudly to say what to do next.
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum Found {
444 /// Read back from a previous run. It may or may not still be trusted; nothing portable can
445 /// tell, so the caller reminds rather than instructs.
446 Existing,
447 /// Made just now, so it is certainly not trusted yet and the reader has a step to take
448 /// before anything will load.
449 Created,
450}
451
452/// Load the authority for `suffix`, or make one and write it down.
453pub fn load_or_create(suffix: &str) -> Result<Authority> {
454 Ok(load_or_create_reporting(suffix)?.0)
455}
456
457/// The same, saying which of the two happened.
458pub fn load_or_create_reporting(suffix: &str) -> Result<(Authority, Found)> {
459 let Some(dir) = authority_dir() else {
460 bail!("no state directory to keep a local certificate authority in");
461 };
462 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
463
464 let key_path = dir.join("authority.key");
465 let cert_path = dir.join("authority.pem");
466
467 if let Some(found) = load(&key_path, &cert_path, suffix) {
468 return Ok((found, Found::Existing));
469 }
470
471 let authority = Authority::create(suffix)?;
472 // The key through `write_private`, which creates it with the permissions already set. A
473 // private key another account can read is the one thing that makes all of the above
474 // pointless.
475 crate::control::write_private(&key_path, authority.issuer.key().serialize_pem().as_bytes())
476 .with_context(|| format!("writing {}", key_path.display()))?;
477 std::fs::write(&cert_path, authority.certificate_pem())
478 .with_context(|| format!("writing {}", cert_path.display()))?;
479 Ok((authority, Found::Created))
480}
481
482/// An authority already on disk, if there is one and it is for this suffix.
483///
484/// Every failure returns `None` and says why, rather than stopping the daemon: a damaged file is
485/// a reason to make a new authority, not a reason to serve nothing. It is *said* because the old
486/// certificate is in a trust store and the new one is not, so the reader has a step to repeat
487/// and no other way to learn that.
488fn load(key_path: &Path, cert_path: &Path, suffix: &str) -> Option<Authority> {
489 let (Ok(key_pem), Ok(certificate_pem)) = (
490 std::fs::read_to_string(key_path),
491 std::fs::read_to_string(cert_path),
492 ) else {
493 return None;
494 };
495
496 let key = match KeyPair::from_pem(&key_pem) {
497 Ok(key) => key,
498 Err(e) => {
499 eprintln!(" the stored authority key could not be read ({e}); making a new one");
500 return None;
501 }
502 };
503
504 // Checked before it is used, and checked against the bytes. An authority that is not
505 // constrained to this suffix cannot work — a verifier rejects what it signs — and serving
506 // from it would produce a TLS error out of a root the reader has already trusted, which is
507 // the least debuggable shape available.
508 if !permits_only(&certificate_pem, suffix) {
509 eprintln!(" the stored authority is not an authority constrained to {suffix:?} alone;");
510 eprintln!(" making one that is. The old certificate can be removed from your trust");
511 eprintln!(" store: see `ssh-browser trust`.");
512 return None;
513 }
514
515 match Issuer::from_ca_cert_pem(&certificate_pem, key) {
516 Ok(issuer) => Some(Authority {
517 issuer,
518 certificate_pem,
519 suffix: suffix.to_string(),
520 }),
521 Err(e) => {
522 eprintln!(" the stored authority could not be loaded ({e}); making a new one");
523 None
524 }
525 }
526}
527
528/// Which trust store the instructions are for.
529///
530/// A parameter rather than a `cfg!`, so all three can be checked on one machine. They were
531/// `cfg!` branches, and the branch nobody ran locally was the one that turned out to be wrong:
532/// the Linux text never named the authority, so the test asserting that it did passed on Windows
533/// and failed in CI. A platform-specific string that only its own platform can test is a string
534/// nobody tests.
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536pub enum Store {
537 /// The current account's root store. Needs no elevation.
538 Windows,
539 /// The login keychain. Prompts for a password.
540 MacOs,
541 /// The system anchors, wherever the distribution puts them — and Firefox, which keeps its
542 /// own and does not read them.
543 Other,
544}
545
546impl Store {
547 /// The one this daemon is running on.
548 pub fn here() -> Self {
549 if cfg!(windows) {
550 Self::Windows
551 } else if cfg!(target_os = "macos") {
552 Self::MacOs
553 } else {
554 Self::Other
555 }
556 }
557}
558
559/// What to run to trust this authority, for the platform this is running on.
560pub fn trust_instructions(suffix: &str, cert_path: &Path) -> String {
561 instructions_for(Store::here(), suffix, cert_path)
562}
563
564/// Printed, never executed. The three differ in more than spelling: the Windows one needs no
565/// elevation and writes to this account only, the macOS one prompts for a password and writes to
566/// the login keychain, and on Linux the location depends on the distribution while Firefox keeps
567/// its own store regardless. Guessing wrong while running as somebody's shell is not a thing to
568/// do quietly.
569pub fn instructions_for(store: Store, suffix: &str, cert_path: &Path) -> String {
570 let path = cert_path.display();
571 // The full name, because the stem alone silently removes nothing.
572 let name = common_name(suffix);
573 let preamble = format!(
574 "The certificate to trust is\n {path}\n\n\
575 It is an authority constrained to one suffix: if its key leaks, it can vouch for that\n\
576 suffix and nothing else. Nothing here installs it — the command below is yours to run,\n\
577 and the one after it undoes this.\n\n"
578 );
579 match store {
580 Store::Windows => format!(
581 "{preamble}Trust it for this account only, no administrator rights needed:\n\
582 \x20 certutil -addstore -user Root \"{path}\"\n\n\
583 Undo:\n\
584 \x20 certutil -delstore -user Root \"{name}\"\n\n\
585 Check what is there:\n\
586 \x20 certutil -store -user Root | findstr /C:\"{name}\"\n"
587 ),
588 Store::MacOs => format!(
589 "{preamble}Trust it in your login keychain (it will ask for your password):\n\
590 \x20 security add-trusted-cert -k ~/Library/Keychains/login.keychain-db \"{path}\"\n\n\
591 Undo:\n\
592 \x20 security delete-certificate -c \"{name}\" ~/Library/Keychains/login.keychain-db\n"
593 ),
594 Store::Other => format!(
595 "{preamble}Where this goes depends on the distribution. On Debian and Ubuntu:\n\
596 \x20 sudo cp \"{path}\" /usr/local/share/ca-certificates/ssh-browser.crt\n\
597 \x20 sudo update-ca-certificates\n\n\
598 Undo:\n\
599 \x20 sudo rm /usr/local/share/ca-certificates/ssh-browser.crt\n\
600 \x20 sudo update-ca-certificates --fresh\n\n\
601 Firefox keeps its own store and does not read that one. Import it under Settings,\n\
602 Privacy & Security, Certificates, View Certificates, Authorities, Import — and to\n\
603 remove it again, find \"{name}\" in that same list and delete it.\n"
604 ),
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 /// Every limit, read out of the encoded certificate by a parser that did not write it.
613 ///
614 /// One test for all of them because they are one claim: this authority reaches exactly as
615 /// far as the suffix and no further. Splitting it would let three of the four pass while the
616 /// fourth silently regressed.
617 #[test]
618 fn the_authority_reaches_exactly_its_suffix_and_no_further() {
619 let ca = Authority::create("ssh-browser").expect("an authority");
620 let limits = limits_of(ca.certificate_pem()).expect("its own output parses");
621
622 assert_eq!(limits.permitted, ["ssh-browser"], "{limits:?}");
623 assert!(limits.excluded.is_empty(), "{limits:?}");
624 assert!(
625 limits.constraints_critical,
626 "a name constraint that is not critical may be skipped by a verifier: {limits:?}"
627 );
628 assert!(limits.is_ca, "{limits:?}");
629 assert_eq!(
630 limits.path_len,
631 Some(0),
632 "without pathLen 0 a leaked key can mint an intermediate: {limits:?}"
633 );
634 assert!(
635 limits.signs_only_certificates,
636 "the authority key must not be usable to serve TLS: {limits:?}"
637 );
638 }
639
640 /// And the same certificate does not read as permitting a different suffix.
641 ///
642 /// The neutering check for the one above: a `permits_only` that ignored its argument would
643 /// pass every assertion there.
644 #[test]
645 fn an_authority_for_one_suffix_does_not_permit_another() {
646 let ca = Authority::create("dev").expect("an authority");
647 assert!(permits_only(ca.certificate_pem(), "dev"));
648 assert!(!permits_only(ca.certificate_pem(), "ssh-browser"));
649 assert!(!permits_only(ca.certificate_pem(), "de"));
650 assert!(!permits_only(ca.certificate_pem(), ""));
651 }
652
653 /// An authority with no constraint at all is refused, not adopted.
654 ///
655 /// This is the shape a stock local CA has — `mkcert`'s — and the one thing this module
656 /// exists to avoid. If such a file appeared in the state directory, by hand or from a
657 /// `mkcert` run pointed there, adopting it would mean serving from a root that can
658 /// impersonate anything.
659 #[test]
660 fn an_unconstrained_authority_is_not_adopted() {
661 let mut params = CertificateParams::default();
662 params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
663 params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
664 let key = KeyPair::generate().expect("a key");
665 let pem = params.self_signed(&key).expect("self signed").pem();
666
667 let limits = limits_of(&pem).expect("parses");
668 assert!(limits.permitted.is_empty(), "{limits:?}");
669 assert_eq!(limits.path_len, None, "{limits:?}");
670 assert!(
671 !permits_only(&pem, "ssh-browser"),
672 "an unconstrained authority must never be treated as constrained"
673 );
674 }
675
676 /// An authority that permits a second subtree is refused too.
677 ///
678 /// Narrower than the case above and more likely: somebody edits the file, or an older
679 /// version of this wrote two. Permitting `ssh-browser` *and* something else is not the
680 /// promise the trust decision was made against.
681 #[test]
682 fn a_second_permitted_subtree_is_refused() {
683 let mut params = CertificateParams::default();
684 params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
685 params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
686 params.name_constraints = Some(NameConstraints {
687 permitted_subtrees: vec![
688 GeneralSubtree::DnsName("ssh-browser".to_string()),
689 GeneralSubtree::DnsName("example.com".to_string()),
690 ],
691 excluded_subtrees: Vec::new(),
692 });
693 let key = KeyPair::generate().expect("a key");
694 let pem = params.self_signed(&key).expect("self signed").pem();
695
696 assert_eq!(
697 limits_of(&pem).expect("parses").permitted,
698 ["ssh-browser", "example.com"]
699 );
700 assert!(!permits_only(&pem, "ssh-browser"));
701 }
702
703 /// A suffix that cannot be a hostname cannot go in a certificate either.
704 #[test]
705 fn a_suffix_that_is_not_a_hostname_is_refused() {
706 for bad in ["Has Caps", "with space", "with/slash", "", "under_score"] {
707 assert!(
708 Authority::create(bad).is_err(),
709 "{bad:?} should not have produced an authority"
710 );
711 }
712 }
713
714 /// Names in a serving certificate, out of the encoded bytes.
715 fn names_in(certificate_pem: &str) -> Vec<String> {
716 use x509_parser::prelude::*;
717
718 let (_, pem) =
719 x509_parser::pem::parse_x509_pem(certificate_pem.as_bytes()).expect("the leaf is PEM");
720 let (_, cert) = X509Certificate::from_der(&pem.contents).expect("the leaf is X.509");
721 cert.subject_alternative_name()
722 .ok()
723 .flatten()
724 .map(|san| {
725 san.value
726 .general_names
727 .iter()
728 .filter_map(|n| match n {
729 x509_parser::extensions::GeneralName::DNSName(d) => Some(d.to_string()),
730 _ => None,
731 })
732 .collect()
733 })
734 .unwrap_or_default()
735 }
736
737 /// The serving certificate names one alias outright, and is not an authority.
738 ///
739 /// **Not a wildcard, and that is measured rather than preferred.** `*.ssh-browser` is refused
740 /// by Chromium with `ERR_CERT_COMMON_NAME_INVALID`: the suffix is not a known registry, so a
741 /// wildcard directly beneath it reads as one covering an entire top-level domain. The same
742 /// authority signing `e2e.ssh-browser` outright loads, with `isSecureContext`, service
743 /// workers and `crypto.subtle` all present — which is the entire point of the https mode.
744 ///
745 /// So this asserts the wildcard is *absent*. A later change back to one would look tidier
746 /// and would break every https page.
747 #[test]
748 fn the_leaf_names_one_alias_and_is_not_itself_an_authority() {
749 let ca = Authority::create("ssh-browser").expect("an authority");
750 let leaf = ca.leaf_for("alias.ssh-browser").expect("a leaf");
751 assert!(leaf.key_pem.contains("PRIVATE KEY"));
752
753 let names = names_in(&leaf.certificate_pem);
754 assert_eq!(names, ["alias.ssh-browser"], "{names:?}");
755 assert!(
756 !names.iter().any(|n| n.starts_with('*')),
757 "a wildcard under a suffix that is not a real registry is refused by browsers: \
758 {names:?}"
759 );
760
761 // A serving certificate that was also an authority could sign for the whole suffix, and
762 // it is handed to whatever terminates TLS.
763 assert!(
764 !limits_of(&leaf.certificate_pem).expect("parses").is_ca,
765 "the serving certificate must not be a CA"
766 );
767 }
768
769 /// And the authority refuses to vouch for anything that is not one label under its suffix.
770 ///
771 /// Refused here rather than left to the name constraint. The constraint would stop it too,
772 /// at the verifier — but failing here means the certificate is never signed at all, and a
773 /// signature that was never produced cannot be misread by anything.
774 #[test]
775 fn the_authority_signs_only_a_single_label_under_its_suffix() {
776 let ca = Authority::create("ssh-browser").expect("an authority");
777
778 assert!(ca.leaf_for("alias.ssh-browser").is_ok());
779 // The bare suffix is served too: it is the index of what is open.
780 assert!(ca.leaf_for("ssh-browser").is_ok());
781
782 for bad in [
783 "evil.example",
784 "deep.nested.ssh-browser",
785 ".ssh-browser",
786 "ssh-browser.evil.example",
787 "*.ssh-browser",
788 "",
789 ] {
790 assert!(
791 ca.leaf_for(bad).is_err(),
792 "{bad:?} should not have been signed"
793 );
794 }
795 }
796
797 /// Every platform's instructions name the file, name the authority, and say how to undo it.
798 ///
799 /// All three on whichever machine runs this, which is the point. They used to be `cfg!`
800 /// branches and this test saw only one of them — so the Linux text, which never named the
801 /// authority, passed on Windows and failed in CI. A platform-specific string only its own
802 /// platform can test is a string nobody tests.
803 #[test]
804 fn every_platforms_instructions_say_what_to_install_and_how_to_undo_it() {
805 for store in [Store::Windows, Store::MacOs, Store::Other] {
806 let said = instructions_for(store, "ssh-browser", Path::new("/tmp/authority.pem"));
807 assert!(said.contains("authority.pem"), "{store:?}: {said}");
808 assert!(
809 said.contains("Undo:"),
810 "{store:?}: telling somebody to install a root without saying how to remove it \
811 is half an instruction: {said}"
812 );
813 // The name is how they find it again in a list months later, when the path this
814 // printed is long forgotten.
815 assert!(
816 said.contains(&common_name("ssh-browser")),
817 "{store:?}: nothing names the authority, so it cannot be found to remove: {said}"
818 );
819 }
820 }
821
822 /// And the platform this is running on gets its own instructions, not somebody else's.
823 ///
824 /// Without this, `Store::here()` could return one constant and every assertion above would
825 /// still pass.
826 #[test]
827 fn the_instructions_printed_here_are_for_this_platform() {
828 let said = trust_instructions("ssh-browser", Path::new("/tmp/authority.pem"));
829 let expect = if cfg!(windows) {
830 "certutil"
831 } else if cfg!(target_os = "macos") {
832 "security add-trusted-cert"
833 } else {
834 "update-ca-certificates"
835 };
836 assert!(
837 said.contains(expect),
838 "expected {expect:?} for this platform: {said}"
839 );
840 }
841
842 /// An independent verifier refuses a name outside the constraint.
843 ///
844 /// This is the only test here that checks the *claim* rather than the encoding. Everything
845 /// above establishes that the certificate says what it should; a name constraint is enforced
846 /// by whoever validates the chain, so a signer that happily mints `evil.example` is expected
847 /// and being refused downstream is the whole property.
848 ///
849 /// `openssl verify` is the verifier because it is a third implementation — not `rcgen` which
850 /// wrote the bytes, and not `x509-parser` which read them back. It is on all three CI
851 /// runners. When it is absent the test says so rather than passing quietly, because a check
852 /// that silently does nothing is worse than one that is missing.
853 #[test]
854 fn an_independent_verifier_refuses_a_name_outside_the_constraint() {
855 use std::process::Command;
856
857 let Ok(version) = Command::new("openssl").arg("version").output() else {
858 println!(
859 " SKIPPED an_independent_verifier_refuses_a_name_outside_the_constraint: \
860 no openssl on PATH"
861 );
862 return;
863 };
864 assert!(
865 version.status.success(),
866 "openssl is on PATH but would not run"
867 );
868
869 let ca = Authority::create("ssh-browser").expect("an authority");
870 let inside = ca
871 .leaf_named(&["alias.ssh-browser"])
872 .expect("a name inside the constraint");
873 let outside = ca
874 .leaf_named(&["evil.example"])
875 .expect("the signer does not police this; the verifier does");
876
877 let dir = std::env::temp_dir().join(format!("ssh-browser-nc-{}", std::process::id()));
878 std::fs::create_dir_all(&dir).expect("a temporary directory");
879 let ca_path = dir.join("ca.pem");
880 let inside_path = dir.join("inside.pem");
881 let outside_path = dir.join("outside.pem");
882 std::fs::write(&ca_path, ca.certificate_pem()).expect("write the authority");
883 std::fs::write(&inside_path, &inside.certificate_pem).expect("write the good leaf");
884 std::fs::write(&outside_path, &outside.certificate_pem).expect("write the bad leaf");
885
886 let verify = |leaf: &Path| {
887 let out = Command::new("openssl")
888 .arg("verify")
889 .arg("-CAfile")
890 .arg(&ca_path)
891 .arg(leaf)
892 .output()
893 .expect("openssl verify runs");
894 let said = format!(
895 "{}{}",
896 String::from_utf8_lossy(&out.stdout),
897 String::from_utf8_lossy(&out.stderr)
898 );
899 (out.status.success(), said)
900 };
901
902 let (ok, said) = verify(&inside_path);
903 assert!(ok, "a name under the suffix should verify: {said}");
904
905 let (ok, said) = verify(&outside_path);
906 assert!(
907 !ok,
908 "openssl accepted a certificate for evil.example from an authority constrained to \
909 ssh-browser, which means the constraint is buying nothing: {said}"
910 );
911 // The reason, not just the refusal. A leaf rejected for an expired date or a bad
912 // signature would fail the assertion above while saying nothing about name constraints.
913 assert!(
914 said.to_lowercase().contains("subtree")
915 || said.to_lowercase().contains("name constraint")
916 || said.to_lowercase().contains("excluded"),
917 "refused, but not for the constraint -- so this test is not measuring it: {said}"
918 );
919
920 let _ = std::fs::remove_dir_all(&dir);
921 }
922
923 /// The base64 encoder, against RFC 4648's own test vectors.
924 ///
925 /// Hand-rolled twelve lines with a padding rule, which is exactly the shape of thing that is
926 /// wrong in the last two characters and looks right. The vectors are from the RFC so they are
927 /// not this implementation checked against itself.
928 #[test]
929 fn base64_matches_the_rfc_vectors() {
930 assert_eq!(base64(b""), "");
931 assert_eq!(base64(b"f"), "Zg==");
932 assert_eq!(base64(b"fo"), "Zm8=");
933 assert_eq!(base64(b"foo"), "Zm9v");
934 assert_eq!(base64(b"foob"), "Zm9vYg==");
935 assert_eq!(base64(b"fooba"), "Zm9vYmE=");
936 assert_eq!(base64(b"foobar"), "Zm9vYmFy");
937 // Every bit set, which is where an alphabet typo shows up.
938 assert_eq!(base64(&[0xff, 0xff, 0xff]), "////");
939 assert_eq!(base64(&[0xfb, 0xff, 0xbf]), "+/+/");
940 }
941
942 /// The pin is of the public key, not of the certificate.
943 ///
944 /// Two certificates for different names signed with the same key must pin the same, and a
945 /// different key must pin differently. Getting this wrong gives a pin that silently never
946 /// matches — which is how the first attempt at this failed: pinning the authority's key
947 /// instead of the served certificate's.
948 #[test]
949 fn the_pin_follows_the_key_and_not_the_certificate() {
950 let ca = Authority::create("ssh-browser").expect("an authority");
951 let one = ca.leaf_for("a.ssh-browser").expect("a leaf");
952 let two = ca.leaf_for("b.ssh-browser").expect("another leaf");
953
954 let pin_one = spki_pin(&one.certificate_pem).expect("a pin");
955 let pin_two = spki_pin(&two.certificate_pem).expect("a pin");
956 let pin_ca = spki_pin(ca.certificate_pem()).expect("a pin");
957
958 // Different keys, so different pins -- each leaf gets its own key.
959 assert_ne!(pin_one, pin_two);
960 // And neither is the authority's, which is the mistake that produced a pin the browser
961 // ignored.
962 assert_ne!(pin_one, pin_ca);
963 // Base64 of a SHA-256 is always 44 characters with one pad.
964 for pin in [&pin_one, &pin_two, &pin_ca] {
965 assert_eq!(pin.len(), 44, "{pin}");
966 assert!(pin.ends_with('='), "{pin}");
967 }
968 }
969
970 /// The date arithmetic, against a calendar rather than against itself.
971 #[test]
972 fn days_since_the_epoch_become_the_right_date() {
973 assert_eq!(civil_from_days(0), (1970, 1, 1));
974 assert_eq!(civil_from_days(1), (1970, 1, 2));
975 // 2000-03-01, just past a leap day in a year divisible by 400.
976 assert_eq!(civil_from_days(11017), (2000, 3, 1));
977 assert_eq!(civil_from_days(11016), (2000, 2, 29));
978 }
979
980 /// The certificate is valid now, and for about as long as it says.
981 ///
982 /// Backdating is the part worth pinning: without it a machine whose clock is a minute behind
983 /// gets a TLS error with nothing in it about clocks.
984 #[test]
985 fn the_authority_is_already_valid_and_the_leaf_expires_sooner() {
986 use x509_parser::prelude::*;
987
988 let read = |pem: &str| {
989 let (_, p) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).expect("PEM");
990 let (_, c) = X509Certificate::from_der(&p.contents).expect("X.509");
991 (
992 c.validity().not_before.timestamp(),
993 c.validity().not_after.timestamp(),
994 )
995 };
996 let now = i64::try_from(
997 std::time::SystemTime::now()
998 .duration_since(std::time::UNIX_EPOCH)
999 .expect("after 1970")
1000 .as_secs(),
1001 )
1002 .expect("a plausible clock");
1003
1004 let ca = Authority::create("ssh-browser").expect("an authority");
1005 let (ca_from, ca_until) = read(ca.certificate_pem());
1006 assert!(
1007 ca_from < now,
1008 "the authority is not valid yet: {ca_from} > {now}"
1009 );
1010 assert!(ca_until > now, "the authority has already expired");
1011
1012 let (leaf_from, leaf_until) = read(
1013 &ca.leaf_for("alias.ssh-browser")
1014 .expect("a leaf")
1015 .certificate_pem,
1016 );
1017 assert!(leaf_from < now, "the leaf is not valid yet");
1018 assert!(
1019 leaf_until < ca_until,
1020 "the leaf must not outlive the authority that signed it"
1021 );
1022 }
1023}