use core::fmt;
use crate::crypto::Sha256;
use crate::encoding::{Base64DecodeError, base64_decode, base64_encode};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshKeyAlgorithm {
Ed25519,
Rsa,
EcdsaNistP256,
EcdsaNistP384,
EcdsaNistP521,
Unknown(String),
}
impl SshKeyAlgorithm {
fn from_wire_name(name: &str) -> Self {
match name {
"ssh-ed25519" => Self::Ed25519,
"ssh-rsa" => Self::Rsa,
"ecdsa-sha2-nistp256" => Self::EcdsaNistP256,
"ecdsa-sha2-nistp384" => Self::EcdsaNistP384,
"ecdsa-sha2-nistp521" => Self::EcdsaNistP521,
other => Self::Unknown(other.to_owned()),
}
}
}
impl fmt::Display for SshKeyAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ed25519 => f.write_str("ssh-ed25519"),
Self::Rsa => f.write_str("ssh-rsa"),
Self::EcdsaNistP256 => f.write_str("ecdsa-sha2-nistp256"),
Self::EcdsaNistP384 => f.write_str("ecdsa-sha2-nistp384"),
Self::EcdsaNistP521 => f.write_str("ecdsa-sha2-nistp521"),
Self::Unknown(s) => f.write_str(s),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SshKeyErrorKind {
InputTooLong,
MissingFields,
InvalidBase64,
BlobTooShort,
InvalidAlgorithmLength,
InvalidAlgorithmName,
AlgorithmMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshKeyError {
kind: SshKeyErrorKind,
}
impl SshKeyError {
const fn new(kind: SshKeyErrorKind) -> Self {
Self { kind }
}
#[must_use]
pub fn is_input_too_long(&self) -> bool {
matches!(self.kind, SshKeyErrorKind::InputTooLong)
}
#[must_use]
pub fn is_missing_fields(&self) -> bool {
matches!(self.kind, SshKeyErrorKind::MissingFields)
}
#[must_use]
pub fn is_invalid_base64(&self) -> bool {
matches!(self.kind, SshKeyErrorKind::InvalidBase64)
}
#[must_use]
pub fn is_blob_too_short(&self) -> bool {
matches!(self.kind, SshKeyErrorKind::BlobTooShort)
}
#[must_use]
pub fn is_invalid_algorithm_length(&self) -> bool {
matches!(self.kind, SshKeyErrorKind::InvalidAlgorithmLength)
}
#[must_use]
pub fn is_invalid_algorithm_name(&self) -> bool {
matches!(self.kind, SshKeyErrorKind::InvalidAlgorithmName)
}
#[must_use]
pub fn is_algorithm_mismatch(&self) -> bool {
matches!(self.kind, SshKeyErrorKind::AlgorithmMismatch)
}
}
impl fmt::Display for SshKeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
SshKeyErrorKind::InputTooLong => {
f.write_str("ssh_key: input exceeds maximum line length")
}
SshKeyErrorKind::MissingFields => {
f.write_str("ssh_key: missing algorithm or key data fields")
}
SshKeyErrorKind::InvalidBase64 => f.write_str("ssh_key: invalid base64 in key data"),
SshKeyErrorKind::BlobTooShort => {
f.write_str("ssh_key: key blob too short for algorithm header")
}
SshKeyErrorKind::InvalidAlgorithmLength => {
f.write_str("ssh_key: algorithm length exceeds blob size")
}
SshKeyErrorKind::InvalidAlgorithmName => {
f.write_str("ssh_key: algorithm name is not valid UTF-8")
}
SshKeyErrorKind::AlgorithmMismatch => {
f.write_str("ssh_key: algorithm prefix does not match wire format")
}
}
}
}
impl std::error::Error for SshKeyError {}
const MAX_LINE_LEN: usize = 16 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshPublicKey {
algorithm: SshKeyAlgorithm,
key_blob: Vec<u8>,
comment: Option<String>,
}
impl SshPublicKey {
pub fn parse(input: &str) -> Result<Self, SshKeyError> {
let input = input.trim();
if input.len() > MAX_LINE_LEN {
return Err(SshKeyError::new(SshKeyErrorKind::InputTooLong));
}
let (algo_prefix, rest) = input
.split_once(char::is_whitespace)
.ok_or(SshKeyError::new(SshKeyErrorKind::MissingFields))?;
let (b64_data, comment) = match rest.trim_start().split_once(char::is_whitespace) {
Some((b64, comment)) => {
let comment = comment.trim();
(b64, (!comment.is_empty()).then(|| comment.to_owned()))
}
None => (rest.trim_start(), None),
};
if algo_prefix.is_empty() || b64_data.is_empty() {
return Err(SshKeyError::new(SshKeyErrorKind::MissingFields));
}
let blob = base64_decode(b64_data)
.map_err(|_: Base64DecodeError| SshKeyError::new(SshKeyErrorKind::InvalidBase64))?;
if blob.len() < 4 {
return Err(SshKeyError::new(SshKeyErrorKind::BlobTooShort));
}
let algo_len = u32::from_be_bytes([blob[0], blob[1], blob[2], blob[3]]) as usize;
if algo_len > blob.len() - 4 {
return Err(SshKeyError::new(SshKeyErrorKind::InvalidAlgorithmLength));
}
let wire_algo = std::str::from_utf8(&blob[4..4 + algo_len])
.map_err(|_| SshKeyError::new(SshKeyErrorKind::InvalidAlgorithmName))?;
if wire_algo != algo_prefix {
return Err(SshKeyError::new(SshKeyErrorKind::AlgorithmMismatch));
}
Ok(Self {
algorithm: SshKeyAlgorithm::from_wire_name(wire_algo),
key_blob: blob,
comment,
})
}
#[must_use]
pub fn fingerprint_sha256(&self) -> String {
let hash = Sha256::digest(&self.key_blob);
let encoded = base64_encode(&hash);
let trimmed = encoded.trim_end_matches('=');
format!("SHA256:{trimmed}")
}
#[must_use]
pub fn algorithm(&self) -> &SshKeyAlgorithm {
&self.algorithm
}
#[must_use]
pub fn comment(&self) -> Option<&str> {
self.comment.as_deref()
}
#[must_use]
pub fn key_blob(&self) -> &[u8] {
&self.key_blob
}
#[must_use]
pub fn to_authorized_keys_line(&self) -> String {
let b64 = base64_encode(&self.key_blob);
match &self.comment {
Some(c) => format!("{} {} {}", self.algorithm, b64, c),
None => format!("{} {}", self.algorithm, b64),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::encoding::hex_encode;
const ED25519_LINE: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHOFsslv7dDSMH6tOWIk2a+jKNOSNi05BQJBKP5HS4Le test@example";
const EXPECTED_ED25519_FINGERPRINT: &str = "SHA256:35egQFxzIJm8xLYiDJBQhZLP+o9hhss8TGs41jDvg8g";
fn make_rsa_line() -> String {
let mut blob = Vec::new();
blob.extend_from_slice(&7u32.to_be_bytes());
blob.extend_from_slice(b"ssh-rsa");
blob.extend_from_slice(&3u32.to_be_bytes());
blob.extend_from_slice(&[0x01, 0x00, 0x01]);
blob.extend_from_slice(&128u32.to_be_bytes());
blob.extend_from_slice(&[0xFF; 128]);
let b64 = crate::encoding::base64_encode(&blob);
format!("ssh-rsa {b64} rsa@example")
}
fn make_ecdsa_p256_line() -> String {
let mut blob = Vec::new();
blob.extend_from_slice(&19u32.to_be_bytes());
blob.extend_from_slice(b"ecdsa-sha2-nistp256");
blob.extend_from_slice(&8u32.to_be_bytes());
blob.extend_from_slice(b"nistp256");
blob.extend_from_slice(&65u32.to_be_bytes());
blob.push(0x04);
blob.extend_from_slice(&[0xAA; 32]);
blob.extend_from_slice(&[0xBB; 32]);
let b64 = crate::encoding::base64_encode(&blob);
format!("ecdsa-sha2-nistp256 {b64} ecdsa@example")
}
#[test]
fn parse_ed25519() {
let key = SshPublicKey::parse(ED25519_LINE).unwrap();
assert_eq!(*key.algorithm(), SshKeyAlgorithm::Ed25519);
}
#[test]
fn parse_rsa() {
let line = make_rsa_line();
let key = SshPublicKey::parse(&line).unwrap();
assert_eq!(*key.algorithm(), SshKeyAlgorithm::Rsa);
}
#[test]
fn parse_ecdsa_p256() {
let line = make_ecdsa_p256_line();
let key = SshPublicKey::parse(&line).unwrap();
assert_eq!(*key.algorithm(), SshKeyAlgorithm::EcdsaNistP256);
}
#[test]
fn fingerprint_sha256_matches_expected() {
let key = SshPublicKey::parse(ED25519_LINE).unwrap();
let fp = key.fingerprint_sha256();
assert_eq!(fp, EXPECTED_ED25519_FINGERPRINT);
assert!(fp.starts_with("SHA256:"));
assert!(!fp.contains('='), "fingerprint should not contain padding");
}
#[test]
fn fingerprint_sha256_deterministic() {
let key1 = SshPublicKey::parse(ED25519_LINE).unwrap();
let key2 = SshPublicKey::parse(ED25519_LINE).unwrap();
assert_eq!(key1.fingerprint_sha256(), key2.fingerprint_sha256());
}
#[test]
fn trailing_bytes_change_the_fingerprint() {
let mut blob = Vec::new();
blob.extend_from_slice(&7u32.to_be_bytes());
blob.extend_from_slice(b"ssh-rsa");
blob.extend_from_slice(&3u32.to_be_bytes());
blob.extend_from_slice(&[0x01, 0x00, 0x01]);
blob.extend_from_slice(&128u32.to_be_bytes());
blob.extend_from_slice(&[0xFF; 128]);
let mut padded = blob.clone();
padded.push(0x00);
let line = format!("ssh-rsa {} k", crate::encoding::base64_encode(&blob));
let padded_line = format!("ssh-rsa {} k", crate::encoding::base64_encode(&padded));
let key = SshPublicKey::parse(&line).unwrap();
let padded_key = SshPublicKey::parse(&padded_line).unwrap();
assert_ne!(
key.fingerprint_sha256(),
padded_key.fingerprint_sha256(),
"trailing bytes must yield a different fingerprint"
);
}
#[test]
fn fingerprint_sha256_is_43_chars_after_prefix() {
let key = SshPublicKey::parse(ED25519_LINE).unwrap();
let fp = key.fingerprint_sha256();
let after_prefix = fp.strip_prefix("SHA256:").unwrap();
assert_eq!(after_prefix.len(), 43);
}
#[test]
fn round_trip_ed25519() {
let key = SshPublicKey::parse(ED25519_LINE).unwrap();
let line = key.to_authorized_keys_line();
let reparsed = SshPublicKey::parse(&line).unwrap();
assert_eq!(key, reparsed);
}
#[test]
fn round_trip_rsa() {
let line = make_rsa_line();
let key = SshPublicKey::parse(&line).unwrap();
let reconstructed = key.to_authorized_keys_line();
let reparsed = SshPublicKey::parse(&reconstructed).unwrap();
assert_eq!(key, reparsed);
}
#[test]
fn round_trip_ecdsa() {
let line = make_ecdsa_p256_line();
let key = SshPublicKey::parse(&line).unwrap();
let reconstructed = key.to_authorized_keys_line();
let reparsed = SshPublicKey::parse(&reconstructed).unwrap();
assert_eq!(key, reparsed);
}
#[test]
fn comment_present() {
let key = SshPublicKey::parse(ED25519_LINE).unwrap();
assert_eq!(key.comment(), Some("test@example"));
}
#[test]
fn comment_absent() {
let parts: Vec<&str> = ED25519_LINE.splitn(3, ' ').collect();
let no_comment = format!("{} {}", parts[0], parts[1]);
let key = SshPublicKey::parse(&no_comment).unwrap();
assert_eq!(key.comment(), None);
}
#[test]
fn comment_with_spaces() {
let parts: Vec<&str> = ED25519_LINE.splitn(3, ' ').collect();
let line = format!("{} {} multi word comment here", parts[0], parts[1]);
let key = SshPublicKey::parse(&line).unwrap();
assert_eq!(key.comment(), Some("multi word comment here"));
}
#[test]
fn parse_tolerates_whitespace_runs() {
let parts: Vec<&str> = ED25519_LINE.splitn(3, ' ').collect();
let line = format!("{} \t {}\t{}", parts[0], parts[1], parts[2]);
let key = SshPublicKey::parse(&line).unwrap();
assert_eq!(*key.algorithm(), SshKeyAlgorithm::Ed25519);
assert_eq!(key.comment(), Some("test@example"));
}
#[test]
fn parse_rejects_overlong_input() {
let line = format!("ssh-ed25519 {}", "A".repeat(MAX_LINE_LEN));
let err = SshPublicKey::parse(&line).unwrap_err();
assert!(err.is_input_too_long(), "got: {err}");
}
#[test]
fn key_blob_matches_decoded_base64() {
let key = SshPublicKey::parse(ED25519_LINE).unwrap();
let parts: Vec<&str> = ED25519_LINE.splitn(3, ' ').collect();
let expected_blob = crate::encoding::base64_decode(parts[1]).unwrap();
assert_eq!(key.key_blob(), expected_blob.as_slice());
}
#[test]
fn error_empty_input() {
let err = SshPublicKey::parse("").unwrap_err();
assert!(err.is_missing_fields());
}
#[test]
fn error_whitespace_only() {
let err = SshPublicKey::parse(" ").unwrap_err();
assert!(err.is_missing_fields());
}
#[test]
fn error_single_field() {
let err = SshPublicKey::parse("ssh-ed25519").unwrap_err();
assert!(err.is_missing_fields());
}
#[test]
fn error_invalid_base64() {
let err = SshPublicKey::parse("ssh-ed25519 !!!invalid!!!").unwrap_err();
assert!(err.is_invalid_base64());
}
#[test]
fn error_blob_too_short() {
let err = SshPublicKey::parse("ssh-ed25519 AAAB").unwrap_err();
assert!(err.is_blob_too_short());
}
#[test]
fn error_algorithm_length_exceeds_blob() {
let mut blob = Vec::new();
blob.extend_from_slice(&255u32.to_be_bytes());
blob.push(0x41); let b64 = crate::encoding::base64_encode(&blob);
let line = format!("ssh-ed25519 {b64}");
let err = SshPublicKey::parse(&line).unwrap_err();
assert!(err.is_invalid_algorithm_length());
}
#[test]
fn error_algorithm_mismatch() {
let parts: Vec<&str> = ED25519_LINE.splitn(3, ' ').collect();
let line = format!("ssh-rsa {}", parts[1]);
let err = SshPublicKey::parse(&line).unwrap_err();
assert!(err.is_algorithm_mismatch());
}
#[test]
fn error_truncated_blob_exactly_4_bytes() {
let mut blob = Vec::new();
blob.extend_from_slice(&1u32.to_be_bytes());
let b64 = crate::encoding::base64_encode(&blob);
let line = format!("x {b64}");
let err = SshPublicKey::parse(&line).unwrap_err();
assert!(err.is_invalid_algorithm_length());
}
#[test]
fn algorithm_display_wire_names() {
assert_eq!(SshKeyAlgorithm::Ed25519.to_string(), "ssh-ed25519");
assert_eq!(SshKeyAlgorithm::Rsa.to_string(), "ssh-rsa");
assert_eq!(
SshKeyAlgorithm::EcdsaNistP256.to_string(),
"ecdsa-sha2-nistp256"
);
assert_eq!(
SshKeyAlgorithm::EcdsaNistP384.to_string(),
"ecdsa-sha2-nistp384"
);
assert_eq!(
SshKeyAlgorithm::EcdsaNistP521.to_string(),
"ecdsa-sha2-nistp521"
);
assert_eq!(
SshKeyAlgorithm::Unknown("custom-algo".to_owned()).to_string(),
"custom-algo"
);
}
#[test]
fn error_display_messages_start_with_ssh_key() {
let cases = [
(
SshKeyErrorKind::MissingFields,
"ssh_key: missing algorithm or key data fields",
),
(
SshKeyErrorKind::InvalidBase64,
"ssh_key: invalid base64 in key data",
),
(
SshKeyErrorKind::BlobTooShort,
"ssh_key: key blob too short for algorithm header",
),
(
SshKeyErrorKind::InvalidAlgorithmLength,
"ssh_key: algorithm length exceeds blob size",
),
(
SshKeyErrorKind::InvalidAlgorithmName,
"ssh_key: algorithm name is not valid UTF-8",
),
(
SshKeyErrorKind::AlgorithmMismatch,
"ssh_key: algorithm prefix does not match wire format",
),
];
for (kind, expected) in &cases {
let err = SshKeyError::new(kind.clone());
let msg = err.to_string();
assert!(
msg.starts_with("ssh_key:"),
"expected Display to start with 'ssh_key:', got: {msg}",
);
assert_eq!(&msg, expected);
}
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> =
Box::new(SshKeyError::new(SshKeyErrorKind::MissingFields));
assert!(err.source().is_none());
let _ = err.to_string();
}
#[test]
fn fingerprint_sha256_matches_manual_computation() {
let key = SshPublicKey::parse(ED25519_LINE).unwrap();
let hash = Sha256::digest(key.key_blob());
let hash_hex = hex_encode(&hash);
let fp = key.fingerprint_sha256();
let fp_b64 = fp.strip_prefix("SHA256:").unwrap();
let fp_bytes = crate::encoding::base64_decode(&format!("{fp_b64}=")).unwrap();
assert_eq!(hex_encode(&fp_bytes), hash_hex);
}
#[test]
fn parse_trims_leading_trailing_whitespace() {
let padded = format!(" {ED25519_LINE} ");
let key = SshPublicKey::parse(&padded).unwrap();
assert_eq!(*key.algorithm(), SshKeyAlgorithm::Ed25519);
}
#[test]
fn parse_unknown_algorithm() {
let algo = "custom-algo-v1";
let mut blob = Vec::new();
blob.extend_from_slice(&u32::try_from(algo.len()).unwrap().to_be_bytes());
blob.extend_from_slice(algo.as_bytes());
blob.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
let b64 = crate::encoding::base64_encode(&blob);
let line = format!("{algo} {b64} custom comment");
let key = SshPublicKey::parse(&line).unwrap();
assert_eq!(
*key.algorithm(),
SshKeyAlgorithm::Unknown("custom-algo-v1".to_owned())
);
assert_eq!(key.comment(), Some("custom comment"));
}
}