Skip to main content

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}