Skip to main content

pitchfork_cli/proxy/
trust.rs

1//! CA certificate trust management for the reverse proxy.
2//!
3//! Provides functions to:
4//! - Check if the pitchfork CA is trusted by the system (`is_ca_trusted`)
5//! - Install the CA into the system trust store (`install_cert`)
6//! - Remove the CA from the system trust store (`uninstall_cert`)
7//! - Auto-trust the CA during supervisor startup (`auto_trust`)
8
9use crate::Result;
10
11/// File name for the installed CA certificate on Linux.
12#[cfg(target_os = "linux")]
13const INSTALLED_CERT_NAME: &str = "pitchfork-proxy.crt";
14
15// ---------------------------------------------------------------------------
16// is_ca_trusted
17// ---------------------------------------------------------------------------
18
19/// Check if the pitchfork CA certificate is already trusted by the system.
20///
21/// Always queries the OS trust store directly. This is correct even when the
22/// user manually removes the cert from their keychain or CA directory — the
23/// check will reflect the actual state rather than a stale cached value.
24pub fn is_ca_trusted(cert_path: &std::path::Path) -> bool {
25    ca_trust_state(cert_path) == Some(true)
26}
27
28/// Whether the CA is trusted, or `None` when the trust store would not say.
29///
30/// `is_ca_trusted` collapses the unknown into `false`, which is the safe
31/// reading when deciding whether to *add* trust: the worst case is installing
32/// a certificate that was already there, and installing is idempotent.
33///
34/// It is the wrong reading in the other direction. A step that removes trust
35/// treats "not trusted" as already done, so folding a timed-out probe into
36/// `false` would quietly skip the removal and leave the CA trusted with
37/// nothing left to take it out. Callers that act on an absence use this and
38/// treat `None` as "not established", so the removal still runs.
39pub fn ca_trust_state(cert_path: &std::path::Path) -> Option<bool> {
40    if !cert_path.exists() {
41        return Some(false);
42    }
43
44    #[cfg(target_os = "macos")]
45    {
46        is_ca_trusted_macos(cert_path)
47    }
48    #[cfg(target_os = "linux")]
49    {
50        Some(is_ca_trusted_linux(cert_path))
51    }
52    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
53    {
54        Some(false)
55    }
56}
57
58#[cfg(target_os = "macos")]
59fn is_ca_trusted_macos(cert_path: &std::path::Path) -> Option<bool> {
60    use std::process::{Command, Stdio};
61    // Use verify-cert without -L -p ssl. The SSL policy evaluates the cert as
62    // a leaf certificate (checking for serverAuth EKU etc.), which a CA cert
63    // typically lacks. Without a policy, verify-cert respects the explicit
64    // trustRoot trust override without applying leaf-oriented constraints.
65    //
66    // Suppress stdout/stderr to prevent security framework diagnostic messages
67    // from leaking into the terminal (e.g. during `proxy status` or supervisor
68    // startup when the cert is not yet trusted).
69    //
70    // Bounded, and the child is killed when the budget runs out. `verify-cert`
71    // can stall on a keychain prompt or a revocation check, and a caller that
72    // simply stops waiting — `proxy doctor` gives every probe a deadline —
73    // would otherwise leave a `security` process behind, possibly sitting on a
74    // dialog the user never asked for and cannot connect to anything.
75    let Ok(mut child) = Command::new("security")
76        .args(["verify-cert", "-c", &cert_path.to_string_lossy()])
77        .stdout(Stdio::null())
78        .stderr(Stdio::null())
79        .spawn()
80    else {
81        return None;
82    };
83    let deadline = std::time::Instant::now() + TRUST_PROBE_TIMEOUT;
84    loop {
85        match child.try_wait() {
86            Ok(Some(status)) => return Some(status.success()),
87            Ok(None) => {}
88            Err(_) => return None,
89        }
90        if std::time::Instant::now() >= deadline {
91            let _ = child.kill();
92            // Reaped so it does not linger as a zombie.
93            let _ = child.wait();
94            log::debug!(
95                "`security verify-cert` did not finish within {TRUST_PROBE_TIMEOUT:?}; \
96                 the CA's trust state is unknown"
97            );
98            return None;
99        }
100        std::thread::sleep(std::time::Duration::from_millis(25));
101    }
102}
103
104/// The longest [`ca_trust_state`] takes before giving up on its own.
105///
106/// Public because a caller that puts its own deadline on this has to allow
107/// more than this, not the same: the point of the inner deadline is to kill
108/// and reap the child, and an outer one that fires first would return, let the
109/// process exit, and leave the child running — the orphan this exists to
110/// prevent.
111pub const TRUST_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
112
113/// Linux distro CA trust configuration.
114#[cfg(target_os = "linux")]
115struct LinuxCATrustConfig {
116    cert_dir: &'static str,
117    /// Update command split into program + args.
118    update_command: &'static [&'static str],
119}
120
121#[cfg(target_os = "linux")]
122fn get_linux_ca_trust_config() -> LinuxCATrustConfig {
123    let configs = [
124        // Debian / Ubuntu
125        LinuxCATrustConfig {
126            cert_dir: "/usr/local/share/ca-certificates",
127            update_command: &["update-ca-certificates"],
128        },
129        // RHEL / Fedora / CentOS
130        LinuxCATrustConfig {
131            cert_dir: "/etc/pki/ca-trust/source/anchors",
132            update_command: &["update-ca-trust"],
133        },
134        // Arch Linux (p11-kit / ca-certificates-utils)
135        LinuxCATrustConfig {
136            cert_dir: "/etc/ca-certificates/trust-source/anchors",
137            update_command: &["trust", "extract-compat"],
138        },
139        // openSUSE
140        LinuxCATrustConfig {
141            cert_dir: "/etc/pki/trust/anchors",
142            update_command: &["update-ca-certificates"],
143        },
144    ];
145
146    // Find the first config whose cert_dir exists
147    for config in &configs {
148        if std::path::Path::new(config.cert_dir).exists() {
149            return LinuxCATrustConfig {
150                cert_dir: config.cert_dir,
151                update_command: config.update_command,
152            };
153        }
154    }
155
156    // Fallback to Debian layout
157    configs.into_iter().next().unwrap()
158}
159
160#[cfg(target_os = "linux")]
161fn is_ca_trusted_linux(cert_path: &std::path::Path) -> bool {
162    let config = get_linux_ca_trust_config();
163    let installed_path = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
164    if !installed_path.exists() {
165        return false;
166    }
167    // Compare file contents
168    let ours = std::fs::read(cert_path).unwrap_or_default();
169    let installed = std::fs::read(&installed_path).unwrap_or_default();
170    ours == installed
171}
172
173// ---------------------------------------------------------------------------
174// install_cert (shared between auto_trust and `proxy trust` command)
175// ---------------------------------------------------------------------------
176
177/// Install the CA certificate into the system trust store.
178///
179/// On macOS, installs into the current user's login keychain (no sudo required;
180/// the OS shows a GUI authorization prompt to confirm).
181///
182/// On Linux, copies to the distro-specific CA directory and runs the
183/// appropriate update command (requires sudo / write access).
184pub fn install_cert(cert_path: &std::path::Path) -> Result<()> {
185    if !cert_path.exists() {
186        miette::bail!(
187            "CA certificate not found at {}\n\
188             \n\
189             The proxy CA certificate is generated automatically when the proxy\n\
190             starts with `proxy.https = true`. Start the supervisor first:\n\
191             \n\
192             pitchfork supervisor start\n\
193             \n\
194             Or specify a custom certificate path with --cert.",
195            cert_path.display()
196        );
197    }
198
199    #[cfg(target_os = "macos")]
200    {
201        install_cert_macos(cert_path)?;
202    }
203    #[cfg(target_os = "linux")]
204    {
205        install_cert_linux(cert_path)?;
206    }
207    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
208    {
209        miette::bail!(
210            "Automatic certificate installation is not supported on this platform.\n\
211             Please manually install the certificate from:\n\
212             {}",
213            cert_path.display()
214        );
215    }
216
217    #[allow(unreachable_code)] // fallback bail! diverges on non-macOS/Linux
218    Ok(())
219}
220
221#[cfg(target_os = "macos")]
222fn install_cert_macos(cert_path: &std::path::Path) -> Result<()> {
223    use std::process::Command;
224
225    let home = &*crate::env::HOME_DIR;
226    let keychain = format!("{}/Library/Keychains/login.keychain-db", home.display());
227
228    let status = Command::new("security")
229        .args([
230            "add-trusted-cert",
231            "-r",
232            "trustRoot",
233            "-k",
234            &keychain,
235            &cert_path.to_string_lossy(),
236        ])
237        .status()
238        .map_err(|e| miette::miette!("Failed to run `security` command: {e}"))?;
239
240    if !status.success() {
241        miette::bail!(
242            "Failed to install certificate (exit code: {}).\n\
243             \n\
244             Try running the command again.",
245            status.code().unwrap_or(-1)
246        );
247    }
248    Ok(())
249}
250
251#[cfg(target_os = "linux")]
252fn install_cert_linux(cert_path: &std::path::Path) -> Result<()> {
253    use std::ffi::CString;
254    use std::process::Command;
255
256    let config = get_linux_ca_trust_config();
257    let dest = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
258
259    // Check write access using libc::access(W_OK)
260    let has_write_access = {
261        let path_cstr =
262            CString::new(config.cert_dir.as_bytes()).unwrap_or_else(|_| CString::new("/").unwrap());
263        // SAFETY: path_cstr is a valid NUL-terminated C string.
264        unsafe { libc::access(path_cstr.as_ptr(), libc::W_OK) == 0 }
265    };
266
267    if !has_write_access {
268        miette::bail!(
269            "Installing certificates on Linux requires elevated privileges.\n\
270             \n\
271             Run with sudo:\n\
272             sudo pitchfork proxy trust\n\
273             \n\
274             This copies the certificate to {}/\n\
275             and runs `{}`.",
276            config.cert_dir,
277            config.update_command.join(" ")
278        );
279    }
280
281    std::fs::copy(cert_path, &dest)
282        .map_err(|e| miette::miette!("Failed to copy certificate to {}: {e}", dest.display()))?;
283
284    let status = Command::new(config.update_command[0])
285        .args(&config.update_command[1..])
286        .status()
287        .map_err(|e| miette::miette!("Failed to run `{}`: {e}", config.update_command.join(" ")))?;
288
289    if !status.success() {
290        // Clean up the copied cert so is_ca_trusted_linux won't falsely
291        // report it as trusted due to file-content equality.
292        let _ = std::fs::remove_file(&dest);
293        miette::bail!(
294            "`{}` failed (exit code: {}).\n\
295             \n\
296             The system trust store was NOT updated.\n\
297             To install manually:\n\
298             sudo cp {} {}\n\
299             sudo {}",
300            config.update_command.join(" "),
301            status.code().unwrap_or(-1),
302            cert_path.display(),
303            dest.display(),
304            config.update_command.join(" ")
305        );
306    }
307    Ok(())
308}
309
310// ---------------------------------------------------------------------------
311// uninstall_cert
312// ---------------------------------------------------------------------------
313
314/// Remove the pitchfork CA certificate from the system trust store.
315///
316/// Handles the case where `cert_path` no longer exists but the cert is still
317/// installed in the system trust store (e.g. the user deleted `ca.pem`).
318pub fn uninstall_cert(cert_path: &std::path::Path) -> Result<()> {
319    // Even if cert_path is gone, the cert may still be installed in the
320    // system trust store. Always attempt platform-specific cleanup.
321    #[cfg(target_os = "macos")]
322    {
323        uninstall_cert_macos(cert_path)?;
324    }
325    #[cfg(target_os = "linux")]
326    {
327        uninstall_cert_linux(cert_path)?;
328    }
329    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
330    {
331        if !cert_path.exists() || !is_ca_trusted(cert_path) {
332            return Ok(());
333        }
334        miette::bail!("Automatic certificate removal is not supported on this platform.");
335    }
336
337    #[allow(unreachable_code)] // fallback bail! diverges on non-macOS/Linux
338    Ok(())
339}
340
341#[cfg(target_os = "macos")]
342fn uninstall_cert_macos(cert_path: &std::path::Path) -> Result<()> {
343    use std::process::Command;
344
345    // remove-trusted-cert removes the trust setting (requires the cert file)
346    if cert_path.exists() {
347        let _ = Command::new("security")
348            .args(["remove-trusted-cert", &cert_path.to_string_lossy()])
349            .status();
350    }
351
352    // Determine the CN for delete-certificate.
353    // If the cert file exists, extract the CN from it. If extraction fails
354    // (e.g. openssl missing), skip delete-certificate to avoid deleting the
355    // wrong entry — remove-trusted-cert already removed the trust setting,
356    // so the remaining keychain entry is harmless.
357    // If the cert file is gone, assume the default CN since pitchfork
358    // generated it.
359    let cn = if cert_path.exists() {
360        match cert_common_name_macos(cert_path) {
361            Some(cn) => Some(cn),
362            None => {
363                log::warn!(
364                    "Could not determine certificate CN; skipping keychain deletion. \
365                     The trust setting has been removed. To delete the certificate \
366                     from the keychain manually, run:\n  \
367                     security delete-certificate -c \"<CN>\" ~/Library/Keychains/login.keychain-db"
368                );
369                None
370            }
371        }
372    } else {
373        Some("Pitchfork Local CA".to_string())
374    };
375
376    if let Some(cn) = cn {
377        // delete-certificate removes from keychain(s)
378        let keychains = [
379            format!(
380                "{}/Library/Keychains/login.keychain-db",
381                crate::env::HOME_DIR.display()
382            ),
383            "/Library/Keychains/System.keychain".to_string(),
384        ];
385        for kc in &keychains {
386            // Loop to remove all matching certs (there may be duplicates)
387            for _ in 0..20 {
388                let status = Command::new("security")
389                    .args(["delete-certificate", "-c", &cn, kc])
390                    .status();
391                if status.map(|s| !s.success()).unwrap_or(true) {
392                    break;
393                }
394            }
395        }
396    }
397
398    // Verify removal (only possible if cert file still exists)
399    // Only when the store positively says it is still there. An unreadable
400    // answer is not evidence the removal failed.
401    if cert_path.exists() && is_ca_trusted_macos(cert_path) == Some(true) {
402        miette::bail!("Could not remove CA from keychain. Try: sudo pitchfork proxy untrust");
403    }
404    Ok(())
405}
406
407/// Extract the Common Name (CN) from a PEM certificate file using `openssl`.
408#[cfg(target_os = "macos")]
409fn cert_common_name_macos(cert_path: &std::path::Path) -> Option<String> {
410    use std::process::Command;
411    // Use -nameopt RFC2253 to get a stable, escaped format, then extract CN.
412    let output = Command::new("openssl")
413        .args([
414            "x509",
415            "-noout",
416            "-subject",
417            "-nameopt",
418            "RFC2253",
419            "-in",
420            &cert_path.to_string_lossy(),
421        ])
422        .output()
423        .ok()?;
424    if !output.status.success() {
425        return None;
426    }
427    // RFC2253 format: "CN=Pitchfork Local CA,O=Org"
428    // Escaped commas in values appear as \, so split on unescaped commas only.
429    let subject = String::from_utf8_lossy(&output.stdout);
430    extract_cn_from_subject_rfc2253(&subject)
431}
432
433/// Extract the CN from an RFC 2253 formatted subject line.
434///
435/// RFC 2253 uses comma-separated RDNs with backslash-escaping.
436/// Example: `subject=CN=Pitchfork Local CA,O=Org` or
437/// `subject=O=Org,CN=Pitchfork Local CA`
438#[cfg(target_os = "macos")]
439fn extract_cn_from_subject_rfc2253(subject: &str) -> Option<String> {
440    let subject = subject.trim();
441    let subject = subject.strip_prefix("subject=").unwrap_or(subject);
442    for rdn in split_rdn(subject) {
443        let rdn = rdn.trim();
444        if let Some(rest) = rdn.strip_prefix("CN=") {
445            let cn = rest.trim();
446            if !cn.is_empty() {
447                return Some(cn.to_string());
448            }
449        }
450    }
451    None
452}
453
454/// Split a subject string on unescaped commas (RFC 2253 escaping).
455///
456/// A comma preceded by a backslash is part of the value, not a separator.
457#[cfg(target_os = "macos")]
458fn split_rdn(subject: &str) -> Vec<&str> {
459    let mut parts = Vec::new();
460    let mut start = 0;
461    let mut escaped = false;
462    for (i, ch) in subject.char_indices() {
463        if escaped {
464            escaped = false;
465            continue;
466        }
467        if ch == '\\' {
468            escaped = true;
469            continue;
470        }
471        if ch == ',' {
472            parts.push(&subject[start..i]);
473            start = i + ','.len_utf8();
474        }
475    }
476    if start < subject.len() {
477        parts.push(&subject[start..]);
478    }
479    parts
480}
481
482#[cfg(target_os = "linux")]
483fn uninstall_cert_linux(cert_path: &std::path::Path) -> Result<()> {
484    use std::ffi::CString;
485    use std::process::Command;
486
487    let config = get_linux_ca_trust_config();
488    let installed_path = std::path::Path::new(config.cert_dir).join(INSTALLED_CERT_NAME);
489
490    if !installed_path.exists() {
491        return Ok(());
492    }
493
494    // Check write access before attempting removal
495    let has_write_access = {
496        let path_cstr =
497            CString::new(config.cert_dir.as_bytes()).unwrap_or_else(|_| CString::new("/").unwrap());
498        // SAFETY: path_cstr is a valid NUL-terminated C string.
499        unsafe { libc::access(path_cstr.as_ptr(), libc::W_OK) == 0 }
500    };
501
502    if !has_write_access {
503        miette::bail!(
504            "Removing certificates on Linux requires elevated privileges.\n\
505             \n\
506             Run with sudo:\n\
507             sudo pitchfork proxy untrust\n\
508             \n\
509             This removes the certificate from {}/\n\
510             and runs `{}`.",
511            config.cert_dir,
512            config.update_command.join(" ")
513        );
514    }
515
516    // If source cert exists, only remove if contents match (safety check
517    // against deleting a cert we didn't install). If source is gone, remove
518    // unconditionally — we own the file.
519    let should_remove = if cert_path.exists() {
520        let ours = std::fs::read(cert_path).unwrap_or_default();
521        let installed = std::fs::read(&installed_path).unwrap_or_default();
522        ours == installed
523    } else {
524        true
525    };
526
527    if should_remove {
528        std::fs::remove_file(&installed_path)
529            .map_err(|e| miette::miette!("Failed to remove {}: {e}", installed_path.display()))?;
530
531        let status = Command::new(config.update_command[0])
532            .args(&config.update_command[1..])
533            .status()
534            .map_err(|e| {
535                miette::miette!("Failed to run `{}`: {e}", config.update_command.join(" "))
536            })?;
537        if !status.success() {
538            miette::bail!(
539                "`{}` failed (exit code: {}).\n\
540                 The certificate was removed from {} but the system trust store was NOT updated.\n\
541                 To complete the removal manually, run:\n\
542                 sudo {}",
543                config.update_command.join(" "),
544                status.code().unwrap_or(-1),
545                config.cert_dir,
546                config.update_command.join(" ")
547            );
548        }
549    }
550
551    // Verify removal (only possible if source cert exists for content comparison)
552    if cert_path.exists() && is_ca_trusted_linux(cert_path) {
553        miette::bail!(
554            "CA still trusted. Remove {}/{} manually and run `{}`.",
555            config.cert_dir,
556            INSTALLED_CERT_NAME,
557            config.update_command.join(" ")
558        );
559    }
560    Ok(())
561}
562
563// ---------------------------------------------------------------------------
564// auto_trust
565// ---------------------------------------------------------------------------
566
567/// Result of an auto-trust attempt.
568pub enum AutoTrustResult {
569    /// CA was already trusted (no action needed).
570    AlreadyTrusted,
571    /// CA was successfully installed into the system trust store.
572    Trusted,
573    /// Auto-trust was skipped or failed (non-fatal).
574    NotTrusted { reason: String },
575}
576
577/// Attempt to automatically install the CA certificate into the system trust
578/// store during supervisor startup.
579///
580/// This is a best-effort operation: if it fails due to permissions or other
581/// issues, it returns `NotTrusted` instead of an error. The user can then
582/// manually run `pitchfork proxy trust`.
583///
584/// Auto trust may fail silently due to permissions; user can run
585/// `pitchfork proxy trust` manually.
586pub fn auto_trust(cert_path: &std::path::Path) -> AutoTrustResult {
587    if !cert_path.exists() {
588        return AutoTrustResult::NotTrusted {
589            reason: "CA certificate not found".to_string(),
590        };
591    }
592
593    if is_ca_trusted(cert_path) {
594        return AutoTrustResult::AlreadyTrusted;
595    }
596
597    match install_cert(cert_path) {
598        Ok(()) => AutoTrustResult::Trusted,
599        Err(e) => AutoTrustResult::NotTrusted {
600            reason: e.to_string(),
601        },
602    }
603}