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    // Bounded: these are the only two reads in the crate that take an operator-supplied
39    // path with no cap. PEM material is kilobytes; refusing beyond `MAX_PEM_FILE_BYTES`
40    // costs nothing legitimate and removes the one unbounded allocation an argument
41    // could steer.
42    let cert_bytes = crate::paths::read_bytes_capped(cert_src, crate::paths::MAX_PEM_FILE_BYTES)
43        .map_err(|e| SshCliError::tls_msg(format!("read {}: {e}", cert_src.display())))?;
44    let key_bytes = crate::paths::read_bytes_capped(key_src, crate::paths::MAX_PEM_FILE_BYTES)
45        .map_err(|e| SshCliError::tls_msg(format!("read {}: {e}", key_src.display())))?;
46    write_secret_file(&cert_path, &cert_bytes)?;
47    write_secret_file(&key_path, &key_bytes)?;
48
49    Ok(MtlsIdentity {
50        name: name.to_owned(),
51        cert_path,
52        key_path,
53    })
54}
55
56/// Lists imported mTLS identity names.
57pub fn mtls_list(config_override: Option<&Path>) -> SshCliResult<Vec<String>> {
58    let root =
59        super::paths::resolve_tls_root(config_override)?.join(crate::constants::TLS_MTLS_DIR_NAME);
60    if !root.exists() {
61        return Ok(Vec::new());
62    }
63    let mut names = Vec::new();
64    for entry in
65        std::fs::read_dir(&root).map_err(|e| SshCliError::tls_msg(format!("list mtls: {e}")))?
66    {
67        let entry = entry.map_err(|e| SshCliError::tls_msg(format!("list mtls entry: {e}")))?;
68        if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
69            if let Some(n) = entry.file_name().to_str() {
70                let cert = cert_pem_path(&entry.path());
71                let key = key_pem_path(&entry.path());
72                if cert.is_file() && key.is_file() {
73                    names.push(n.to_owned());
74                }
75            }
76        }
77    }
78    names.sort();
79    Ok(names)
80}
81
82/// Shows paths for one identity.
83pub fn mtls_show(config_override: Option<&Path>, name: &str) -> SshCliResult<MtlsIdentity> {
84    let dir = mtls_identity_dir(config_override, name)?;
85    let cert_path = cert_pem_path(&dir);
86    let key_path = key_pem_path(&dir);
87    if !cert_path.is_file() || !key_path.is_file() {
88        return Err(SshCliError::FileNotFound(format!(
89            "mTLS identity '{name}' not found under {}",
90            dir.display()
91        )));
92    }
93    // Parse to ensure integrity.
94    let _ = load_cert_chain(&cert_path)?;
95    let _ = load_private_key(&key_path)?;
96    Ok(MtlsIdentity {
97        name: name.to_owned(),
98        cert_path,
99        key_path,
100    })
101}
102
103/// Removes an identity directory.
104pub fn mtls_remove(config_override: Option<&Path>, name: &str) -> SshCliResult<()> {
105    let dir = mtls_identity_dir(config_override, name)?;
106    if !dir.exists() {
107        return Err(SshCliError::FileNotFound(format!(
108            "mTLS identity '{name}' not found"
109        )));
110    }
111    std::fs::remove_dir_all(&dir)
112        .map_err(|e| SshCliError::tls_msg(format!("remove mTLS '{name}': {e}")))?;
113    Ok(())
114}
115
116/// Resolves mTLS paths: either explicit paths or an XDG identity name.
117pub fn resolve_mtls_paths(
118    config_override: Option<&Path>,
119    identity: Option<&str>,
120    cert: Option<&Path>,
121    key: Option<&Path>,
122) -> SshCliResult<(Option<PathBuf>, Option<PathBuf>)> {
123    if let Some(id) = identity {
124        let show = mtls_show(config_override, id)?;
125        return Ok((Some(show.cert_path), Some(show.key_path)));
126    }
127    Ok((cert.map(Path::to_path_buf), key.map(Path::to_path_buf)))
128}