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/// 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}