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