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

            
9
use ssh_key::{HashAlg, PublicKey};
10
use thiserror::Error;
11

            
12
#[derive(Debug, Error)]
13
pub 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)]
23
pub 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.
42
4
pub fn parse_authorized_keys_line(line: &str) -> Result<ParsedKey, SshKeyParseError> {
43
2
    let key =
44
4
        PublicKey::from_openssh(line.trim()).map_err(|e| SshKeyParseError::Parse(e.to_string()))?;
45
2
    let key_type = key.algorithm().as_str().to_string();
46
2
    let key_blob = key
47
2
        .to_bytes()
48
2
        .map_err(|e| SshKeyParseError::Parse(e.to_string()))?;
49
2
    let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
50
2
    let comment = key.comment().to_string();
51
2
    Ok(ParsedKey {
52
2
        key_type,
53
2
        key_blob,
54
2
        fingerprint,
55
2
        comment,
56
2
    })
57
4
}
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.
67
pub 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)]
73
mod 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
1
    fn parses_ed25519_line() {
87
1
        let parsed = parse_authorized_keys_line(SAMPLE_PUB).expect("valid ed25519 pubkey");
88
1
        assert_eq!(parsed.key_type, "ssh-ed25519");
89
1
        assert_eq!(parsed.comment, "alice@laptop");
90
1
        assert!(parsed.fingerprint.starts_with("SHA256:"));
91
1
    }
92

            
93
    #[test]
94
1
    fn fingerprint_matches_openssh_convention() {
95
1
        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
1
        let body = &parsed.fingerprint["SHA256:".len()..];
99
1
        assert_eq!(body.len(), 43);
100
1
    }
101

            
102
    #[test]
103
1
    fn rejects_garbage() {
104
1
        let err = parse_authorized_keys_line("not-a-key").unwrap_err();
105
1
        assert!(matches!(err, SshKeyParseError::Parse(_)));
106
1
    }
107

            
108
    #[test]
109
1
    fn rejects_empty_input() {
110
1
        assert!(matches!(
111
1
            parse_authorized_keys_line(""),
112
            Err(SshKeyParseError::Parse(_))
113
        ));
114
1
    }
115
}