Skip to main content

cli_core/
ssh_keys.rs

1//! SSH public-key parsing and fingerprinting helpers.
2//!
3//! Lives here (not in the daemon) so both the automation CLI
4//! (`nomisync ssh-key add --key-file …`) and the eventual SSH daemon
5//! compute fingerprints identically. Uses the `ssh-key` crate's
6//! OpenSSH wire format so the SHA-256 fingerprint matches
7//! `ssh-keygen -lf key.pub`.
8
9use ssh_key::{HashAlg, PublicKey};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum SshKeyParseError {
14    #[error("key file not readable: {0}")]
15    Io(#[from] std::io::Error),
16    #[error("key blob not parseable: {0}")]
17    Parse(String),
18}
19
20/// Parsed representation of an OpenSSH public key, ready to hand to
21/// `AddSshKey`.
22#[derive(Debug, Clone)]
23pub struct ParsedKey {
24    /// OpenSSH algorithm name, e.g. `ssh-ed25519`.
25    pub key_type: String,
26    /// Raw wire-format public key blob. Not the base64 form —
27    /// decoded.
28    pub key_blob: Vec<u8>,
29    /// SHA-256 fingerprint as OpenSSH renders it (`SHA256:…` with
30    /// unpadded base64).
31    pub fingerprint: String,
32    /// Comment from the `authorized_keys` line, if any.
33    pub comment: String,
34}
35
36/// Parse a line in `authorized_keys` format (`<type> <base64> [comment]`).
37///
38/// # Errors
39///
40/// Returns `SshKeyParseError::Parse` when the input is not a valid
41/// OpenSSH public key.
42pub fn parse_authorized_keys_line(line: &str) -> Result<ParsedKey, SshKeyParseError> {
43    let key =
44        PublicKey::from_openssh(line.trim()).map_err(|e| SshKeyParseError::Parse(e.to_string()))?;
45    let key_type = key.algorithm().as_str().to_string();
46    let key_blob = key
47        .to_bytes()
48        .map_err(|e| SshKeyParseError::Parse(e.to_string()))?;
49    let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
50    let comment = key.comment().to_string();
51    Ok(ParsedKey {
52        key_type,
53        key_blob,
54        fingerprint,
55        comment,
56    })
57}
58
59/// Read and parse an OpenSSH public-key file (typically ending in
60/// `.pub`).
61///
62/// # Errors
63///
64/// Returns `SshKeyParseError::Io` on filesystem failures,
65/// `SshKeyParseError::Parse` when the file's contents are not a
66/// valid OpenSSH public key.
67pub fn parse_public_key_file(path: &str) -> Result<ParsedKey, SshKeyParseError> {
68    let text = std::fs::read_to_string(path)?;
69    parse_authorized_keys_line(&text)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    // Fixed Ed25519 public key so the fingerprint is deterministic.
77    // Generated with `ssh-keygen -t ed25519 -f /tmp/tk -N ''` then
78    // capturing /tmp/tk.pub verbatim.
79    const SAMPLE_PUB: &str = concat!(
80        "ssh-ed25519 ",
81        "AAAAC3NzaC1lZDI1NTE5AAAAIM4Prl5sFtRqvGeOYeKDx4f1HYpW2v0Z4V6yHlHWJaLi",
82        " alice@laptop",
83    );
84
85    #[test]
86    fn parses_ed25519_line() {
87        let parsed = parse_authorized_keys_line(SAMPLE_PUB).expect("valid ed25519 pubkey");
88        assert_eq!(parsed.key_type, "ssh-ed25519");
89        assert_eq!(parsed.comment, "alice@laptop");
90        assert!(parsed.fingerprint.starts_with("SHA256:"));
91    }
92
93    #[test]
94    fn fingerprint_matches_openssh_convention() {
95        let parsed = parse_authorized_keys_line(SAMPLE_PUB).unwrap();
96        // `SHA256:` prefix + 43-char unpadded base64 body = 50 total.
97        // The body is always 43 chars for SHA-256 unpadded base64.
98        let body = &parsed.fingerprint["SHA256:".len()..];
99        assert_eq!(body.len(), 43);
100    }
101
102    #[test]
103    fn rejects_garbage() {
104        let err = parse_authorized_keys_line("not-a-key").unwrap_err();
105        assert!(matches!(err, SshKeyParseError::Parse(_)));
106    }
107
108    #[test]
109    fn rejects_empty_input() {
110        assert!(matches!(
111            parse_authorized_keys_line(""),
112            Err(SshKeyParseError::Parse(_))
113        ));
114    }
115}