use rsa::pkcs1v15::{Signature as RsaSignature, SigningKey as RsaSigningKey, VerifyingKey};
use rsa::sha2::{Sha256, Sha512};
use rsa::signature::{SignatureEncoding, Signer, Verifier};
use rsa::{BigUint, RsaPrivateKey};
use serde::{Deserialize, Serialize};
use ssh_key::private::{Ed25519Keypair, KeypairData, PrivateKey, RsaKeypair};
use ssh_key::public::KeyData;
use ssh_key::{HashAlg, LineEnding, PublicKey, SshSig};
use std::str::FromStr;
use zeroize::Zeroizing;
use crate::error::CoreError;
pub const RSA_BITS: usize = 3072;
pub const SSH_SIG_NAMESPACE: &str = "kovra";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KeyAlgorithm {
Ed25519,
Rsa,
}
impl KeyAlgorithm {
pub fn parse(s: &str) -> Result<Self, CoreError> {
match s.to_ascii_lowercase().as_str() {
"ed25519" => Ok(KeyAlgorithm::Ed25519),
"rsa" => Ok(KeyAlgorithm::Rsa),
other => Err(CoreError::Keypair(format!(
"unknown key algorithm `{other}` (expected ed25519|rsa)"
))),
}
}
pub fn as_str(&self) -> &'static str {
match self {
KeyAlgorithm::Ed25519 => "ed25519",
KeyAlgorithm::Rsa => "rsa",
}
}
pub fn supports_encryption(&self) -> bool {
matches!(self, KeyAlgorithm::Ed25519)
}
}
pub struct GeneratedKeypair {
pub algorithm: KeyAlgorithm,
pub private_openssh: Zeroizing<String>,
pub public_openssh: String,
}
pub fn generate(algorithm: KeyAlgorithm) -> Result<GeneratedKeypair, CoreError> {
let mut rng = rand::rngs::OsRng;
let private_key = match algorithm {
KeyAlgorithm::Ed25519 => {
let kp = Ed25519Keypair::random(&mut rng);
PrivateKey::from(kp)
}
KeyAlgorithm::Rsa => {
let base = RsaPrivateKey::new(&mut rng, RSA_BITS)
.map_err(|e| CoreError::Keypair(format!("rsa keygen: {e}")))?;
let kp = RsaKeypair::try_from(base)
.map_err(|e| CoreError::Keypair(format!("rsa keypair wrap: {e}")))?;
PrivateKey::from(kp)
}
};
let private_openssh = private_key
.to_openssh(LineEnding::LF)
.map_err(|e| CoreError::Keypair(format!("encode private key: {e}")))?
.to_string();
let public_openssh = private_key
.public_key()
.to_openssh()
.map_err(|e| CoreError::Keypair(format!("encode public key: {e}")))?;
Ok(GeneratedKeypair {
algorithm,
private_openssh: Zeroizing::new(private_openssh),
public_openssh,
})
}
pub fn public_algorithm(public_openssh: &str) -> Result<KeyAlgorithm, CoreError> {
let pk = PublicKey::from_openssh(public_openssh)
.map_err(|_| invalid("not an OpenSSH public key"))?;
algorithm_of_key_data(pk.key_data())
}
pub fn public_from_private(private_openssh: &str) -> Result<String, CoreError> {
let pk = PrivateKey::from_openssh(private_openssh)
.map_err(|_| invalid("not an OpenSSH private key"))?;
pk.public_key()
.to_openssh()
.map_err(|e| CoreError::Keypair(format!("encode public key: {e}")))
}
pub fn sign(private_openssh: &str, data: &[u8]) -> Result<String, CoreError> {
let pk = PrivateKey::from_openssh(private_openssh)
.map_err(|_| invalid("not an OpenSSH private key"))?;
match pk.key_data() {
KeypairData::Ed25519(_) => {
let sig: SshSig = pk
.sign(SSH_SIG_NAMESPACE, HashAlg::Sha512, data)
.map_err(|_| CoreError::Keypair("ed25519 signing failed".to_string()))?;
sig.to_pem(LineEnding::LF)
.map_err(|e| CoreError::Keypair(format!("encode signature: {e}")))
}
KeypairData::Rsa(rsa_kp) => {
let priv_rsa = rsa_private_from_components(rsa_kp)?;
let signing = RsaSigningKey::<Sha256>::new(priv_rsa);
let sig = signing.sign(data);
Ok(hex(&sig.to_vec()))
}
_ => Err(invalid("unsupported key algorithm for signing")),
}
}
pub const SSH_AGENT_RSA_SHA2_256: u32 = 0x02;
pub const SSH_AGENT_RSA_SHA2_512: u32 = 0x04;
pub fn sign_ssh_agent(
private_openssh: &str,
data: &[u8],
flags: u32,
) -> Result<Vec<u8>, CoreError> {
let pk = PrivateKey::from_openssh(private_openssh)
.map_err(|_| invalid("not an OpenSSH private key"))?;
match pk.key_data() {
KeypairData::Ed25519(_) => {
use rsa::signature::Signer as _;
use ssh_key::Signature as SshKeySignature;
let sig: SshKeySignature = pk
.try_sign(data)
.map_err(|_| CoreError::Keypair("ed25519 ssh-agent signing failed".to_string()))?;
Ok(encode_signature(b"ssh-ed25519", sig.as_bytes()))
}
KeypairData::Rsa(rsa_kp) => {
let priv_rsa = rsa_private_from_components(rsa_kp)?;
if flags & SSH_AGENT_RSA_SHA2_512 != 0 {
let sig = RsaSigningKey::<Sha512>::new(priv_rsa).sign(data);
Ok(encode_signature(b"rsa-sha2-512", &sig.to_vec()))
} else if flags & SSH_AGENT_RSA_SHA2_256 != 0 {
let sig = RsaSigningKey::<Sha256>::new(priv_rsa).sign(data);
Ok(encode_signature(b"rsa-sha2-256", &sig.to_vec()))
} else {
let sig = RsaSigningKey::<sha1::Sha1>::new(priv_rsa).sign(data);
Ok(encode_signature(b"ssh-rsa", &sig.to_vec()))
}
}
_ => Err(invalid("unsupported key algorithm for ssh-agent signing")),
}
}
pub fn public_key_blob(public_openssh: &str) -> Result<Vec<u8>, CoreError> {
use ssh_encoding::Encode;
let pk = PublicKey::from_openssh(public_openssh)
.map_err(|_| invalid("not an OpenSSH public key"))?;
let mut blob = Vec::new();
pk.key_data()
.encode(&mut blob)
.map_err(|e| CoreError::Keypair(format!("encode public key blob: {e}")))?;
Ok(blob)
}
fn encode_signature(algorithm: &[u8], blob: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + algorithm.len() + blob.len());
write_string(&mut out, algorithm);
write_string(&mut out, blob);
out
}
pub fn verify(public_openssh: &str, data: &[u8], signature: &str) -> Result<bool, CoreError> {
let pk = PublicKey::from_openssh(public_openssh)
.map_err(|_| invalid("not an OpenSSH public key"))?;
match pk.key_data() {
KeyData::Ed25519(_) => {
let sig = match SshSig::from_pem(signature.trim().as_bytes()) {
Ok(s) => s,
Err(_) => return Ok(false),
};
Ok(pk.verify(SSH_SIG_NAMESPACE, data, &sig).is_ok())
}
KeyData::Rsa(rsa_pub) => {
let pub_rsa: rsa::RsaPublicKey = rsa_pub
.try_into()
.map_err(|_| invalid("malformed RSA public key"))?;
let bytes = match unhex(signature) {
Some(b) => b,
None => return Ok(false),
};
let sig = match RsaSignature::try_from(bytes.as_slice()) {
Ok(s) => s,
Err(_) => return Ok(false),
};
let verifying = VerifyingKey::<Sha256>::new(pub_rsa);
Ok(verifying.verify(data, &sig).is_ok())
}
_ => Err(invalid("unsupported key algorithm for verification")),
}
}
pub fn encrypt_to(public_openssh: &str, plaintext: &[u8]) -> Result<Vec<u8>, CoreError> {
let recipient = age::ssh::Recipient::from_str(public_openssh.trim())
.map_err(|_| invalid("not an ed25519 OpenSSH public key (encryption is ed25519-only)"))?;
if public_algorithm(public_openssh).ok() != Some(KeyAlgorithm::Ed25519) {
return Err(invalid(
"RSA keys cannot be used for encryption (encryption is ed25519-only)",
));
}
age::encrypt(&recipient, plaintext).map_err(|e| CoreError::Keypair(format!("encrypt: {e}")))
}
pub fn decrypt(private_openssh: &str, ciphertext: &[u8]) -> Result<Zeroizing<Vec<u8>>, CoreError> {
let identity = age::ssh::Identity::from_buffer(private_openssh.as_bytes(), None)
.map_err(|_| invalid("not an ed25519 OpenSSH private key (decryption is ed25519-only)"))?;
let plaintext = age::decrypt(&identity, ciphertext)
.map_err(|_| CoreError::Keypair("decryption failed".to_string()))?;
Ok(Zeroizing::new(plaintext))
}
pub trait SshAgent {
fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct EnvSshAgent;
const SSH_AGENTC_ADD_IDENTITY: u8 = 17;
const SSH_AGENT_SUCCESS: u8 = 6;
impl SshAgent for EnvSshAgent {
fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError> {
let pk = PrivateKey::from_openssh(private_openssh)
.map_err(|_| invalid("not an OpenSSH private key"))?;
let body = encode_add_identity(&pk, comment)?;
let mut frame = Vec::with_capacity(5 + body.len());
frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
frame.extend_from_slice(&body);
#[cfg(unix)]
{
use std::os::unix::net::UnixStream;
let sock = std::env::var_os("SSH_AUTH_SOCK").ok_or_else(|| {
CoreError::Keypair("SSH_AUTH_SOCK is not set (no ssh-agent)".into())
})?;
let stream = UnixStream::connect(&sock)
.map_err(|e| CoreError::Keypair(format!("connect ssh-agent: {e}")))?;
add_identity_over(stream, &frame)
}
#[cfg(windows)]
{
use std::fs::OpenOptions;
let pipe = std::env::var_os("SSH_AUTH_SOCK")
.unwrap_or_else(|| r"\\.\pipe\openssh-ssh-agent".into());
let stream = OpenOptions::new()
.read(true)
.write(true)
.open(&pipe)
.map_err(|e| CoreError::Keypair(format!("connect ssh-agent: {e}")))?;
add_identity_over(stream, &frame)
}
#[cfg(not(any(unix, windows)))]
{
let _ = &frame;
Err(CoreError::Keypair(
"ssh-agent integration is not supported on this platform".into(),
))
}
}
}
fn add_identity_over<S: std::io::Read + std::io::Write>(
mut stream: S,
frame: &[u8],
) -> Result<(), CoreError> {
stream
.write_all(frame)
.map_err(|e| CoreError::Keypair(format!("write ssh-agent: {e}")))?;
let mut len_buf = [0u8; 4];
stream
.read_exact(&mut len_buf)
.map_err(|e| CoreError::Keypair(format!("read ssh-agent: {e}")))?;
let reply_len = u32::from_be_bytes(len_buf) as usize;
if reply_len == 0 {
return Err(CoreError::Keypair("empty ssh-agent reply".into()));
}
let mut reply = vec![0u8; reply_len];
stream
.read_exact(&mut reply)
.map_err(|e| CoreError::Keypair(format!("read ssh-agent: {e}")))?;
if reply[0] == SSH_AGENT_SUCCESS {
Ok(())
} else {
Err(CoreError::Keypair(
"ssh-agent refused the identity".to_string(),
))
}
}
fn encode_add_identity(pk: &PrivateKey, comment: &str) -> Result<Zeroizing<Vec<u8>>, CoreError> {
use ssh_encoding::Encode;
let mut out = Zeroizing::new(Vec::new());
out.push(SSH_AGENTC_ADD_IDENTITY);
write_string(&mut out, pk.algorithm().as_str().as_bytes());
let mut blob = Zeroizing::new(Vec::new());
pk.key_data()
.encode(&mut *blob)
.map_err(|e| CoreError::Keypair(format!("encode agent key: {e}")))?;
out.extend_from_slice(&blob);
write_string(&mut out, comment.as_bytes());
Ok(out)
}
pub fn write_string(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
#[derive(Default)]
pub struct MockSshAgent {
added: std::sync::Mutex<Vec<(String, String)>>,
}
impl MockSshAgent {
pub fn new() -> Self {
Self::default()
}
pub fn added(&self) -> Vec<(String, String)> {
self.added.lock().expect("agent mutex poisoned").clone()
}
}
impl SshAgent for MockSshAgent {
fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError> {
PrivateKey::from_openssh(private_openssh)
.map_err(|_| invalid("not an OpenSSH private key"))?;
self.added
.lock()
.expect("agent mutex poisoned")
.push((private_openssh.to_string(), comment.to_string()));
Ok(())
}
}
fn rsa_private_from_components(kp: &RsaKeypair) -> Result<RsaPrivateKey, CoreError> {
let n = BigUint::try_from(&kp.public.n).map_err(|_| invalid("malformed RSA modulus"))?;
let e = BigUint::try_from(&kp.public.e).map_err(|_| invalid("malformed RSA exponent"))?;
let d =
BigUint::try_from(&kp.private.d).map_err(|_| invalid("malformed RSA private exponent"))?;
let p = BigUint::try_from(&kp.private.p).map_err(|_| invalid("malformed RSA prime p"))?;
let q = BigUint::try_from(&kp.private.q).map_err(|_| invalid("malformed RSA prime q"))?;
RsaPrivateKey::from_components(n, e, d, vec![p, q])
.map_err(|e| CoreError::Keypair(format!("reconstruct RSA key: {e}")))
}
fn algorithm_of_key_data(kd: &KeyData) -> Result<KeyAlgorithm, CoreError> {
match kd {
KeyData::Ed25519(_) => Ok(KeyAlgorithm::Ed25519),
KeyData::Rsa(_) => Ok(KeyAlgorithm::Rsa),
_ => Err(invalid(
"unsupported key algorithm (expected ed25519 or rsa)",
)),
}
}
fn invalid(msg: &str) -> CoreError {
CoreError::Keypair(msg.to_string())
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn unhex(s: &str) -> Option<Vec<u8>> {
let s = s.trim();
if !s.len().is_multiple_of(2) {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
let bytes = s.as_bytes();
for pair in bytes.chunks(2) {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out.push((hi * 16 + lo) as u8);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ed25519_keygen_is_openssh_valid() {
let kp = generate(KeyAlgorithm::Ed25519).unwrap();
assert!(kp.public_openssh.starts_with("ssh-ed25519 "));
assert_eq!(
public_algorithm(&kp.public_openssh).unwrap(),
KeyAlgorithm::Ed25519
);
assert_eq!(
public_from_private(&kp.private_openssh).unwrap(),
kp.public_openssh
);
assert!(PrivateKey::from_openssh(&kp.private_openssh).is_ok());
}
#[test]
fn rsa_keygen_is_openssh_valid() {
let kp = generate(KeyAlgorithm::Rsa).unwrap();
assert!(kp.public_openssh.starts_with("ssh-rsa "));
assert_eq!(
public_algorithm(&kp.public_openssh).unwrap(),
KeyAlgorithm::Rsa
);
}
#[test]
fn ed25519_sign_verify_round_trip() {
let kp = generate(KeyAlgorithm::Ed25519).unwrap();
let sig = sign(&kp.private_openssh, b"deploy v2").unwrap();
assert!(verify(&kp.public_openssh, b"deploy v2", &sig).unwrap());
assert!(!verify(&kp.public_openssh, b"deploy v3", &sig).unwrap());
let other = generate(KeyAlgorithm::Ed25519).unwrap();
assert!(!verify(&other.public_openssh, b"deploy v2", &sig).unwrap());
}
#[test]
fn ed25519_verify_tolerates_trailing_newline() {
let kp = generate(KeyAlgorithm::Ed25519).unwrap();
let mut sig = sign(&kp.private_openssh, b"attest this").unwrap();
sig.push('\n');
assert!(verify(&kp.public_openssh, b"attest this", &sig).unwrap());
}
#[test]
fn rsa_sign_verify_round_trip() {
let kp = generate(KeyAlgorithm::Rsa).unwrap();
let sig = sign(&kp.private_openssh, b"payload").unwrap();
assert!(verify(&kp.public_openssh, b"payload", &sig).unwrap());
assert!(!verify(&kp.public_openssh, b"payloae", &sig).unwrap());
}
#[test]
fn ed25519_encrypt_decrypt_round_trip() {
let kp = generate(KeyAlgorithm::Ed25519).unwrap();
let msg = b"a small secret message";
let ct = encrypt_to(&kp.public_openssh, msg).unwrap();
assert_ne!(ct, msg, "ciphertext must differ from plaintext");
let pt = decrypt(&kp.private_openssh, &ct).unwrap();
assert_eq!(&*pt, msg);
let other = generate(KeyAlgorithm::Ed25519).unwrap();
assert!(decrypt(&other.private_openssh, &ct).is_err());
}
#[test]
fn rsa_encryption_is_rejected() {
let kp = generate(KeyAlgorithm::Rsa).unwrap();
assert!(!KeyAlgorithm::Rsa.supports_encryption());
let err = encrypt_to(&kp.public_openssh, b"x").unwrap_err();
assert!(matches!(err, CoreError::Keypair(_)));
}
#[test]
fn mock_ssh_agent_records_added_key() {
let kp = generate(KeyAlgorithm::Ed25519).unwrap();
let agent = MockSshAgent::new();
agent
.add_identity(&kp.private_openssh, "kovra:dev/ssh/deploy")
.unwrap();
let added = agent.added();
assert_eq!(added.len(), 1);
assert_eq!(added[0].1, "kovra:dev/ssh/deploy");
assert!(agent.add_identity("not a key", "c").is_err());
}
#[test]
fn ed25519_sign_ssh_agent_blob_verifies() {
use ssh_encoding::Decode;
let kp = generate(KeyAlgorithm::Ed25519).unwrap();
let challenge = b"ssh session challenge bytes";
let blob = sign_ssh_agent(&kp.private_openssh, challenge, 0).unwrap();
let mut reader = blob.as_slice();
let alg = String::decode(&mut reader).unwrap();
assert_eq!(alg, "ssh-ed25519");
let sig_bytes = Vec::<u8>::decode(&mut reader).unwrap();
assert_eq!(sig_bytes.len(), 64, "ed25519 raw signature is 64 bytes");
use rsa::signature::Verifier as _;
use ssh_key::{Algorithm, Signature as SshKeySignature};
let pk = PublicKey::from_openssh(&kp.public_openssh).unwrap();
let sig = SshKeySignature::new(Algorithm::Ed25519, sig_bytes).unwrap();
assert!(pk.key_data().verify(challenge, &sig).is_ok());
assert!(pk.key_data().verify(b"other challenge", &sig).is_err());
}
#[test]
fn rsa_sign_ssh_agent_honors_flags() {
use ssh_encoding::Decode;
let kp = generate(KeyAlgorithm::Rsa).unwrap();
let challenge = b"challenge";
let cases = [
(SSH_AGENT_RSA_SHA2_256, "rsa-sha2-256"),
(SSH_AGENT_RSA_SHA2_512, "rsa-sha2-512"),
(0, "ssh-rsa"),
];
for (flags, expected) in cases {
let blob = sign_ssh_agent(&kp.private_openssh, challenge, flags).unwrap();
let mut reader = blob.as_slice();
let alg = String::decode(&mut reader).unwrap();
assert_eq!(alg, expected, "flags {flags:#x} → {expected}");
let sig = Vec::<u8>::decode(&mut reader).unwrap();
assert!(!sig.is_empty());
}
}
#[test]
fn public_key_blob_round_trips() {
use ssh_encoding::Decode;
let kp = generate(KeyAlgorithm::Ed25519).unwrap();
let blob = public_key_blob(&kp.public_openssh).unwrap();
let decoded = KeyData::decode(&mut blob.as_slice()).unwrap();
let original = PublicKey::from_openssh(&kp.public_openssh).unwrap();
assert_eq!(&decoded, original.key_data());
}
#[test]
fn algorithm_parse_round_trips() {
assert_eq!(
KeyAlgorithm::parse("ed25519").unwrap(),
KeyAlgorithm::Ed25519
);
assert_eq!(KeyAlgorithm::parse("RSA").unwrap(), KeyAlgorithm::Rsa);
assert!(KeyAlgorithm::parse("dsa").is_err());
}
#[test]
fn hex_round_trips() {
let bytes = [0x00u8, 0xff, 0x10, 0xab, 0x7e];
assert_eq!(unhex(&hex(&bytes)).unwrap(), bytes);
assert!(unhex("xyz").is_none());
assert!(unhex("abc").is_none()); }
}