| 1 | //! Self-signed TLS for loopback. |
| 2 | //! |
| 3 | //! Firefox's HTTPS-Only Mode rewrites `ws://` to `wss://`, including for |
| 4 | //! loopback. Rather than asking people to weaken a browser security setting so |
| 5 | //! our daemon can run, we just speak TLS. |
| 6 | //! |
| 7 | //! The certificate is generated once, stored in the config dir with the key at |
| 8 | //! 0600, and covers `127.0.0.1`, `::1` and `localhost`. It is self-signed, so |
| 9 | //! the browser needs a one-time exception — see `serve` output. |
| 10 | |
| 11 | use std::fs; |
| 12 | use std::io::Write; |
| 13 | use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; |
| 14 | use std::path::Path; |
| 15 | use std::sync::Arc; |
| 16 | |
| 17 | use tokio_rustls::TlsAcceptor; |
| 18 | use tokio_rustls::rustls::ServerConfig; |
| 19 | use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; |
| 20 | |
| 21 | pub const CERT_FILE: &str = "cert.pem"; |
| 22 | pub const KEY_FILE: &str = "key.pem"; |
| 23 | |
| 24 | /// Regenerate when the cert is within this of expiry, so a long-lived install |
| 25 | /// doesn't silently start failing. |
| 26 | const VALIDITY_DAYS: i64 = 3650; |
| 27 | |
| 28 | pub struct Identity { |
| 29 | pub cert_pem: String, |
| 30 | pub key_pem: String, |
| 31 | /// SHA-256 of the DER certificate, colon-separated — the same fingerprint |
| 32 | /// the browser shows, so a user can verify they're trusting our cert. |
| 33 | pub fingerprint: String, |
| 34 | } |
| 35 | |
| 36 | fn fingerprint(der: &[u8]) -> String { |
| 37 | // Avoid pulling in a hash crate for one call; rcgen already depends on ring. |
| 38 | let digest = ring_sha256(der); |
| 39 | digest |
| 40 | .iter() |
| 41 | .map(|b| format!("{b:02X}")) |
| 42 | .collect::<Vec<_>>() |
| 43 | .join(":") |
| 44 | } |
| 45 | |
| 46 | fn ring_sha256(data: &[u8]) -> Vec<u8> { |
| 47 | use sha2::{Digest, Sha256}; |
| 48 | Sha256::digest(data).to_vec() |
| 49 | } |
| 50 | |
| 51 | /// Load the existing identity, or generate one on first run. |
| 52 | pub fn load_or_create(dir: &Path) -> std::io::Result<Identity> { |
| 53 | let cert_path = dir.join(CERT_FILE); |
| 54 | let key_path = dir.join(KEY_FILE); |
| 55 | |
| 56 | if cert_path.exists() && key_path.exists() { |
| 57 | let mode = fs::metadata(&key_path)?.permissions().mode() & 0o777; |
| 58 | if mode & 0o077 != 0 { |
| 59 | return Err(std::io::Error::other(format!( |
| 60 | "{} has mode {mode:04o}; expected 0600. Fix with: chmod 600 {}", |
| 61 | key_path.display(), |
| 62 | key_path.display() |
| 63 | ))); |
| 64 | } |
| 65 | let cert_pem = fs::read_to_string(&cert_path)?; |
| 66 | let key_pem = fs::read_to_string(&key_path)?; |
| 67 | let der = first_cert_der(&cert_pem)?; |
| 68 | return Ok(Identity { |
| 69 | fingerprint: fingerprint(&der), |
| 70 | cert_pem, |
| 71 | key_pem, |
| 72 | }); |
| 73 | } |
| 74 | |
| 75 | generate(dir) |
| 76 | } |
| 77 | |
| 78 | pub fn generate(dir: &Path) -> std::io::Result<Identity> { |
| 79 | fs::create_dir_all(dir)?; |
| 80 | fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; |
| 81 | |
| 82 | let mut params = rcgen::CertificateParams::new(vec![ |
| 83 | "localhost".to_string(), |
| 84 | "127.0.0.1".to_string(), |
| 85 | "::1".to_string(), |
| 86 | ]) |
| 87 | .map_err(|e| std::io::Error::other(e.to_string()))?; |
| 88 | |
| 89 | params.distinguished_name = { |
| 90 | let mut dn = rcgen::DistinguishedName::new(); |
| 91 | dn.push(rcgen::DnType::CommonName, "termbridge (local)"); |
| 92 | dn.push(rcgen::DnType::OrganizationName, "termbridge"); |
| 93 | dn |
| 94 | }; |
| 95 | params.not_before = rcgen::date_time_ymd(2020, 1, 1); |
| 96 | params.not_after = rcgen::date_time_ymd(2025 + (VALIDITY_DAYS / 365) as i32, 1, 1); |
| 97 | // A leaf, not a CA: this cert can only ever vouch for this host, so |
| 98 | // trusting it cannot be leveraged to impersonate anything else. |
| 99 | params.is_ca = rcgen::IsCa::ExplicitNoCa; |
| 100 | |
| 101 | let key = rcgen::KeyPair::generate().map_err(|e| std::io::Error::other(e.to_string()))?; |
| 102 | let cert = params |
| 103 | .self_signed(&key) |
| 104 | .map_err(|e| std::io::Error::other(e.to_string()))?; |
| 105 | |
| 106 | let cert_pem = cert.pem(); |
| 107 | let key_pem = key.serialize_pem(); |
| 108 | |
| 109 | fs::write(dir.join(CERT_FILE), &cert_pem)?; |
| 110 | fs::set_permissions(dir.join(CERT_FILE), fs::Permissions::from_mode(0o644))?; |
| 111 | |
| 112 | // The private key gets the same treatment as the auth token. |
| 113 | let key_path = dir.join(KEY_FILE); |
| 114 | let mut f = fs::OpenOptions::new() |
| 115 | .write(true) |
| 116 | .create(true) |
| 117 | .truncate(true) |
| 118 | .mode(0o600) |
| 119 | .open(&key_path)?; |
| 120 | f.write_all(key_pem.as_bytes())?; |
| 121 | f.sync_all()?; |
| 122 | fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?; |
| 123 | |
| 124 | let der = cert.der().to_vec(); |
| 125 | Ok(Identity { |
| 126 | fingerprint: fingerprint(&der), |
| 127 | cert_pem, |
| 128 | key_pem, |
| 129 | }) |
| 130 | } |
| 131 | |
| 132 | fn first_cert_der(pem: &str) -> std::io::Result<Vec<u8>> { |
| 133 | let mut cursor = std::io::Cursor::new(pem.as_bytes()); |
| 134 | let certs: Vec<CertificateDer> = rustls_pemfile_certs(&mut cursor)?; |
| 135 | certs |
| 136 | .first() |
| 137 | .map(|c| c.as_ref().to_vec()) |
| 138 | .ok_or_else(|| std::io::Error::other("no certificate in cert.pem")) |
| 139 | } |
| 140 | |
| 141 | /// Minimal PEM decoder so we don't add rustls-pemfile for two call sites. |
| 142 | fn rustls_pemfile_certs( |
| 143 | r: &mut std::io::Cursor<&[u8]>, |
| 144 | ) -> std::io::Result<Vec<CertificateDer<'static>>> { |
| 145 | let text = String::from_utf8_lossy(r.get_ref()).to_string(); |
| 146 | let mut out = Vec::new(); |
| 147 | for block in text.split("-----BEGIN CERTIFICATE-----").skip(1) { |
| 148 | let Some(body) = block.split("-----END CERTIFICATE-----").next() else { |
| 149 | continue; |
| 150 | }; |
| 151 | let b64: String = body.chars().filter(|c| !c.is_whitespace()).collect(); |
| 152 | out.push(CertificateDer::from(base64_decode(&b64)?)); |
| 153 | } |
| 154 | Ok(out) |
| 155 | } |
| 156 | |
| 157 | fn base64_decode(s: &str) -> std::io::Result<Vec<u8>> { |
| 158 | const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
| 159 | let mut rev = [255u8; 256]; |
| 160 | for (i, c) in TABLE.iter().enumerate() { |
| 161 | rev[*c as usize] = i as u8; |
| 162 | } |
| 163 | let mut out = Vec::new(); |
| 164 | let mut acc: u32 = 0; |
| 165 | let mut bits = 0; |
| 166 | for c in s.bytes() { |
| 167 | if c == b'=' { |
| 168 | break; |
| 169 | } |
| 170 | let v = rev[c as usize]; |
| 171 | if v == 255 { |
| 172 | return Err(std::io::Error::other("invalid base64 in PEM")); |
| 173 | } |
| 174 | acc = (acc << 6) | v as u32; |
| 175 | bits += 6; |
| 176 | if bits >= 8 { |
| 177 | bits -= 8; |
| 178 | out.push((acc >> bits) as u8); |
| 179 | } |
| 180 | } |
| 181 | Ok(out) |
| 182 | } |
| 183 | |
| 184 | pub fn acceptor(identity: &Identity) -> std::io::Result<TlsAcceptor> { |
| 185 | let mut cert_cursor = std::io::Cursor::new(identity.cert_pem.as_bytes()); |
| 186 | let certs = rustls_pemfile_certs(&mut cert_cursor)?; |
| 187 | |
| 188 | let key = parse_private_key(&identity.key_pem)?; |
| 189 | |
| 190 | let config = ServerConfig::builder() |
| 191 | .with_no_client_auth() |
| 192 | .with_single_cert(certs, key) |
| 193 | .map_err(|e| std::io::Error::other(e.to_string()))?; |
| 194 | |
| 195 | Ok(TlsAcceptor::from(Arc::new(config))) |
| 196 | } |
| 197 | |
| 198 | fn parse_private_key(pem: &str) -> std::io::Result<PrivateKeyDer<'static>> { |
| 199 | for (begin, end) in [ |
| 200 | ("-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"), |
| 201 | ( |
| 202 | "-----BEGIN EC PRIVATE KEY-----", |
| 203 | "-----END EC PRIVATE KEY-----", |
| 204 | ), |
| 205 | ( |
| 206 | "-----BEGIN RSA PRIVATE KEY-----", |
| 207 | "-----END RSA PRIVATE KEY-----", |
| 208 | ), |
| 209 | ] { |
| 210 | if let Some(rest) = pem.split(begin).nth(1) |
| 211 | && let Some(body) = rest.split(end).next() |
| 212 | { |
| 213 | let b64: String = body.chars().filter(|c| !c.is_whitespace()).collect(); |
| 214 | let der = base64_decode(&b64)?; |
| 215 | return Ok(match begin { |
| 216 | "-----BEGIN EC PRIVATE KEY-----" => PrivateKeyDer::Sec1(der.into()), |
| 217 | "-----BEGIN RSA PRIVATE KEY-----" => PrivateKeyDer::Pkcs1(der.into()), |
| 218 | _ => PrivateKeyDer::Pkcs8(der.into()), |
| 219 | }); |
| 220 | } |
| 221 | } |
| 222 | Err(std::io::Error::other("no private key found in key.pem")) |
| 223 | } |