Lines
89.74 %
Functions
37.5 %
Branches
100 %
//! SSH public-key parsing and fingerprinting helpers.
//!
//! Lives here (not in the daemon) so both the automation CLI
//! (`nomisync ssh-key add --key-file …`) and the eventual SSH daemon
//! compute fingerprints identically. Uses the `ssh-key` crate's
//! OpenSSH wire format so the SHA-256 fingerprint matches
//! `ssh-keygen -lf key.pub`.
use ssh_key::{HashAlg, PublicKey};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SshKeyParseError {
#[error("key file not readable: {0}")]
Io(#[from] std::io::Error),
#[error("key blob not parseable: {0}")]
Parse(String),
}
/// Parsed representation of an OpenSSH public key, ready to hand to
/// `AddSshKey`.
#[derive(Debug, Clone)]
pub struct ParsedKey {
/// OpenSSH algorithm name, e.g. `ssh-ed25519`.
pub key_type: String,
/// Raw wire-format public key blob. Not the base64 form —
/// decoded.
pub key_blob: Vec<u8>,
/// SHA-256 fingerprint as OpenSSH renders it (`SHA256:…` with
/// unpadded base64).
pub fingerprint: String,
/// Comment from the `authorized_keys` line, if any.
pub comment: String,
/// Parse a line in `authorized_keys` format (`<type> <base64> [comment]`).
///
/// # Errors
/// Returns `SshKeyParseError::Parse` when the input is not a valid
/// OpenSSH public key.
pub fn parse_authorized_keys_line(line: &str) -> Result<ParsedKey, SshKeyParseError> {
let key =
PublicKey::from_openssh(line.trim()).map_err(|e| SshKeyParseError::Parse(e.to_string()))?;
let key_type = key.algorithm().as_str().to_string();
let key_blob = key
.to_bytes()
.map_err(|e| SshKeyParseError::Parse(e.to_string()))?;
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
let comment = key.comment().to_string();
Ok(ParsedKey {
key_type,
key_blob,
fingerprint,
comment,
})
/// Read and parse an OpenSSH public-key file (typically ending in
/// `.pub`).
/// Returns `SshKeyParseError::Io` on filesystem failures,
/// `SshKeyParseError::Parse` when the file's contents are not a
/// valid OpenSSH public key.
pub fn parse_public_key_file(path: &str) -> Result<ParsedKey, SshKeyParseError> {
let text = std::fs::read_to_string(path)?;
parse_authorized_keys_line(&text)
#[cfg(test)]
mod tests {
use super::*;
// Fixed Ed25519 public key so the fingerprint is deterministic.
// Generated with `ssh-keygen -t ed25519 -f /tmp/tk -N ''` then
// capturing /tmp/tk.pub verbatim.
const SAMPLE_PUB: &str = concat!(
"ssh-ed25519 ",
"AAAAC3NzaC1lZDI1NTE5AAAAIM4Prl5sFtRqvGeOYeKDx4f1HYpW2v0Z4V6yHlHWJaLi",
" alice@laptop",
);
#[test]
fn parses_ed25519_line() {
let parsed = parse_authorized_keys_line(SAMPLE_PUB).expect("valid ed25519 pubkey");
assert_eq!(parsed.key_type, "ssh-ed25519");
assert_eq!(parsed.comment, "alice@laptop");
assert!(parsed.fingerprint.starts_with("SHA256:"));
fn fingerprint_matches_openssh_convention() {
let parsed = parse_authorized_keys_line(SAMPLE_PUB).unwrap();
// `SHA256:` prefix + 43-char unpadded base64 body = 50 total.
// The body is always 43 chars for SHA-256 unpadded base64.
let body = &parsed.fingerprint["SHA256:".len()..];
assert_eq!(body.len(), 43);
fn rejects_garbage() {
let err = parse_authorized_keys_line("not-a-key").unwrap_err();
assert!(matches!(err, SshKeyParseError::Parse(_)));
fn rejects_empty_input() {
assert!(matches!(
parse_authorized_keys_line(""),
Err(SshKeyParseError::Parse(_))
));