Skip to main content

fbi_proxy/
tls.rs

1//! Self-signed TLS support for `--tls` mode (Phase 1: no system trust
2//! install). Generates a self-signed certificate for the configured
3//! domain (with `*.<domain>` SAN) and persists it under
4//! `~/.config/fbi-proxy/certs/` so the same fingerprint survives
5//! restarts — browsers can "remember the exception" once.
6//!
7//! The browser warning is expected in this phase. Use Phase 2
8//! (`fbi-proxy trust`) to install a local CA into the system trust
9//! store for a clean lock-icon experience.
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use rcgen::{CertificateParams, DnType, DistinguishedName, KeyPair, SanType};
15use tokio_rustls::TlsAcceptor;
16use tokio_rustls::rustls::ServerConfig;
17use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
18
19pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
20
21/// Where on-disk certs live. Layout: `{base}/certs/{domain}.{pem,key}`.
22pub fn default_cert_dir() -> PathBuf {
23    let base = std::env::var_os("XDG_CONFIG_HOME")
24        .map(PathBuf::from)
25        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
26        .unwrap_or_else(|| PathBuf::from("."));
27    base.join("fbi-proxy").join("certs")
28}
29
30/// Path to the cert file for a given domain (sibling `.key` lives at
31/// the same stem). Use this when you need to install the cert into a
32/// system trust store after `build_acceptor` has materialized it.
33pub fn cert_pem_path(domain: &str, cert_dir: &Path) -> PathBuf {
34    let slug = if domain.is_empty() { "localhost" } else { domain };
35    cert_dir.join(format!("{slug}.pem"))
36}
37
38/// Whether the given cert is currently a trusted anchor on this
39/// system. Returns `false` if the check itself can't be performed
40/// (unsupported platform, missing tool) — callers should treat that
41/// as "no, attempt install."
42pub fn is_trusted(cert_path: &Path) -> bool {
43    #[cfg(target_os = "macos")]
44    {
45        std::process::Command::new("security")
46            .args(["verify-cert", "-c"])
47            .arg(cert_path)
48            .stdout(std::process::Stdio::null())
49            .stderr(std::process::Stdio::null())
50            .status()
51            .map(|s| s.success())
52            .unwrap_or(false)
53    }
54    #[cfg(not(target_os = "macos"))]
55    {
56        let _ = cert_path;
57        false
58    }
59}
60
61/// Install `cert_path` as a trusted root anchor in the system trust
62/// store. Idempotent — checks `is_trusted` first and returns `Ok(false)`
63/// if no install was performed.
64///
65/// Requires root on macOS (writes to `/Library/Keychains/System.keychain`).
66/// On other platforms this is a no-op for now (Linux / Windows is a
67/// follow-up — see TODO.md).
68pub fn install_to_system_trust(cert_path: &Path) -> Result<bool, BoxError> {
69    if is_trusted(cert_path) {
70        return Ok(false);
71    }
72
73    #[cfg(target_os = "macos")]
74    {
75        log::info!("installing {} to System.keychain", cert_path.display());
76        let status = std::process::Command::new("security")
77            .args([
78                "add-trusted-cert",
79                "-d",
80                "-r",
81                "trustRoot",
82                "-k",
83                "/Library/Keychains/System.keychain",
84            ])
85            .arg(cert_path)
86            .status()?;
87        if !status.success() {
88            return Err(format!(
89                "security add-trusted-cert failed (exit {:?}); needs root (sudo)",
90                status.code(),
91            )
92            .into());
93        }
94        Ok(true)
95    }
96    #[cfg(not(target_os = "macos"))]
97    {
98        let _ = cert_path;
99        log::warn!("auto-trust-install: only macOS supported in this build");
100        Ok(false)
101    }
102}
103
104/// Build a `TlsAcceptor` for the given domain, reusing a persisted
105/// cert if one exists or generating + writing a fresh one if not.
106///
107/// `domain` is the apex (e.g. `"fbi.com"`); the cert SAN includes both
108/// the apex and `*.{domain}` so any subdomain validates. If `domain`
109/// is empty or `"localhost"`, only `localhost` + `127.0.0.1` are
110/// covered.
111pub fn build_acceptor(domain: &str, cert_dir: &Path) -> Result<TlsAcceptor, BoxError> {
112    let (cert_pem, key_pem) = load_or_generate(domain, cert_dir)?;
113
114    let cert_chain: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
115        .collect::<Result<Vec<_>, _>>()?;
116    let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes())?;
117
118    let config = ServerConfig::builder()
119        .with_no_client_auth()
120        .with_single_cert(cert_chain, key)?;
121
122    Ok(TlsAcceptor::from(Arc::new(config)))
123}
124
125fn load_or_generate(domain: &str, cert_dir: &Path) -> Result<(String, String), BoxError> {
126    let slug = if domain.is_empty() { "localhost" } else { domain };
127    let cert_path = cert_dir.join(format!("{slug}.pem"));
128    let key_path = cert_dir.join(format!("{slug}.key"));
129
130    if cert_path.exists() && key_path.exists() {
131        let cert = std::fs::read_to_string(&cert_path)?;
132        let key = std::fs::read_to_string(&key_path)?;
133        return Ok((cert, key));
134    }
135
136    let (cert_pem, key_pem) = generate_self_signed(domain)?;
137    std::fs::create_dir_all(cert_dir)?;
138    std::fs::write(&cert_path, &cert_pem)?;
139    // 0600 on the key — std::fs::write opens 0644 by default
140    write_private(&key_path, key_pem.as_bytes())?;
141
142    Ok((cert_pem, key_pem))
143}
144
145#[cfg(unix)]
146fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
147    use std::io::Write;
148    use std::os::unix::fs::OpenOptionsExt;
149    let mut f = std::fs::OpenOptions::new()
150        .write(true)
151        .create(true)
152        .truncate(true)
153        .mode(0o600)
154        .open(path)?;
155    f.write_all(bytes)?;
156    Ok(())
157}
158
159#[cfg(not(unix))]
160fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
161    std::fs::write(path, bytes)
162}
163
164/// Generate a SAN-only self-signed cert valid for ~1 year. Returns
165/// `(cert_pem, key_pem)`. The Common Name is intentionally left blank
166/// — modern browsers ignore CN and only honor SAN entries.
167pub fn generate_self_signed(domain: &str) -> Result<(String, String), BoxError> {
168    let mut sans: Vec<SanType> = Vec::new();
169    if domain.is_empty() || domain == "localhost" {
170        sans.push(SanType::DnsName("localhost".try_into()?));
171        sans.push(SanType::IpAddress("127.0.0.1".parse()?));
172    } else {
173        sans.push(SanType::DnsName(domain.try_into()?));
174        sans.push(SanType::DnsName(format!("*.{domain}").try_into()?));
175    }
176
177    let mut params = CertificateParams::default();
178    params.subject_alt_names = sans;
179
180    // Browsers ignore CN, but a non-empty DN avoids some tooling
181    // warnings. Use OrganizationName so the CN stays empty.
182    let mut dn = DistinguishedName::new();
183    dn.push(DnType::OrganizationName, "fbi-proxy (self-signed)");
184    params.distinguished_name = dn;
185
186    let now = time::OffsetDateTime::now_utc();
187    params.not_before = now - time::Duration::days(1);
188    params.not_after = now + time::Duration::days(365);
189
190    let key_pair = KeyPair::generate()?;
191    let cert = params.self_signed(&key_pair)?;
192
193    Ok((cert.pem(), key_pair.serialize_pem()))
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn generates_pem_with_domain_san() {
202        let (cert, key) = generate_self_signed("fbi.com").unwrap();
203        assert!(cert.contains("BEGIN CERTIFICATE"));
204        assert!(key.contains("BEGIN PRIVATE KEY"));
205
206        // Parse the cert and check SAN entries
207        let der = CertificateDer::pem_slice_iter(cert.as_bytes())
208            .next()
209            .unwrap()
210            .unwrap();
211        // We don't fully x509-parse here — but the cert+key should be
212        // accepted by rustls' single-cert builder, which is what the
213        // real server uses. That's the real-world contract.
214        let key_der = PrivateKeyDer::from_pem_slice(key.as_bytes()).unwrap();
215        let config = ServerConfig::builder()
216            .with_no_client_auth()
217            .with_single_cert(vec![der], key_der);
218        assert!(config.is_ok(), "rustls should accept generated cert+key");
219    }
220
221    #[test]
222    fn generates_for_localhost_fallback() {
223        let (cert, _key) = generate_self_signed("").unwrap();
224        assert!(cert.contains("BEGIN CERTIFICATE"));
225    }
226
227    #[test]
228    fn load_or_generate_round_trips_persisted_certs() {
229        let tmp = std::env::temp_dir().join(format!(
230            "fbi-tls-test-{}",
231            std::process::id()
232        ));
233        let _ = std::fs::remove_dir_all(&tmp);
234
235        let (cert1, key1) = load_or_generate("test.dev", &tmp).unwrap();
236        // Second call should return the same content (loaded from disk)
237        let (cert2, key2) = load_or_generate("test.dev", &tmp).unwrap();
238        assert_eq!(cert1, cert2);
239        assert_eq!(key1, key2);
240
241        let _ = std::fs::remove_dir_all(&tmp);
242    }
243}