1use rsa::pkcs1v15::{Signature as RsaSignature, SigningKey as RsaSigningKey, VerifyingKey};
27use rsa::sha2::{Sha256, Sha512};
28use rsa::signature::{SignatureEncoding, Signer, Verifier};
29use rsa::{BigUint, RsaPrivateKey};
30use serde::{Deserialize, Serialize};
31use ssh_key::private::{Ed25519Keypair, KeypairData, PrivateKey, RsaKeypair};
32use ssh_key::public::KeyData;
33use ssh_key::{HashAlg, LineEnding, PublicKey, SshSig};
34use std::str::FromStr;
35use zeroize::Zeroizing;
36
37use crate::error::CoreError;
38
39pub const RSA_BITS: usize = 3072;
42
43pub const SSH_SIG_NAMESPACE: &str = "kovra";
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum KeyAlgorithm {
52 Ed25519,
54 Rsa,
56}
57
58impl KeyAlgorithm {
59 pub fn parse(s: &str) -> Result<Self, CoreError> {
61 match s.to_ascii_lowercase().as_str() {
62 "ed25519" => Ok(KeyAlgorithm::Ed25519),
63 "rsa" => Ok(KeyAlgorithm::Rsa),
64 other => Err(CoreError::Keypair(format!(
65 "unknown key algorithm `{other}` (expected ed25519|rsa)"
66 ))),
67 }
68 }
69
70 pub fn as_str(&self) -> &'static str {
72 match self {
73 KeyAlgorithm::Ed25519 => "ed25519",
74 KeyAlgorithm::Rsa => "rsa",
75 }
76 }
77
78 pub fn supports_encryption(&self) -> bool {
81 matches!(self, KeyAlgorithm::Ed25519)
82 }
83}
84
85pub struct GeneratedKeypair {
89 pub algorithm: KeyAlgorithm,
91 pub private_openssh: Zeroizing<String>,
93 pub public_openssh: String,
95}
96
97pub fn generate(algorithm: KeyAlgorithm) -> Result<GeneratedKeypair, CoreError> {
101 let mut rng = rand::rngs::OsRng;
102 let private_key = match algorithm {
103 KeyAlgorithm::Ed25519 => {
104 let kp = Ed25519Keypair::random(&mut rng);
105 PrivateKey::from(kp)
106 }
107 KeyAlgorithm::Rsa => {
108 let base = RsaPrivateKey::new(&mut rng, RSA_BITS)
113 .map_err(|e| CoreError::Keypair(format!("rsa keygen: {e}")))?;
114 let kp = RsaKeypair::try_from(base)
115 .map_err(|e| CoreError::Keypair(format!("rsa keypair wrap: {e}")))?;
116 PrivateKey::from(kp)
117 }
118 };
119 let private_openssh = private_key
120 .to_openssh(LineEnding::LF)
121 .map_err(|e| CoreError::Keypair(format!("encode private key: {e}")))?
122 .to_string();
123 let public_openssh = private_key
124 .public_key()
125 .to_openssh()
126 .map_err(|e| CoreError::Keypair(format!("encode public key: {e}")))?;
127 Ok(GeneratedKeypair {
128 algorithm,
129 private_openssh: Zeroizing::new(private_openssh),
130 public_openssh,
131 })
132}
133
134pub fn public_algorithm(public_openssh: &str) -> Result<KeyAlgorithm, CoreError> {
137 let pk = PublicKey::from_openssh(public_openssh)
138 .map_err(|_| invalid("not an OpenSSH public key"))?;
139 algorithm_of_key_data(pk.key_data())
140}
141
142pub fn public_from_private(private_openssh: &str) -> Result<String, CoreError> {
146 let pk = PrivateKey::from_openssh(private_openssh)
147 .map_err(|_| invalid("not an OpenSSH private key"))?;
148 pk.public_key()
149 .to_openssh()
150 .map_err(|e| CoreError::Keypair(format!("encode public key: {e}")))
151}
152
153pub fn sign(private_openssh: &str, data: &[u8]) -> Result<String, CoreError> {
162 let pk = PrivateKey::from_openssh(private_openssh)
163 .map_err(|_| invalid("not an OpenSSH private key"))?;
164 match pk.key_data() {
165 KeypairData::Ed25519(_) => {
166 let sig: SshSig = pk
167 .sign(SSH_SIG_NAMESPACE, HashAlg::Sha512, data)
168 .map_err(|_| CoreError::Keypair("ed25519 signing failed".to_string()))?;
169 sig.to_pem(LineEnding::LF)
170 .map_err(|e| CoreError::Keypair(format!("encode signature: {e}")))
171 }
172 KeypairData::Rsa(rsa_kp) => {
173 let priv_rsa = rsa_private_from_components(rsa_kp)?;
174 let signing = RsaSigningKey::<Sha256>::new(priv_rsa);
175 let sig = signing.sign(data);
176 Ok(hex(&sig.to_vec()))
177 }
178 _ => Err(invalid("unsupported key algorithm for signing")),
179 }
180}
181
182pub const SSH_AGENT_RSA_SHA2_256: u32 = 0x02;
187pub const SSH_AGENT_RSA_SHA2_512: u32 = 0x04;
189
190pub fn sign_ssh_agent(
208 private_openssh: &str,
209 data: &[u8],
210 flags: u32,
211) -> Result<Vec<u8>, CoreError> {
212 let pk = PrivateKey::from_openssh(private_openssh)
213 .map_err(|_| invalid("not an OpenSSH private key"))?;
214 match pk.key_data() {
215 KeypairData::Ed25519(_) => {
216 use rsa::signature::Signer as _;
221 use ssh_key::Signature as SshKeySignature;
222 let sig: SshKeySignature = pk
223 .try_sign(data)
224 .map_err(|_| CoreError::Keypair("ed25519 ssh-agent signing failed".to_string()))?;
225 Ok(encode_signature(b"ssh-ed25519", sig.as_bytes()))
226 }
227 KeypairData::Rsa(rsa_kp) => {
228 let priv_rsa = rsa_private_from_components(rsa_kp)?;
229 if flags & SSH_AGENT_RSA_SHA2_512 != 0 {
230 let sig = RsaSigningKey::<Sha512>::new(priv_rsa).sign(data);
231 Ok(encode_signature(b"rsa-sha2-512", &sig.to_vec()))
232 } else if flags & SSH_AGENT_RSA_SHA2_256 != 0 {
233 let sig = RsaSigningKey::<Sha256>::new(priv_rsa).sign(data);
234 Ok(encode_signature(b"rsa-sha2-256", &sig.to_vec()))
235 } else {
236 let sig = RsaSigningKey::<sha1::Sha1>::new(priv_rsa).sign(data);
240 Ok(encode_signature(b"ssh-rsa", &sig.to_vec()))
241 }
242 }
243 _ => Err(invalid("unsupported key algorithm for ssh-agent signing")),
244 }
245}
246
247pub fn public_key_blob(public_openssh: &str) -> Result<Vec<u8>, CoreError> {
254 use ssh_encoding::Encode;
255 let pk = PublicKey::from_openssh(public_openssh)
256 .map_err(|_| invalid("not an OpenSSH public key"))?;
257 let mut blob = Vec::new();
258 pk.key_data()
259 .encode(&mut blob)
260 .map_err(|e| CoreError::Keypair(format!("encode public key blob: {e}")))?;
261 Ok(blob)
262}
263
264fn encode_signature(algorithm: &[u8], blob: &[u8]) -> Vec<u8> {
266 let mut out = Vec::with_capacity(8 + algorithm.len() + blob.len());
267 write_string(&mut out, algorithm);
268 write_string(&mut out, blob);
269 out
270}
271
272pub fn verify(public_openssh: &str, data: &[u8], signature: &str) -> Result<bool, CoreError> {
276 let pk = PublicKey::from_openssh(public_openssh)
277 .map_err(|_| invalid("not an OpenSSH public key"))?;
278 match pk.key_data() {
279 KeyData::Ed25519(_) => {
280 let sig = match SshSig::from_pem(signature.trim().as_bytes()) {
284 Ok(s) => s,
285 Err(_) => return Ok(false),
289 };
290 Ok(pk.verify(SSH_SIG_NAMESPACE, data, &sig).is_ok())
291 }
292 KeyData::Rsa(rsa_pub) => {
293 let pub_rsa: rsa::RsaPublicKey = rsa_pub
294 .try_into()
295 .map_err(|_| invalid("malformed RSA public key"))?;
296 let bytes = match unhex(signature) {
297 Some(b) => b,
298 None => return Ok(false),
299 };
300 let sig = match RsaSignature::try_from(bytes.as_slice()) {
301 Ok(s) => s,
302 Err(_) => return Ok(false),
303 };
304 let verifying = VerifyingKey::<Sha256>::new(pub_rsa);
305 Ok(verifying.verify(data, &sig).is_ok())
306 }
307 _ => Err(invalid("unsupported key algorithm for verification")),
308 }
309}
310
311pub fn encrypt_to(public_openssh: &str, plaintext: &[u8]) -> Result<Vec<u8>, CoreError> {
314 let recipient = age::ssh::Recipient::from_str(public_openssh.trim())
315 .map_err(|_| invalid("not an ed25519 OpenSSH public key (encryption is ed25519-only)"))?;
316 if public_algorithm(public_openssh).ok() != Some(KeyAlgorithm::Ed25519) {
319 return Err(invalid(
320 "RSA keys cannot be used for encryption (encryption is ed25519-only)",
321 ));
322 }
323 age::encrypt(&recipient, plaintext).map_err(|e| CoreError::Keypair(format!("encrypt: {e}")))
324}
325
326pub fn decrypt(private_openssh: &str, ciphertext: &[u8]) -> Result<Zeroizing<Vec<u8>>, CoreError> {
329 let identity = age::ssh::Identity::from_buffer(private_openssh.as_bytes(), None)
330 .map_err(|_| invalid("not an ed25519 OpenSSH private key (decryption is ed25519-only)"))?;
331 let plaintext = age::decrypt(&identity, ciphertext)
332 .map_err(|_| CoreError::Keypair("decryption failed".to_string()))?;
333 Ok(Zeroizing::new(plaintext))
334}
335
336pub trait SshAgent {
344 fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError>;
347}
348
349#[derive(Debug, Default, Clone, Copy)]
357pub struct EnvSshAgent;
358
359const SSH_AGENTC_ADD_IDENTITY: u8 = 17;
362const SSH_AGENT_SUCCESS: u8 = 6;
364
365impl SshAgent for EnvSshAgent {
366 fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError> {
367 use std::io::{Read, Write};
368 use std::os::unix::net::UnixStream;
369
370 let sock = std::env::var_os("SSH_AUTH_SOCK")
371 .ok_or_else(|| CoreError::Keypair("SSH_AUTH_SOCK is not set (no ssh-agent)".into()))?;
372 let pk = PrivateKey::from_openssh(private_openssh)
373 .map_err(|_| invalid("not an OpenSSH private key"))?;
374
375 let body = encode_add_identity(&pk, comment)?;
380 let mut frame = Vec::with_capacity(5 + body.len());
381 frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
382 frame.extend_from_slice(&body);
383
384 let mut stream = UnixStream::connect(&sock)
385 .map_err(|e| CoreError::Keypair(format!("connect ssh-agent: {e}")))?;
386 stream
387 .write_all(&frame)
388 .map_err(|e| CoreError::Keypair(format!("write ssh-agent: {e}")))?;
389
390 let mut len_buf = [0u8; 4];
391 stream
392 .read_exact(&mut len_buf)
393 .map_err(|e| CoreError::Keypair(format!("read ssh-agent: {e}")))?;
394 let reply_len = u32::from_be_bytes(len_buf) as usize;
395 if reply_len == 0 {
396 return Err(CoreError::Keypair("empty ssh-agent reply".into()));
397 }
398 let mut reply = vec![0u8; reply_len];
399 stream
400 .read_exact(&mut reply)
401 .map_err(|e| CoreError::Keypair(format!("read ssh-agent: {e}")))?;
402 if reply[0] == SSH_AGENT_SUCCESS {
403 Ok(())
404 } else {
405 Err(CoreError::Keypair(
406 "ssh-agent refused the identity".to_string(),
407 ))
408 }
409 }
410}
411
412fn encode_add_identity(pk: &PrivateKey, comment: &str) -> Result<Zeroizing<Vec<u8>>, CoreError> {
414 use ssh_encoding::Encode;
415
416 let mut out = Zeroizing::new(Vec::new());
417 out.push(SSH_AGENTC_ADD_IDENTITY);
418 write_string(&mut out, pk.algorithm().as_str().as_bytes());
420 let mut blob = Zeroizing::new(Vec::new());
422 pk.key_data()
423 .encode(&mut *blob)
424 .map_err(|e| CoreError::Keypair(format!("encode agent key: {e}")))?;
425 out.extend_from_slice(&blob);
426 write_string(&mut out, comment.as_bytes());
428 Ok(out)
429}
430
431pub fn write_string(out: &mut Vec<u8>, bytes: &[u8]) {
435 out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
436 out.extend_from_slice(bytes);
437}
438
439#[derive(Default)]
443pub struct MockSshAgent {
444 added: std::sync::Mutex<Vec<(String, String)>>,
445}
446
447impl MockSshAgent {
448 pub fn new() -> Self {
450 Self::default()
451 }
452
453 pub fn added(&self) -> Vec<(String, String)> {
455 self.added.lock().expect("agent mutex poisoned").clone()
456 }
457}
458
459impl SshAgent for MockSshAgent {
460 fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError> {
461 PrivateKey::from_openssh(private_openssh)
464 .map_err(|_| invalid("not an OpenSSH private key"))?;
465 self.added
466 .lock()
467 .expect("agent mutex poisoned")
468 .push((private_openssh.to_string(), comment.to_string()));
469 Ok(())
470 }
471}
472
473fn rsa_private_from_components(kp: &RsaKeypair) -> Result<RsaPrivateKey, CoreError> {
482 let n = BigUint::try_from(&kp.public.n).map_err(|_| invalid("malformed RSA modulus"))?;
483 let e = BigUint::try_from(&kp.public.e).map_err(|_| invalid("malformed RSA exponent"))?;
484 let d =
485 BigUint::try_from(&kp.private.d).map_err(|_| invalid("malformed RSA private exponent"))?;
486 let p = BigUint::try_from(&kp.private.p).map_err(|_| invalid("malformed RSA prime p"))?;
487 let q = BigUint::try_from(&kp.private.q).map_err(|_| invalid("malformed RSA prime q"))?;
488 RsaPrivateKey::from_components(n, e, d, vec![p, q])
489 .map_err(|e| CoreError::Keypair(format!("reconstruct RSA key: {e}")))
490}
491
492fn algorithm_of_key_data(kd: &KeyData) -> Result<KeyAlgorithm, CoreError> {
494 match kd {
495 KeyData::Ed25519(_) => Ok(KeyAlgorithm::Ed25519),
496 KeyData::Rsa(_) => Ok(KeyAlgorithm::Rsa),
497 _ => Err(invalid(
498 "unsupported key algorithm (expected ed25519 or rsa)",
499 )),
500 }
501}
502
503fn invalid(msg: &str) -> CoreError {
505 CoreError::Keypair(msg.to_string())
506}
507
508fn hex(bytes: &[u8]) -> String {
510 let mut s = String::with_capacity(bytes.len() * 2);
511 for b in bytes {
512 s.push_str(&format!("{b:02x}"));
513 }
514 s
515}
516
517fn unhex(s: &str) -> Option<Vec<u8>> {
519 let s = s.trim();
520 if !s.len().is_multiple_of(2) {
521 return None;
522 }
523 let mut out = Vec::with_capacity(s.len() / 2);
524 let bytes = s.as_bytes();
525 for pair in bytes.chunks(2) {
526 let hi = (pair[0] as char).to_digit(16)?;
527 let lo = (pair[1] as char).to_digit(16)?;
528 out.push((hi * 16 + lo) as u8);
529 }
530 Some(out)
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
539 fn ed25519_keygen_is_openssh_valid() {
540 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
541 assert!(kp.public_openssh.starts_with("ssh-ed25519 "));
542 assert_eq!(
543 public_algorithm(&kp.public_openssh).unwrap(),
544 KeyAlgorithm::Ed25519
545 );
546 assert_eq!(
548 public_from_private(&kp.private_openssh).unwrap(),
549 kp.public_openssh
550 );
551 assert!(PrivateKey::from_openssh(&kp.private_openssh).is_ok());
553 }
554
555 #[test]
556 fn rsa_keygen_is_openssh_valid() {
557 let kp = generate(KeyAlgorithm::Rsa).unwrap();
558 assert!(kp.public_openssh.starts_with("ssh-rsa "));
559 assert_eq!(
560 public_algorithm(&kp.public_openssh).unwrap(),
561 KeyAlgorithm::Rsa
562 );
563 }
564
565 #[test]
566 fn ed25519_sign_verify_round_trip() {
567 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
568 let sig = sign(&kp.private_openssh, b"deploy v2").unwrap();
569 assert!(verify(&kp.public_openssh, b"deploy v2", &sig).unwrap());
570 assert!(!verify(&kp.public_openssh, b"deploy v3", &sig).unwrap());
572 let other = generate(KeyAlgorithm::Ed25519).unwrap();
574 assert!(!verify(&other.public_openssh, b"deploy v2", &sig).unwrap());
575 }
576
577 #[test]
580 fn ed25519_verify_tolerates_trailing_newline() {
581 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
582 let mut sig = sign(&kp.private_openssh, b"attest this").unwrap();
583 sig.push('\n');
584 assert!(verify(&kp.public_openssh, b"attest this", &sig).unwrap());
585 }
586
587 #[test]
588 fn rsa_sign_verify_round_trip() {
589 let kp = generate(KeyAlgorithm::Rsa).unwrap();
590 let sig = sign(&kp.private_openssh, b"payload").unwrap();
591 assert!(verify(&kp.public_openssh, b"payload", &sig).unwrap());
592 assert!(!verify(&kp.public_openssh, b"payloae", &sig).unwrap());
593 }
594
595 #[test]
596 fn ed25519_encrypt_decrypt_round_trip() {
597 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
598 let msg = b"a small secret message";
599 let ct = encrypt_to(&kp.public_openssh, msg).unwrap();
600 assert_ne!(ct, msg, "ciphertext must differ from plaintext");
601 let pt = decrypt(&kp.private_openssh, &ct).unwrap();
602 assert_eq!(&*pt, msg);
603 let other = generate(KeyAlgorithm::Ed25519).unwrap();
605 assert!(decrypt(&other.private_openssh, &ct).is_err());
606 }
607
608 #[test]
610 fn rsa_encryption_is_rejected() {
611 let kp = generate(KeyAlgorithm::Rsa).unwrap();
612 assert!(!KeyAlgorithm::Rsa.supports_encryption());
613 let err = encrypt_to(&kp.public_openssh, b"x").unwrap_err();
614 assert!(matches!(err, CoreError::Keypair(_)));
615 }
616
617 #[test]
619 fn mock_ssh_agent_records_added_key() {
620 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
621 let agent = MockSshAgent::new();
622 agent
623 .add_identity(&kp.private_openssh, "kovra:dev/ssh/deploy")
624 .unwrap();
625 let added = agent.added();
626 assert_eq!(added.len(), 1);
627 assert_eq!(added[0].1, "kovra:dev/ssh/deploy");
628 assert!(agent.add_identity("not a key", "c").is_err());
630 }
631
632 #[test]
636 fn ed25519_sign_ssh_agent_blob_verifies() {
637 use ssh_encoding::Decode;
638 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
639 let challenge = b"ssh session challenge bytes";
640 let blob = sign_ssh_agent(&kp.private_openssh, challenge, 0).unwrap();
641 let mut reader = blob.as_slice();
643 let alg = String::decode(&mut reader).unwrap();
644 assert_eq!(alg, "ssh-ed25519");
645 let sig_bytes = Vec::<u8>::decode(&mut reader).unwrap();
646 assert_eq!(sig_bytes.len(), 64, "ed25519 raw signature is 64 bytes");
647 use rsa::signature::Verifier as _;
651 use ssh_key::{Algorithm, Signature as SshKeySignature};
652 let pk = PublicKey::from_openssh(&kp.public_openssh).unwrap();
653 let sig = SshKeySignature::new(Algorithm::Ed25519, sig_bytes).unwrap();
654 assert!(pk.key_data().verify(challenge, &sig).is_ok());
655 assert!(pk.key_data().verify(b"other challenge", &sig).is_err());
657 }
658
659 #[test]
662 fn rsa_sign_ssh_agent_honors_flags() {
663 use ssh_encoding::Decode;
664 let kp = generate(KeyAlgorithm::Rsa).unwrap();
665 let challenge = b"challenge";
666 let cases = [
667 (SSH_AGENT_RSA_SHA2_256, "rsa-sha2-256"),
668 (SSH_AGENT_RSA_SHA2_512, "rsa-sha2-512"),
669 (0, "ssh-rsa"),
670 ];
671 for (flags, expected) in cases {
672 let blob = sign_ssh_agent(&kp.private_openssh, challenge, flags).unwrap();
673 let mut reader = blob.as_slice();
674 let alg = String::decode(&mut reader).unwrap();
675 assert_eq!(alg, expected, "flags {flags:#x} → {expected}");
676 let sig = Vec::<u8>::decode(&mut reader).unwrap();
677 assert!(!sig.is_empty());
678 }
679 }
680
681 #[test]
684 fn public_key_blob_round_trips() {
685 use ssh_encoding::Decode;
686 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
687 let blob = public_key_blob(&kp.public_openssh).unwrap();
688 let decoded = KeyData::decode(&mut blob.as_slice()).unwrap();
689 let original = PublicKey::from_openssh(&kp.public_openssh).unwrap();
690 assert_eq!(&decoded, original.key_data());
691 }
692
693 #[test]
694 fn algorithm_parse_round_trips() {
695 assert_eq!(
696 KeyAlgorithm::parse("ed25519").unwrap(),
697 KeyAlgorithm::Ed25519
698 );
699 assert_eq!(KeyAlgorithm::parse("RSA").unwrap(), KeyAlgorithm::Rsa);
700 assert!(KeyAlgorithm::parse("dsa").is_err());
701 }
702
703 #[test]
704 fn hex_round_trips() {
705 let bytes = [0x00u8, 0xff, 0x10, 0xab, 0x7e];
706 assert_eq!(unhex(&hex(&bytes)).unwrap(), bytes);
707 assert!(unhex("xyz").is_none());
708 assert!(unhex("abc").is_none()); }
710}