use std::str::{self, Utf8Error};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
use sha2::{Digest, Sha256, Sha512};
pub mod agent;
pub mod protocol;
const SSH_ED25519_ALGORITHM: &str = "ssh-ed25519";
const SSHSIG_PREAMBLE: &[u8] = b"SSHSIG";
const DEFAULT_HASH_ALGORITHM: &str = "sha512";
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Ed25519PublicKey([u8; 32]);
impl Ed25519PublicKey {
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn from_blob(blob: &[u8]) -> Result<Self, PublicKeyParseError> {
let mut cursor = blob;
let algorithm = read_ssh_bytes(&mut cursor)?;
let algorithm =
str::from_utf8(algorithm).map_err(PublicKeyParseError::InvalidAlgorithmEncoding)?;
if algorithm != SSH_ED25519_ALGORITHM {
return Err(PublicKeyParseError::UnsupportedAlgorithm {
algorithm: algorithm.to_string(),
});
}
let public_key = read_ssh_bytes(&mut cursor)?;
if !cursor.is_empty() {
return Err(PublicKeyParseError::TrailingBlobData);
}
if public_key.len() != 32 {
return Err(PublicKeyParseError::InvalidKeyLength {
actual: public_key.len(),
});
}
let mut bytes = [0; 32];
bytes.copy_from_slice(public_key);
Ok(Self(bytes))
}
pub fn blob(&self) -> Vec<u8> {
let blob = encode_string(SSH_ED25519_ALGORITHM.as_bytes(), Vec::new());
encode_string(&self.0, blob)
}
pub fn line(&self) -> String {
format!("{SSH_ED25519_ALGORITHM} {}", STANDARD.encode(self.blob()))
}
pub fn fingerprint(&self) -> String {
let digest = Sha256::digest(self.blob());
format!("SHA256:{}", STANDARD_NO_PAD.encode(digest))
}
}
#[derive(Debug, thiserror::Error)]
pub enum PublicKeyParseError {
#[error("missing SSH key algorithm")]
MissingAlgorithm,
#[error("missing SSH public key body")]
MissingBody,
#[error("failed to decode SSH public key body")]
InvalidBase64(#[source] base64::DecodeError),
#[error("unsupported SSH public key algorithm: {algorithm}")]
UnsupportedAlgorithm {
algorithm: String,
},
#[error(
"SSH public key blob algorithm mismatch: expected {line_algorithm}, got {blob_algorithm}"
)]
AlgorithmMismatch {
line_algorithm: String,
blob_algorithm: String,
},
#[error("SSH algorithm was not valid utf-8")]
InvalidAlgorithmEncoding(#[source] Utf8Error),
#[error("truncated SSH string length")]
TruncatedStringLength,
#[error("truncated SSH string body")]
TruncatedStringBody,
#[error("expected 32-byte SSH Ed25519 public key, got {actual} bytes")]
InvalidKeyLength {
actual: usize,
},
#[error("unexpected trailing data in SSH public key blob")]
TrailingBlobData,
}
pub fn parse_public_key_line(line: &str) -> Result<Ed25519PublicKey, PublicKeyParseError> {
let mut parts = line.split_whitespace();
let algorithm = parts.next().ok_or(PublicKeyParseError::MissingAlgorithm)?;
if algorithm != SSH_ED25519_ALGORITHM {
return Err(PublicKeyParseError::UnsupportedAlgorithm {
algorithm: algorithm.to_string(),
});
}
let encoded = parts.next().ok_or(PublicKeyParseError::MissingBody)?;
let blob = STANDARD
.decode(encoded)
.map_err(PublicKeyParseError::InvalidBase64)?;
match Ed25519PublicKey::from_blob(&blob) {
Err(PublicKeyParseError::UnsupportedAlgorithm {
algorithm: blob_algorithm,
}) => Err(PublicKeyParseError::AlgorithmMismatch {
line_algorithm: algorithm.to_string(),
blob_algorithm,
}),
result => result,
}
}
pub fn build_signed_data(namespace: &str, payload: &[u8]) -> Vec<u8> {
let digest = Sha512::digest(payload);
let mut output = Vec::new();
output.extend_from_slice(SSHSIG_PREAMBLE);
output = encode_string(namespace.as_bytes(), output);
output = encode_string(&[], output);
output = encode_string(DEFAULT_HASH_ALGORITHM.as_bytes(), output);
output = encode_string(&digest, output);
output
}
pub fn encode_armored_signature(
public_key_blob: &[u8],
namespace: &str,
signature: &[u8; 64],
) -> String {
let signature_blob = encode_string(
signature,
encode_string(SSH_ED25519_ALGORITHM.as_bytes(), Vec::new()),
);
let mut blob = Vec::new();
blob.extend_from_slice(SSHSIG_PREAMBLE);
blob.extend_from_slice(&1u32.to_be_bytes());
blob = encode_string(public_key_blob, blob);
blob = encode_string(namespace.as_bytes(), blob);
blob = encode_string(&[], blob);
blob = encode_string(DEFAULT_HASH_ALGORITHM.as_bytes(), blob);
blob = encode_string(&signature_blob, blob);
let base64 = STANDARD.encode(blob);
let wrapped = wrap_base64(&base64, 76);
format!(
r#"-----BEGIN SSH SIGNATURE-----
{wrapped}
-----END SSH SIGNATURE-----
"#
)
}
fn encode_string(bytes: &[u8], mut output: Vec<u8>) -> Vec<u8> {
output.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
output.extend_from_slice(bytes);
output
}
fn wrap_base64(input: &str, width: usize) -> String {
let mut lines = Vec::new();
let mut start = 0;
while start < input.len() {
let end = usize::min(start + width, input.len());
lines.push(input[start..end].to_string());
start = end;
}
lines.join("\n")
}
fn read_ssh_bytes<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8], PublicKeyParseError> {
let Some((length, rest)) = cursor.split_first_chunk::<4>() else {
return Err(PublicKeyParseError::TruncatedStringLength);
};
*cursor = rest;
let length = u32::from_be_bytes(*length) as usize;
if cursor.len() < length {
return Err(PublicKeyParseError::TruncatedStringBody);
}
let value = &cursor[..length];
*cursor = &cursor[length..];
Ok(value)
}
#[cfg(test)]
mod tests;