//! The TLS certificates a server presents, generated in process on first //! start, from a CA the app pins. //! //! Shared because it existed twice and differed by an organisation name. //! It is also the piece where being written twice is worst: a trust //! anchor built two ways can be built differently two ways, and the //! difference would surface as an opaque handshake failure on a phone. //! //! `product` names the certificate's organisation and common name, and is //! the whole of what is per-project. //! //! There used to be a `gen-dev-cert.sh` calling openssl, which meant a //! setup step to remember, a second place for the "which SANs?" answer to //! live, and a dependency on whatever openssl was installed. Doing it here //! means the server can simply ensure its own certificates exist, with the //! file modes and extensions it wants, and with the address it is actually //! about to bind already in the leaf. //! //! The split that matters is between the two: //! //! - The **CA** is generated once and then left alone. The updater app //! pins it, so replacing it strands every installed copy -- recovery is //! a reinstall over the plain-HTTP bootstrap port. It is the one thing //! here that is a one-way door. //! - The **leaf** is cheap and reissued on every start, signed by that //! same unchanged CA. Nothing pins it, so covering a new address is just //! a restart rather than anything the phone has to be told about. //! //! Everything is written owner-only into a directory outside the repo (see //! `config_home`): a CA private key readable by another machine is one it //! can sign with, and a certificate signed by a pinned CA is accepted //! without question. use std::net::IpAddr; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use rcgen::{ BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, }; /// Where the leaf lives, for handing to the TLS listener. pub struct Certificates { pub leaf_cert: PathBuf, pub leaf_key: PathBuf, /// True when the CA was created just now, i.e. anything already /// installed pins the wrong one and has to be reinstalled. pub ca_is_new: bool, } /// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what /// is missing. Safe to call on every start. pub fn ensure(product: &str, dir: &Path, addresses: &[IpAddr]) -> Result { crate::private::create_dir(dir)?; let ca_cert_path = dir.join("ca.pem"); let ca_key_path = dir.join("ca-key.pem"); let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file(); let (ca_pem, ca_key_pem) = if ca_is_new { let (pem, key) = generate_ca(product)?; crate::private::write_file(&ca_key_path, key.as_bytes())?; crate::private::write_file(&ca_cert_path, pem.as_bytes())?; tracing::info!("generated a new CA in {}", dir.display()); (pem, key) } else { ( std::fs::read_to_string(&ca_cert_path) .with_context(|| format!("read {}", ca_cert_path.display()))?, std::fs::read_to_string(&ca_key_path) .with_context(|| format!("read {}", ca_key_path.display()))?, ) }; let (leaf_pem, leaf_key_pem) = generate_leaf(product, &ca_pem, &ca_key_pem, addresses)?; let leaf_cert = dir.join("leaf.pem"); let leaf_key = dir.join("leaf-key.pem"); crate::private::write_file(&leaf_key, leaf_key_pem.as_bytes())?; crate::private::write_file(&leaf_cert, leaf_pem.as_bytes())?; Ok(Certificates { leaf_cert, leaf_key, ca_is_new, }) } fn generate_ca(product: &str) -> Result<(String, String)> { let key = KeyPair::generate().context("generate CA key")?; let mut params = CertificateParams::default(); params .distinguished_name .push(DnType::OrganizationName, format!("{product} dev")); params .distinguished_name .push(DnType::CommonName, format!("{product} dev CA")); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); // Explicit, because strict verifiers reject a CA without them -- and // that rejection surfaces as an opaque handshake failure on a phone. params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; let certificate = params.self_signed(&key).context("self-sign CA")?; Ok((certificate.pem(), key.serialize_pem())) } fn generate_leaf( product: &str, ca_pem: &str, ca_key_pem: &str, addresses: &[IpAddr], ) -> Result<(String, String)> { let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?; let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?; let key = KeyPair::generate().context("generate leaf key")?; let mut params = CertificateParams::default(); params .distinguished_name .push(DnType::OrganizationName, format!("{product} dev")); params.distinguished_name.push( DnType::CommonName, addresses .first() .map(|a| a.to_string()) .unwrap_or_else(|| product.to_string()), ); params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect(); params.is_ca = IsCa::ExplicitNoCa; params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; params.use_authority_key_identifier_extension = true; let certificate = params.signed_by(&key, &issuer).context("sign leaf")?; Ok((certificate.pem(), key.serialize_pem())) } #[cfg(test)] mod tests { use std::os::unix::fs::PermissionsExt; use super::*; fn decode_pem(pem: &str) -> Vec { use base64::Engine; let body: String = pem .lines() .filter(|line| !line.starts_with("-----")) .collect(); base64::engine::general_purpose::STANDARD .decode(body) .expect("base64") } fn contains(haystack: &[u8], needle: &[u8]) -> bool { haystack .windows(needle.len()) .any(|window| window == needle) } fn addresses() -> Vec { vec!["192.168.1.5".parse().unwrap(), "127.0.0.1".parse().unwrap()] } #[test] fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() { let dir = tempfile::tempdir().expect("tempdir"); let first = ensure("demo", dir.path(), &addresses()).expect("generate"); assert!(first.ca_is_new); let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"); let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf"); assert!(ca.starts_with("-----BEGIN CERTIFICATE-----")); let second = ensure("demo", dir.path(), &addresses()).expect("regenerate"); // The CA is the pinned one: replacing it strands every installed // app, so it must survive a restart untouched. assert!(!second.ca_is_new); assert_eq!( ca, std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca") ); // The leaf is not pinned, and is reissued so a new address is a // restart away rather than a reinstall. assert_ne!( leaf, std::fs::read_to_string(&second.leaf_cert).expect("leaf") ); } /// Private keys are rewritten on every start, which is the case that /// makes the mode matter: `private` narrows a file that already /// exists, and this is the caller that depends on it. #[test] fn everything_is_owner_only_even_on_the_second_start() { let dir = tempfile::tempdir().expect("tempdir"); ensure("demo", dir.path(), &addresses()).expect("generate"); // What an older version could have left behind. for file in ["leaf.pem", "leaf-key.pem"] { std::fs::set_permissions( dir.path().join(file), std::fs::Permissions::from_mode(0o644), ) .expect("chmod"); } ensure("demo", dir.path(), &addresses()).expect("reissue"); let mode = |path: std::path::PathBuf| { std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777 }; assert_eq!(mode(dir.path().to_path_buf()), 0o700); for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] { assert_eq!( mode(dir.path().join(file)), 0o600, "{file} is not owner-only" ); } } /// The product name is the whole of what is per-project, so it has to /// reach the certificate rather than being decoration. #[test] fn the_product_names_the_certificate() { let dir = tempfile::tempdir().expect("tempdir"); ensure("dev-updater", dir.path(), &addresses()).expect("generate"); let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"); // Decoded rather than searched as base64: a distinguished name is // stored in the DER as literal bytes, so this checks the value // that actually reaches a phone rather than the value we passed in. let der = decode_pem(&ca); assert!( contains(&der, b"dev-updater dev CA"), "the CA's common name" ); assert!(contains(&der, b"dev-updater dev"), "the organisation"); } }