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)]
358pub struct EnvSshAgent;
359
360const SSH_AGENTC_ADD_IDENTITY: u8 = 17;
363const SSH_AGENT_SUCCESS: u8 = 6;
365
366impl SshAgent for EnvSshAgent {
367 fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError> {
368 let pk = PrivateKey::from_openssh(private_openssh)
369 .map_err(|_| invalid("not an OpenSSH private key"))?;
370
371 let body = encode_add_identity(&pk, comment)?;
376 let mut frame = Vec::with_capacity(5 + body.len());
377 frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
378 frame.extend_from_slice(&body);
379
380 #[cfg(unix)]
384 {
385 use std::os::unix::net::UnixStream;
386 let sock = std::env::var_os("SSH_AUTH_SOCK").ok_or_else(|| {
387 CoreError::Keypair("SSH_AUTH_SOCK is not set (no ssh-agent)".into())
388 })?;
389 let stream = UnixStream::connect(&sock)
390 .map_err(|e| CoreError::Keypair(format!("connect ssh-agent: {e}")))?;
391 add_identity_over(stream, &frame)
392 }
393 #[cfg(windows)]
394 {
395 use std::fs::OpenOptions;
399 let pipe = std::env::var_os("SSH_AUTH_SOCK")
400 .unwrap_or_else(|| r"\\.\pipe\openssh-ssh-agent".into());
401 let stream = OpenOptions::new()
402 .read(true)
403 .write(true)
404 .open(&pipe)
405 .map_err(|e| CoreError::Keypair(format!("connect ssh-agent: {e}")))?;
406 add_identity_over(stream, &frame)
407 }
408 #[cfg(not(any(unix, windows)))]
409 {
410 let _ = &frame;
411 Err(CoreError::Keypair(
412 "ssh-agent integration is not supported on this platform".into(),
413 ))
414 }
415 }
416}
417
418fn add_identity_over<S: std::io::Read + std::io::Write>(
422 mut stream: S,
423 frame: &[u8],
424) -> Result<(), CoreError> {
425 stream
426 .write_all(frame)
427 .map_err(|e| CoreError::Keypair(format!("write ssh-agent: {e}")))?;
428
429 let mut len_buf = [0u8; 4];
430 stream
431 .read_exact(&mut len_buf)
432 .map_err(|e| CoreError::Keypair(format!("read ssh-agent: {e}")))?;
433 let reply_len = u32::from_be_bytes(len_buf) as usize;
434 if reply_len == 0 {
435 return Err(CoreError::Keypair("empty ssh-agent reply".into()));
436 }
437 let mut reply = vec![0u8; reply_len];
438 stream
439 .read_exact(&mut reply)
440 .map_err(|e| CoreError::Keypair(format!("read ssh-agent: {e}")))?;
441 if reply[0] == SSH_AGENT_SUCCESS {
442 Ok(())
443 } else {
444 Err(CoreError::Keypair(
445 "ssh-agent refused the identity".to_string(),
446 ))
447 }
448}
449
450fn encode_add_identity(pk: &PrivateKey, comment: &str) -> Result<Zeroizing<Vec<u8>>, CoreError> {
452 use ssh_encoding::Encode;
453
454 let mut out = Zeroizing::new(Vec::new());
455 out.push(SSH_AGENTC_ADD_IDENTITY);
456 write_string(&mut out, pk.algorithm().as_str().as_bytes());
458 let mut blob = Zeroizing::new(Vec::new());
460 pk.key_data()
461 .encode(&mut *blob)
462 .map_err(|e| CoreError::Keypair(format!("encode agent key: {e}")))?;
463 out.extend_from_slice(&blob);
464 write_string(&mut out, comment.as_bytes());
466 Ok(out)
467}
468
469pub fn write_string(out: &mut Vec<u8>, bytes: &[u8]) {
473 out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
474 out.extend_from_slice(bytes);
475}
476
477#[derive(Default)]
481pub struct MockSshAgent {
482 added: std::sync::Mutex<Vec<(String, String)>>,
483}
484
485impl MockSshAgent {
486 pub fn new() -> Self {
488 Self::default()
489 }
490
491 pub fn added(&self) -> Vec<(String, String)> {
493 self.added.lock().expect("agent mutex poisoned").clone()
494 }
495}
496
497impl SshAgent for MockSshAgent {
498 fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError> {
499 PrivateKey::from_openssh(private_openssh)
502 .map_err(|_| invalid("not an OpenSSH private key"))?;
503 self.added
504 .lock()
505 .expect("agent mutex poisoned")
506 .push((private_openssh.to_string(), comment.to_string()));
507 Ok(())
508 }
509}
510
511fn rsa_private_from_components(kp: &RsaKeypair) -> Result<RsaPrivateKey, CoreError> {
520 let n = BigUint::try_from(&kp.public.n).map_err(|_| invalid("malformed RSA modulus"))?;
521 let e = BigUint::try_from(&kp.public.e).map_err(|_| invalid("malformed RSA exponent"))?;
522 let d =
523 BigUint::try_from(&kp.private.d).map_err(|_| invalid("malformed RSA private exponent"))?;
524 let p = BigUint::try_from(&kp.private.p).map_err(|_| invalid("malformed RSA prime p"))?;
525 let q = BigUint::try_from(&kp.private.q).map_err(|_| invalid("malformed RSA prime q"))?;
526 RsaPrivateKey::from_components(n, e, d, vec![p, q])
527 .map_err(|e| CoreError::Keypair(format!("reconstruct RSA key: {e}")))
528}
529
530fn algorithm_of_key_data(kd: &KeyData) -> Result<KeyAlgorithm, CoreError> {
532 match kd {
533 KeyData::Ed25519(_) => Ok(KeyAlgorithm::Ed25519),
534 KeyData::Rsa(_) => Ok(KeyAlgorithm::Rsa),
535 _ => Err(invalid(
536 "unsupported key algorithm (expected ed25519 or rsa)",
537 )),
538 }
539}
540
541fn invalid(msg: &str) -> CoreError {
543 CoreError::Keypair(msg.to_string())
544}
545
546fn hex(bytes: &[u8]) -> String {
548 let mut s = String::with_capacity(bytes.len() * 2);
549 for b in bytes {
550 s.push_str(&format!("{b:02x}"));
551 }
552 s
553}
554
555fn unhex(s: &str) -> Option<Vec<u8>> {
557 let s = s.trim();
558 if !s.len().is_multiple_of(2) {
559 return None;
560 }
561 let mut out = Vec::with_capacity(s.len() / 2);
562 let bytes = s.as_bytes();
563 for pair in bytes.chunks(2) {
564 let hi = (pair[0] as char).to_digit(16)?;
565 let lo = (pair[1] as char).to_digit(16)?;
566 out.push((hi * 16 + lo) as u8);
567 }
568 Some(out)
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
577 fn ed25519_keygen_is_openssh_valid() {
578 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
579 assert!(kp.public_openssh.starts_with("ssh-ed25519 "));
580 assert_eq!(
581 public_algorithm(&kp.public_openssh).unwrap(),
582 KeyAlgorithm::Ed25519
583 );
584 assert_eq!(
586 public_from_private(&kp.private_openssh).unwrap(),
587 kp.public_openssh
588 );
589 assert!(PrivateKey::from_openssh(&kp.private_openssh).is_ok());
591 }
592
593 #[test]
594 fn rsa_keygen_is_openssh_valid() {
595 let kp = generate(KeyAlgorithm::Rsa).unwrap();
596 assert!(kp.public_openssh.starts_with("ssh-rsa "));
597 assert_eq!(
598 public_algorithm(&kp.public_openssh).unwrap(),
599 KeyAlgorithm::Rsa
600 );
601 }
602
603 #[test]
604 fn ed25519_sign_verify_round_trip() {
605 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
606 let sig = sign(&kp.private_openssh, b"deploy v2").unwrap();
607 assert!(verify(&kp.public_openssh, b"deploy v2", &sig).unwrap());
608 assert!(!verify(&kp.public_openssh, b"deploy v3", &sig).unwrap());
610 let other = generate(KeyAlgorithm::Ed25519).unwrap();
612 assert!(!verify(&other.public_openssh, b"deploy v2", &sig).unwrap());
613 }
614
615 #[test]
618 fn ed25519_verify_tolerates_trailing_newline() {
619 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
620 let mut sig = sign(&kp.private_openssh, b"attest this").unwrap();
621 sig.push('\n');
622 assert!(verify(&kp.public_openssh, b"attest this", &sig).unwrap());
623 }
624
625 #[test]
626 fn rsa_sign_verify_round_trip() {
627 let kp = generate(KeyAlgorithm::Rsa).unwrap();
628 let sig = sign(&kp.private_openssh, b"payload").unwrap();
629 assert!(verify(&kp.public_openssh, b"payload", &sig).unwrap());
630 assert!(!verify(&kp.public_openssh, b"payloae", &sig).unwrap());
631 }
632
633 #[test]
634 fn ed25519_encrypt_decrypt_round_trip() {
635 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
636 let msg = b"a small secret message";
637 let ct = encrypt_to(&kp.public_openssh, msg).unwrap();
638 assert_ne!(ct, msg, "ciphertext must differ from plaintext");
639 let pt = decrypt(&kp.private_openssh, &ct).unwrap();
640 assert_eq!(&*pt, msg);
641 let other = generate(KeyAlgorithm::Ed25519).unwrap();
643 assert!(decrypt(&other.private_openssh, &ct).is_err());
644 }
645
646 #[test]
648 fn rsa_encryption_is_rejected() {
649 let kp = generate(KeyAlgorithm::Rsa).unwrap();
650 assert!(!KeyAlgorithm::Rsa.supports_encryption());
651 let err = encrypt_to(&kp.public_openssh, b"x").unwrap_err();
652 assert!(matches!(err, CoreError::Keypair(_)));
653 }
654
655 #[test]
657 fn mock_ssh_agent_records_added_key() {
658 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
659 let agent = MockSshAgent::new();
660 agent
661 .add_identity(&kp.private_openssh, "kovra:dev/ssh/deploy")
662 .unwrap();
663 let added = agent.added();
664 assert_eq!(added.len(), 1);
665 assert_eq!(added[0].1, "kovra:dev/ssh/deploy");
666 assert!(agent.add_identity("not a key", "c").is_err());
668 }
669
670 #[test]
674 fn ed25519_sign_ssh_agent_blob_verifies() {
675 use ssh_encoding::Decode;
676 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
677 let challenge = b"ssh session challenge bytes";
678 let blob = sign_ssh_agent(&kp.private_openssh, challenge, 0).unwrap();
679 let mut reader = blob.as_slice();
681 let alg = String::decode(&mut reader).unwrap();
682 assert_eq!(alg, "ssh-ed25519");
683 let sig_bytes = Vec::<u8>::decode(&mut reader).unwrap();
684 assert_eq!(sig_bytes.len(), 64, "ed25519 raw signature is 64 bytes");
685 use rsa::signature::Verifier as _;
689 use ssh_key::{Algorithm, Signature as SshKeySignature};
690 let pk = PublicKey::from_openssh(&kp.public_openssh).unwrap();
691 let sig = SshKeySignature::new(Algorithm::Ed25519, sig_bytes).unwrap();
692 assert!(pk.key_data().verify(challenge, &sig).is_ok());
693 assert!(pk.key_data().verify(b"other challenge", &sig).is_err());
695 }
696
697 #[test]
700 fn rsa_sign_ssh_agent_honors_flags() {
701 use ssh_encoding::Decode;
702 let kp = generate(KeyAlgorithm::Rsa).unwrap();
703 let challenge = b"challenge";
704 let cases = [
705 (SSH_AGENT_RSA_SHA2_256, "rsa-sha2-256"),
706 (SSH_AGENT_RSA_SHA2_512, "rsa-sha2-512"),
707 (0, "ssh-rsa"),
708 ];
709 for (flags, expected) in cases {
710 let blob = sign_ssh_agent(&kp.private_openssh, challenge, flags).unwrap();
711 let mut reader = blob.as_slice();
712 let alg = String::decode(&mut reader).unwrap();
713 assert_eq!(alg, expected, "flags {flags:#x} → {expected}");
714 let sig = Vec::<u8>::decode(&mut reader).unwrap();
715 assert!(!sig.is_empty());
716 }
717 }
718
719 #[test]
722 fn public_key_blob_round_trips() {
723 use ssh_encoding::Decode;
724 let kp = generate(KeyAlgorithm::Ed25519).unwrap();
725 let blob = public_key_blob(&kp.public_openssh).unwrap();
726 let decoded = KeyData::decode(&mut blob.as_slice()).unwrap();
727 let original = PublicKey::from_openssh(&kp.public_openssh).unwrap();
728 assert_eq!(&decoded, original.key_data());
729 }
730
731 #[test]
732 fn algorithm_parse_round_trips() {
733 assert_eq!(
734 KeyAlgorithm::parse("ed25519").unwrap(),
735 KeyAlgorithm::Ed25519
736 );
737 assert_eq!(KeyAlgorithm::parse("RSA").unwrap(), KeyAlgorithm::Rsa);
738 assert!(KeyAlgorithm::parse("dsa").is_err());
739 }
740
741 #[test]
742 fn hex_round_trips() {
743 let bytes = [0x00u8, 0xff, 0x10, 0xab, 0x7e];
744 assert_eq!(unhex(&hex(&bytes)).unwrap(), bytes);
745 assert!(unhex("xyz").is_none());
746 assert!(unhex("abc").is_none()); }
748}