Skip to main content

ssh_cli/tls/
mtls.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![forbid(unsafe_code)]
3//! mTLS client identity store under XDG `tls/mtls/<name>/`.
4
5use std::path::{Path, PathBuf};
6
7use super::paths::{cert_pem_path, ensure_dir, key_pem_path, mtls_identity_dir, write_secret_file};
8use super::pem::{load_cert_chain, load_private_key};
9use crate::errors::{SshCliError, SshCliResult};
10
11/// Named mTLS client identity on disk.
12#[derive(Debug, Clone)]
13pub struct MtlsIdentity {
14    /// Logical name (XDG leaf).
15    pub name: String,
16    /// Absolute path to certificate chain PEM.
17    pub cert_path: PathBuf,
18    /// Absolute path to private key PEM.
19    pub key_path: PathBuf,
20}
21
22/// Imports PEM cert+key into XDG as identity `name` (overwrites).
23pub fn mtls_import(
24    config_override: Option<&Path>,
25    name: &str,
26    cert_src: &Path,
27    key_src: &Path,
28) -> SshCliResult<MtlsIdentity> {
29    // Validate PEMs before writing.
30    let _ = load_cert_chain(cert_src)?;
31    let _ = load_private_key(key_src)?;
32
33    let dir = mtls_identity_dir(config_override, name)?;
34    ensure_dir(&dir)?;
35    let cert_path = cert_pem_path(&dir);
36    let key_path = key_pem_path(&dir);
37
38    let cert_bytes = std::fs::read(cert_src)
39        .map_err(|e| SshCliError::tls_msg(format!("read {}: {e}", cert_src.display())))?;
40    let key_bytes = std::fs::read(key_src)
41        .map_err(|e| SshCliError::tls_msg(format!("read {}: {e}", key_src.display())))?;
42    write_secret_file(&cert_path, &cert_bytes)?;
43    write_secret_file(&key_path, &key_bytes)?;
44
45    Ok(MtlsIdentity {
46        name: name.to_owned(),
47        cert_path,
48        key_path,
49    })
50}
51
52/// Lists imported mTLS identity names.
53pub fn mtls_list(config_override: Option<&Path>) -> SshCliResult<Vec<String>> {
54    let root =
55        super::paths::resolve_tls_root(config_override)?.join(crate::constants::TLS_MTLS_DIR_NAME);
56    if !root.exists() {
57        return Ok(Vec::new());
58    }
59    let mut names = Vec::new();
60    for entry in
61        std::fs::read_dir(&root).map_err(|e| SshCliError::tls_msg(format!("list mtls: {e}")))?
62    {
63        let entry = entry.map_err(|e| SshCliError::tls_msg(format!("list mtls entry: {e}")))?;
64        if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
65            if let Some(n) = entry.file_name().to_str() {
66                let cert = cert_pem_path(&entry.path());
67                let key = key_pem_path(&entry.path());
68                if cert.is_file() && key.is_file() {
69                    names.push(n.to_owned());
70                }
71            }
72        }
73    }
74    names.sort();
75    Ok(names)
76}
77
78/// Shows paths for one identity.
79pub fn mtls_show(config_override: Option<&Path>, name: &str) -> SshCliResult<MtlsIdentity> {
80    let dir = mtls_identity_dir(config_override, name)?;
81    let cert_path = cert_pem_path(&dir);
82    let key_path = key_pem_path(&dir);
83    if !cert_path.is_file() || !key_path.is_file() {
84        return Err(SshCliError::FileNotFound(format!(
85            "mTLS identity '{name}' not found under {}",
86            dir.display()
87        )));
88    }
89    // Parse to ensure integrity.
90    let _ = load_cert_chain(&cert_path)?;
91    let _ = load_private_key(&key_path)?;
92    Ok(MtlsIdentity {
93        name: name.to_owned(),
94        cert_path,
95        key_path,
96    })
97}
98
99/// Removes an identity directory.
100pub fn mtls_remove(config_override: Option<&Path>, name: &str) -> SshCliResult<()> {
101    let dir = mtls_identity_dir(config_override, name)?;
102    if !dir.exists() {
103        return Err(SshCliError::FileNotFound(format!(
104            "mTLS identity '{name}' not found"
105        )));
106    }
107    std::fs::remove_dir_all(&dir)
108        .map_err(|e| SshCliError::tls_msg(format!("remove mTLS '{name}': {e}")))?;
109    Ok(())
110}
111
112/// Resolves mTLS paths: either explicit paths or an XDG identity name.
113pub fn resolve_mtls_paths(
114    config_override: Option<&Path>,
115    identity: Option<&str>,
116    cert: Option<&Path>,
117    key: Option<&Path>,
118) -> SshCliResult<(Option<PathBuf>, Option<PathBuf>)> {
119    if let Some(id) = identity {
120        let show = mtls_show(config_override, id)?;
121        return Ok((Some(show.cert_path), Some(show.key_path)));
122    }
123    Ok((cert.map(Path::to_path_buf), key.map(Path::to_path_buf)))
124}