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/// Where the authority lives between runs.
376///
377/// Beside the control token, so it is under the same directory and the same permissions. Not in
378/// the configuration directory: a key is state a reader may delete to start again, and
379/// configuration is something they wrote and expect to keep.
380pub fn authority_dir() -> Option<PathBuf> {
381 Some(crate::control::state_dir()?.join("ca"))
382}
383
384/// Where the certificate to be trusted goes. Named so `ssh-browser trust` can print it without
385/// creating an authority.
386pub fn certificate_path() -> Option<PathBuf> {
387 Some(authority_dir()?.join("authority.pem"))
388}
389
390/// Whether the authority was already there, which decides how loudly to say what to do next.
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum Found {
393 /// Read back from a previous run. It may or may not still be trusted; nothing portable can
394 /// tell, so the caller reminds rather than instructs.
395 Existing,
396 /// Made just now, so it is certainly not trusted yet and the reader has a step to take
397 /// before anything will load.
398 Created,
399}
400
401/// Load the authority for `suffix`, or make one and write it down.
402pub fn load_or_create(suffix: &str) -> Result<Authority> {
403 Ok(load_or_create_reporting(suffix)?.0)
404}
405
406/// The same, saying which of the two happened.
407pub fn load_or_create_reporting(suffix: &str) -> Result<(Authority, Found)> {
408 let Some(dir) = authority_dir() else {
409 bail!("no state directory to keep a local certificate authority in");
410 };
411 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
412
413 let key_path = dir.join("authority.key");
414 let cert_path = dir.join("authority.pem");
415
416 if let Some(found) = load(&key_path, &cert_path, suffix) {
417 return Ok((found, Found::Existing));
418 }
419
420 let authority = Authority::create(suffix)?;
421 // The key through `write_private`, which creates it with the permissions already set. A
422 // private key another account can read is the one thing that makes all of the above
423 // pointless.
424 crate::control::write_private(&key_path, authority.issuer.key().serialize_pem().as_bytes())
425 .with_context(|| format!("writing {}", key_path.display()))?;
426 std::fs::write(&cert_path, authority.certificate_pem())
427 .with_context(|| format!("writing {}", cert_path.display()))?;
428 Ok((authority, Found::Created))
429}
430
431/// An authority already on disk, if there is one and it is for this suffix.
432///
433/// Every failure returns `None` and says why, rather than stopping the daemon: a damaged file is
434/// a reason to make a new authority, not a reason to serve nothing. It is *said* because the old
435/// certificate is in a trust store and the new one is not, so the reader has a step to repeat
436/// and no other way to learn that.
437fn load(key_path: &Path, cert_path: &Path, suffix: &str) -> Option<Authority> {
438 let (Ok(key_pem), Ok(certificate_pem)) = (
439 std::fs::read_to_string(key_path),
440 std::fs::read_to_string(cert_path),
441 ) else {
442 return None;
443 };
444
445 let key = match KeyPair::from_pem(&key_pem) {
446 Ok(key) => key,
447 Err(e) => {
448 eprintln!(" the stored authority key could not be read ({e}); making a new one");
449 return None;
450 }
451 };
452
453 // Checked before it is used, and checked against the bytes. An authority that is not
454 // constrained to this suffix cannot work — a verifier rejects what it signs — and serving
455 // from it would produce a TLS error out of a root the reader has already trusted, which is
456 // the least debuggable shape available.
457 if !permits_only(&certificate_pem, suffix) {
458 eprintln!(" the stored authority is not an authority constrained to {suffix:?} alone;");
459 eprintln!(" making one that is. The old certificate can be removed from your trust");
460 eprintln!(" store: see `ssh-browser trust`.");
461 return None;
462 }
463
464 match Issuer::from_ca_cert_pem(&certificate_pem, key) {
465 Ok(issuer) => Some(Authority {
466 issuer,
467 certificate_pem,
468 suffix: suffix.to_string(),
469 }),
470 Err(e) => {
471 eprintln!(" the stored authority could not be loaded ({e}); making a new one");
472 None
473 }
474 }
475}
476
477/// Which trust store the instructions are for.
478///
479/// A parameter rather than a `cfg!`, so all three can be checked on one machine. They were
480/// `cfg!` branches, and the branch nobody ran locally was the one that turned out to be wrong:
481/// the Linux text never named the authority, so the test asserting that it did passed on Windows
482/// and failed in CI. A platform-specific string that only its own platform can test is a string
483/// nobody tests.
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
485pub enum Store {
486 /// The current account's root store. Needs no elevation.
487 Windows,
488 /// The login keychain. Prompts for a password.
489 MacOs,
490 /// The system anchors, wherever the distribution puts them — and Firefox, which keeps its
491 /// own and does not read them.
492 Other,
493}
494
495impl Store {
496 /// The one this daemon is running on.
497 pub fn here() -> Self {
498 if cfg!(windows) {
499 Self::Windows
500 } else if cfg!(target_os = "macos") {
501 Self::MacOs
502 } else {
503 Self::Other
504 }
505 }
506}
507
508/// What to run to trust this authority, for the platform this is running on.
509pub fn trust_instructions(suffix: &str, cert_path: &Path) -> String {
510 instructions_for(Store::here(), suffix, cert_path)
511}
512
513/// Printed, never executed. The three differ in more than spelling: the Windows one needs no
514/// elevation and writes to this account only, the macOS one prompts for a password and writes to
515/// the login keychain, and on Linux the location depends on the distribution while Firefox keeps
516/// its own store regardless. Guessing wrong while running as somebody's shell is not a thing to
517/// do quietly.
518pub fn instructions_for(store: Store, suffix: &str, cert_path: &Path) -> String {
519 let path = cert_path.display();
520 // The full name, because the stem alone silently removes nothing.
521 let name = common_name(suffix);
522 let preamble = format!(
523 "The certificate to trust is\n {path}\n\n\
524 It is an authority constrained to one suffix: if its key leaks, it can vouch for that\n\
525 suffix and nothing else. Nothing here installs it — the command below is yours to run,\n\
526 and the one after it undoes this.\n\n"
527 );
528 match store {
529 Store::Windows => format!(
530 "{preamble}Trust it for this account only, no administrator rights needed:\n\
531 \x20 certutil -addstore -user Root \"{path}\"\n\n\
532 Undo:\n\
533 \x20 certutil -delstore -user Root \"{name}\"\n\n\
534 Check what is there:\n\
535 \x20 certutil -store -user Root | findstr /C:\"{name}\"\n"
536 ),
537 Store::MacOs => format!(
538 "{preamble}Trust it in your login keychain (it will ask for your password):\n\
539 \x20 security add-trusted-cert -k ~/Library/Keychains/login.keychain-db \"{path}\"\n\n\
540 Undo:\n\
541 \x20 security delete-certificate -c \"{name}\" ~/Library/Keychains/login.keychain-db\n"
542 ),
543 Store::Other => format!(
544 "{preamble}Where this goes depends on the distribution. On Debian and Ubuntu:\n\
545 \x20 sudo cp \"{path}\" /usr/local/share/ca-certificates/ssh-browser.crt\n\
546 \x20 sudo update-ca-certificates\n\n\
547 Undo:\n\
548 \x20 sudo rm /usr/local/share/ca-certificates/ssh-browser.crt\n\
549 \x20 sudo update-ca-certificates --fresh\n\n\
550 Firefox keeps its own store and does not read that one. Import it under Settings,\n\
551 Privacy & Security, Certificates, View Certificates, Authorities, Import — and to\n\
552 remove it again, find \"{name}\" in that same list and delete it.\n"
553 ),
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 /// Every limit, read out of the encoded certificate by a parser that did not write it.
562 ///
563 /// One test for all of them because they are one claim: this authority reaches exactly as
564 /// far as the suffix and no further. Splitting it would let three of the four pass while the
565 /// fourth silently regressed.
566 #[test]
567 fn the_authority_reaches_exactly_its_suffix_and_no_further() {
568 let ca = Authority::create("ssh-browser").expect("an authority");
569 let limits = limits_of(ca.certificate_pem()).expect("its own output parses");
570
571 assert_eq!(limits.permitted, ["ssh-browser"], "{limits:?}");
572 assert!(limits.excluded.is_empty(), "{limits:?}");
573 assert!(
574 limits.constraints_critical,
575 "a name constraint that is not critical may be skipped by a verifier: {limits:?}"
576 );
577 assert!(limits.is_ca, "{limits:?}");
578 assert_eq!(
579 limits.path_len,
580 Some(0),
581 "without pathLen 0 a leaked key can mint an intermediate: {limits:?}"
582 );
583 assert!(
584 limits.signs_only_certificates,
585 "the authority key must not be usable to serve TLS: {limits:?}"
586 );
587 }
588
589 /// And the same certificate does not read as permitting a different suffix.
590 ///
591 /// The neutering check for the one above: a `permits_only` that ignored its argument would
592 /// pass every assertion there.
593 #[test]
594 fn an_authority_for_one_suffix_does_not_permit_another() {
595 let ca = Authority::create("dev").expect("an authority");
596 assert!(permits_only(ca.certificate_pem(), "dev"));
597 assert!(!permits_only(ca.certificate_pem(), "ssh-browser"));
598 assert!(!permits_only(ca.certificate_pem(), "de"));
599 assert!(!permits_only(ca.certificate_pem(), ""));
600 }
601
602 /// An authority with no constraint at all is refused, not adopted.
603 ///
604 /// This is the shape a stock local CA has — `mkcert`'s — and the one thing this module
605 /// exists to avoid. If such a file appeared in the state directory, by hand or from a
606 /// `mkcert` run pointed there, adopting it would mean serving from a root that can
607 /// impersonate anything.
608 #[test]
609 fn an_unconstrained_authority_is_not_adopted() {
610 let mut params = CertificateParams::default();
611 params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
612 params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
613 let key = KeyPair::generate().expect("a key");
614 let pem = params.self_signed(&key).expect("self signed").pem();
615
616 let limits = limits_of(&pem).expect("parses");
617 assert!(limits.permitted.is_empty(), "{limits:?}");
618 assert_eq!(limits.path_len, None, "{limits:?}");
619 assert!(
620 !permits_only(&pem, "ssh-browser"),
621 "an unconstrained authority must never be treated as constrained"
622 );
623 }
624
625 /// An authority that permits a second subtree is refused too.
626 ///
627 /// Narrower than the case above and more likely: somebody edits the file, or an older
628 /// version of this wrote two. Permitting `ssh-browser` *and* something else is not the
629 /// promise the trust decision was made against.
630 #[test]
631 fn a_second_permitted_subtree_is_refused() {
632 let mut params = CertificateParams::default();
633 params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
634 params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
635 params.name_constraints = Some(NameConstraints {
636 permitted_subtrees: vec![
637 GeneralSubtree::DnsName("ssh-browser".to_string()),
638 GeneralSubtree::DnsName("example.com".to_string()),
639 ],
640 excluded_subtrees: Vec::new(),
641 });
642 let key = KeyPair::generate().expect("a key");
643 let pem = params.self_signed(&key).expect("self signed").pem();
644
645 assert_eq!(
646 limits_of(&pem).expect("parses").permitted,
647 ["ssh-browser", "example.com"]
648 );
649 assert!(!permits_only(&pem, "ssh-browser"));
650 }
651
652 /// A suffix that cannot be a hostname cannot go in a certificate either.
653 #[test]
654 fn a_suffix_that_is_not_a_hostname_is_refused() {
655 for bad in ["Has Caps", "with space", "with/slash", "", "under_score"] {
656 assert!(
657 Authority::create(bad).is_err(),
658 "{bad:?} should not have produced an authority"
659 );
660 }
661 }
662
663 /// Names in a serving certificate, out of the encoded bytes.
664 fn names_in(certificate_pem: &str) -> Vec<String> {
665 use x509_parser::prelude::*;
666
667 let (_, pem) =
668 x509_parser::pem::parse_x509_pem(certificate_pem.as_bytes()).expect("the leaf is PEM");
669 let (_, cert) = X509Certificate::from_der(&pem.contents).expect("the leaf is X.509");
670 cert.subject_alternative_name()
671 .ok()
672 .flatten()
673 .map(|san| {
674 san.value
675 .general_names
676 .iter()
677 .filter_map(|n| match n {
678 x509_parser::extensions::GeneralName::DNSName(d) => Some(d.to_string()),
679 _ => None,
680 })
681 .collect()
682 })
683 .unwrap_or_default()
684 }
685
686 /// The serving certificate names one alias outright, and is not an authority.
687 ///
688 /// **Not a wildcard, and that is measured rather than preferred.** `*.ssh-browser` is refused
689 /// by Chromium with `ERR_CERT_COMMON_NAME_INVALID`: the suffix is not a known registry, so a
690 /// wildcard directly beneath it reads as one covering an entire top-level domain. The same
691 /// authority signing `e2e.ssh-browser` outright loads, with `isSecureContext`, service
692 /// workers and `crypto.subtle` all present — which is the entire point of the https mode.
693 ///
694 /// So this asserts the wildcard is *absent*. A later change back to one would look tidier
695 /// and would break every https page.
696 #[test]
697 fn the_leaf_names_one_alias_and_is_not_itself_an_authority() {
698 let ca = Authority::create("ssh-browser").expect("an authority");
699 let leaf = ca.leaf_for("alias.ssh-browser").expect("a leaf");
700 assert!(leaf.key_pem.contains("PRIVATE KEY"));
701
702 let names = names_in(&leaf.certificate_pem);
703 assert_eq!(names, ["alias.ssh-browser"], "{names:?}");
704 assert!(
705 !names.iter().any(|n| n.starts_with('*')),
706 "a wildcard under a suffix that is not a real registry is refused by browsers: \
707 {names:?}"
708 );
709
710 // A serving certificate that was also an authority could sign for the whole suffix, and
711 // it is handed to whatever terminates TLS.
712 assert!(
713 !limits_of(&leaf.certificate_pem).expect("parses").is_ca,
714 "the serving certificate must not be a CA"
715 );
716 }
717
718 /// And the authority refuses to vouch for anything that is not one label under its suffix.
719 ///
720 /// Refused here rather than left to the name constraint. The constraint would stop it too,
721 /// at the verifier — but failing here means the certificate is never signed at all, and a
722 /// signature that was never produced cannot be misread by anything.
723 #[test]
724 fn the_authority_signs_only_a_single_label_under_its_suffix() {
725 let ca = Authority::create("ssh-browser").expect("an authority");
726
727 assert!(ca.leaf_for("alias.ssh-browser").is_ok());
728 // The bare suffix is served too: it is the index of what is open.
729 assert!(ca.leaf_for("ssh-browser").is_ok());
730
731 for bad in [
732 "evil.example",
733 "deep.nested.ssh-browser",
734 ".ssh-browser",
735 "ssh-browser.evil.example",
736 "*.ssh-browser",
737 "",
738 ] {
739 assert!(
740 ca.leaf_for(bad).is_err(),
741 "{bad:?} should not have been signed"
742 );
743 }
744 }
745
746 /// Every platform's instructions name the file, name the authority, and say how to undo it.
747 ///
748 /// All three on whichever machine runs this, which is the point. They used to be `cfg!`
749 /// branches and this test saw only one of them — so the Linux text, which never named the
750 /// authority, passed on Windows and failed in CI. A platform-specific string only its own
751 /// platform can test is a string nobody tests.
752 #[test]
753 fn every_platforms_instructions_say_what_to_install_and_how_to_undo_it() {
754 for store in [Store::Windows, Store::MacOs, Store::Other] {
755 let said = instructions_for(store, "ssh-browser", Path::new("/tmp/authority.pem"));
756 assert!(said.contains("authority.pem"), "{store:?}: {said}");
757 assert!(
758 said.contains("Undo:"),
759 "{store:?}: telling somebody to install a root without saying how to remove it \
760 is half an instruction: {said}"
761 );
762 // The name is how they find it again in a list months later, when the path this
763 // printed is long forgotten.
764 assert!(
765 said.contains(&common_name("ssh-browser")),
766 "{store:?}: nothing names the authority, so it cannot be found to remove: {said}"
767 );
768 }
769 }
770
771 /// And the platform this is running on gets its own instructions, not somebody else's.
772 ///
773 /// Without this, `Store::here()` could return one constant and every assertion above would
774 /// still pass.
775 #[test]
776 fn the_instructions_printed_here_are_for_this_platform() {
777 let said = trust_instructions("ssh-browser", Path::new("/tmp/authority.pem"));
778 let expect = if cfg!(windows) {
779 "certutil"
780 } else if cfg!(target_os = "macos") {
781 "security add-trusted-cert"
782 } else {
783 "update-ca-certificates"
784 };
785 assert!(
786 said.contains(expect),
787 "expected {expect:?} for this platform: {said}"
788 );
789 }
790
791 /// An independent verifier refuses a name outside the constraint.
792 ///
793 /// This is the only test here that checks the *claim* rather than the encoding. Everything
794 /// above establishes that the certificate says what it should; a name constraint is enforced
795 /// by whoever validates the chain, so a signer that happily mints `evil.example` is expected
796 /// and being refused downstream is the whole property.
797 ///
798 /// `openssl verify` is the verifier because it is a third implementation — not `rcgen` which
799 /// wrote the bytes, and not `x509-parser` which read them back. It is on all three CI
800 /// runners. When it is absent the test says so rather than passing quietly, because a check
801 /// that silently does nothing is worse than one that is missing.
802 #[test]
803 fn an_independent_verifier_refuses_a_name_outside_the_constraint() {
804 use std::process::Command;
805
806 let Ok(version) = Command::new("openssl").arg("version").output() else {
807 println!(
808 " SKIPPED an_independent_verifier_refuses_a_name_outside_the_constraint: \
809 no openssl on PATH"
810 );
811 return;
812 };
813 assert!(
814 version.status.success(),
815 "openssl is on PATH but would not run"
816 );
817
818 let ca = Authority::create("ssh-browser").expect("an authority");
819 let inside = ca
820 .leaf_named(&["alias.ssh-browser"])
821 .expect("a name inside the constraint");
822 let outside = ca
823 .leaf_named(&["evil.example"])
824 .expect("the signer does not police this; the verifier does");
825
826 let dir = std::env::temp_dir().join(format!("ssh-browser-nc-{}", std::process::id()));
827 std::fs::create_dir_all(&dir).expect("a temporary directory");
828 let ca_path = dir.join("ca.pem");
829 let inside_path = dir.join("inside.pem");
830 let outside_path = dir.join("outside.pem");
831 std::fs::write(&ca_path, ca.certificate_pem()).expect("write the authority");
832 std::fs::write(&inside_path, &inside.certificate_pem).expect("write the good leaf");
833 std::fs::write(&outside_path, &outside.certificate_pem).expect("write the bad leaf");
834
835 let verify = |leaf: &Path| {
836 let out = Command::new("openssl")
837 .arg("verify")
838 .arg("-CAfile")
839 .arg(&ca_path)
840 .arg(leaf)
841 .output()
842 .expect("openssl verify runs");
843 let said = format!(
844 "{}{}",
845 String::from_utf8_lossy(&out.stdout),
846 String::from_utf8_lossy(&out.stderr)
847 );
848 (out.status.success(), said)
849 };
850
851 let (ok, said) = verify(&inside_path);
852 assert!(ok, "a name under the suffix should verify: {said}");
853
854 let (ok, said) = verify(&outside_path);
855 assert!(
856 !ok,
857 "openssl accepted a certificate for evil.example from an authority constrained to \
858 ssh-browser, which means the constraint is buying nothing: {said}"
859 );
860 // The reason, not just the refusal. A leaf rejected for an expired date or a bad
861 // signature would fail the assertion above while saying nothing about name constraints.
862 assert!(
863 said.to_lowercase().contains("subtree")
864 || said.to_lowercase().contains("name constraint")
865 || said.to_lowercase().contains("excluded"),
866 "refused, but not for the constraint -- so this test is not measuring it: {said}"
867 );
868
869 let _ = std::fs::remove_dir_all(&dir);
870 }
871
872 /// The date arithmetic, against a calendar rather than against itself.
873 #[test]
874 fn days_since_the_epoch_become_the_right_date() {
875 assert_eq!(civil_from_days(0), (1970, 1, 1));
876 assert_eq!(civil_from_days(1), (1970, 1, 2));
877 // 2000-03-01, just past a leap day in a year divisible by 400.
878 assert_eq!(civil_from_days(11017), (2000, 3, 1));
879 assert_eq!(civil_from_days(11016), (2000, 2, 29));
880 }
881
882 /// The certificate is valid now, and for about as long as it says.
883 ///
884 /// Backdating is the part worth pinning: without it a machine whose clock is a minute behind
885 /// gets a TLS error with nothing in it about clocks.
886 #[test]
887 fn the_authority_is_already_valid_and_the_leaf_expires_sooner() {
888 use x509_parser::prelude::*;
889
890 let read = |pem: &str| {
891 let (_, p) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).expect("PEM");
892 let (_, c) = X509Certificate::from_der(&p.contents).expect("X.509");
893 (
894 c.validity().not_before.timestamp(),
895 c.validity().not_after.timestamp(),
896 )
897 };
898 let now = i64::try_from(
899 std::time::SystemTime::now()
900 .duration_since(std::time::UNIX_EPOCH)
901 .expect("after 1970")
902 .as_secs(),
903 )
904 .expect("a plausible clock");
905
906 let ca = Authority::create("ssh-browser").expect("an authority");
907 let (ca_from, ca_until) = read(ca.certificate_pem());
908 assert!(
909 ca_from < now,
910 "the authority is not valid yet: {ca_from} > {now}"
911 );
912 assert!(ca_until > now, "the authority has already expired");
913
914 let (leaf_from, leaf_until) = read(
915 &ca.leaf_for("alias.ssh-browser")
916 .expect("a leaf")
917 .certificate_pem,
918 );
919 assert!(leaf_from < now, "the leaf is not valid yet");
920 assert!(
921 leaf_until < ca_until,
922 "the leaf must not outlive the authority that signed it"
923 );
924 }
925}