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