Skip to main content

kovra_core/
keypair.rs

1//! Asymmetric keypair custody & operations (KOV-12, extends spec §1.3).
2//!
3//! kovra custodies a full asymmetric keypair (ed25519 or RSA) — or just a
4//! peer's **public** key (a public-only entry, not a secret). The private half
5//! lives in a [`SecretValue`](crate::secret::SecretValue) and is sealed by the
6//! same AEAD path as a literal ([`crate::crypto`]); it is **never exported**.
7//! Like injection, a private key is used only *through* an operation (sign /
8//! decrypt / load into the ssh-agent); the key material never crosses back into
9//! the caller's — or the model's — context (I11/I14), is never logged (I7/I12),
10//! and is never placed in `argv` (I6).
11//!
12//! This module is **pure** — it knows nothing about the vault, policy, or the
13//! broker. The faces wire these primitives into [`crate::policy::decide`]:
14//! private-key ops map to [`Operation::Inject`](crate::scope::Operation) (so
15//! they are broker-gated for `high`/`prod`, I3/I15) and public-key ops map to
16//! [`Operation::Metadata`](crate::scope::Operation) (free). All on-the-wire key
17//! material is the **OpenSSH** text format, so generated public keys are
18//! `ssh-*`-valid and the private key is exactly what the ssh-agent wants.
19//!
20//! ## Algorithm scope (closed decision)
21//! - **ed25519**: keygen, sign/verify, *and* asymmetric encrypt/decrypt (via
22//!   `age`'s SSH-recipient support — X25519 under the hood).
23//! - **RSA**: keygen and sign/verify only. **No RSA encryption.** Asymmetric
24//!   encrypt/decrypt is ed25519-only.
25
26use 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
39/// Minimum RSA modulus size we generate (bits). OpenSSH/`ssh-key` reject
40/// anything smaller; 3072 is the modern default.
41pub const RSA_BITS: usize = 3072;
42
43/// The SSH signature namespace kovra signs/verifies under (the `-n` of
44/// `ssh-keygen -Y sign`). A fixed, authoritative constant — never caller-set —
45/// so a signature made by kovra verifies with `ssh-keygen -Y verify -n kovra`.
46pub const SSH_SIG_NAMESPACE: &str = "kovra";
47
48/// The asymmetric key algorithm of a [`Keypair`](crate::record::SecretRecord).
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum KeyAlgorithm {
52    /// Edwards-curve ed25519 (signing) / X25519 (encryption via `age`).
53    Ed25519,
54    /// RSA (signing/verify and SSH only — never encryption here).
55    Rsa,
56}
57
58impl KeyAlgorithm {
59    /// Parse a CLI/tool algorithm name (`ed25519` / `rsa`, case-insensitive).
60    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    /// Stable lowercase label.
71    pub fn as_str(&self) -> &'static str {
72        match self {
73            KeyAlgorithm::Ed25519 => "ed25519",
74            KeyAlgorithm::Rsa => "rsa",
75        }
76    }
77
78    /// Whether this algorithm supports asymmetric encrypt/decrypt (ed25519
79    /// only — the closed decision: RSA is ssh/sign only).
80    pub fn supports_encryption(&self) -> bool {
81        matches!(self, KeyAlgorithm::Ed25519)
82    }
83}
84
85/// A freshly generated keypair, in OpenSSH text form. The private half is held
86/// in a [`Zeroizing`] buffer so it is wiped when the caller drops it; the face
87/// immediately moves it into a sealed [`SecretValue`](crate::secret::SecretValue).
88pub struct GeneratedKeypair {
89    /// The algorithm.
90    pub algorithm: KeyAlgorithm,
91    /// OpenSSH-format private key (`-----BEGIN OPENSSH PRIVATE KEY-----`).
92    pub private_openssh: Zeroizing<String>,
93    /// OpenSSH-format public key (`ssh-ed25519 …` / `ssh-rsa …`).
94    pub public_openssh: String,
95}
96
97/// Generate a new keypair of `algorithm`. The private key is returned in
98/// OpenSSH form (unencrypted — at rest it is sealed by kovra's AEAD, not by an
99/// SSH passphrase) and the public key is OpenSSH-valid.
100pub 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            // Generate with the `rsa` crate directly: `ssh-key`'s own RSA path
109            // round-trips through the same buggy component conversion we avoid
110            // in `rsa_private_from_openssh`, so we build the keypair from a key
111            // we know is valid.
112            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
134/// The algorithm of an OpenSSH **public** key string. Used to validate a
135/// public-only entry on `add --public-key` and to detect a mismatched op.
136pub 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
142/// The OpenSSH public key string corresponding to an OpenSSH private key.
143/// Lets the faces derive (and re-store) the public half from the sealed
144/// private one without ever exporting the private bytes.
145pub 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
153/// Sign `data` with the OpenSSH private key, returning a detached, ASCII-armored
154/// signature.
155///
156/// - **ed25519** → an OpenSSH `SshSig` (PEM, verifiable with
157///   `ssh-keygen -Y verify`), under the [`SSH_SIG_NAMESPACE`].
158/// - **RSA** → a PKCS#1 v1.5 / SHA-256 signature, hex-encoded. (`ssh-key` 0.6
159///   cannot emit an RSA `SshSig`; the `rsa` crate gives a standard, verifiable
160///   RSA signature instead — see the module note on the algorithm scope.)
161pub 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
182// ssh-agent SIGN_REQUEST flags (OpenSSH `PROTOCOL.agent`). They select the RSA
183// hash; ed25519 ignores them. `0` means the key's default algorithm (legacy
184// `ssh-rsa` / SHA-1 for an RSA key).
185/// `SSH_AGENT_RSA_SHA2_256` — sign an RSA key with `rsa-sha2-256`.
186pub const SSH_AGENT_RSA_SHA2_256: u32 = 0x02;
187/// `SSH_AGENT_RSA_SHA2_512` — sign an RSA key with `rsa-sha2-512`.
188pub const SSH_AGENT_RSA_SHA2_512: u32 = 0x04;
189
190/// Produce a **raw ssh-agent signature blob** over `data` (the SSH session
191/// challenge) with an OpenSSH private key — the `SSH_AGENT_SIGN_RESPONSE` payload
192/// the ssh-agent protocol expects (KOV-13), *distinct* from [`sign`] (which is a
193/// detached `SshSig` attestation under a namespace).
194///
195/// The returned bytes are the wire-format SSH `signature` value:
196/// `string sig_algorithm_name || string signature_blob`, ready to be wrapped in
197/// the response frame by the agent face. The cryptography stays here in `core`;
198/// the `kovra-agent` crate only frames it.
199///
200/// Algorithm selection follows the client's SIGN_REQUEST `flags`:
201/// - **ed25519** → always `ssh-ed25519` (64-byte raw signature); flags ignored.
202/// - **RSA** → `rsa-sha2-512` / `rsa-sha2-256` per the flag, else legacy
203///   `ssh-rsa` (SHA-1) when the client sets neither (some old clients).
204///
205/// The private key material is exposed only inside this call (in-memory) and is
206/// never written anywhere (I7); the signature blob carries no key bytes.
207pub 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            // Sign the raw challenge through `ssh-key`'s own `Signer` (Ed25519 is
217            // pure EdDSA — the hash flag does not apply). We avoid depending on
218            // `ed25519-dalek` directly (it arrives via `ssh-key`'s feature) and
219            // re-emit the raw 64-byte signature with the `ssh-ed25519` name.
220            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                // No SHA-2 flag → legacy ssh-rsa (SHA-1). Supported for old
237                // clients that request the key's default algorithm. SHA-1 comes
238                // from the `sha1` crate `core` already depends on (for TOTP).
239                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
247/// The raw SSH **public-key blob** of an OpenSSH public key — the
248/// `KeyData`-encoded bytes the ssh-agent protocol carries inside an
249/// `IDENTITIES_ANSWER` (and that a client echoes back in a `SIGN_REQUEST` to
250/// select a key). This is public material (no secret, I12); the agent uses it
251/// both to advertise a key and to match an incoming sign request to a custodied
252/// keypair by exact byte equality.
253pub 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
264/// Encode an SSH `signature` value: `string algorithm || string blob`.
265fn 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
272/// Verify a signature produced by [`sign`] against an OpenSSH **public** key.
273/// Returns `Ok(true)` on a valid signature, `Ok(false)` on a well-formed but
274/// non-matching one, and an error only for malformed inputs.
275pub 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            // Tolerate surrounding whitespace/newlines: a signature read from a
281            // file (or printed by `kovra sign`) carries a trailing newline that
282            // `SshSig::from_pem` would otherwise reject.
283            let sig = match SshSig::from_pem(signature.trim().as_bytes()) {
284                Ok(s) => s,
285                // A malformed PEM is a verification failure, not a parse error of
286                // the key — report it as "does not verify" so callers treat a
287                // garbage signature uniformly.
288                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
311/// Encrypt `plaintext` **to** an OpenSSH **public** key (ed25519 only). Returns
312/// an `age` ciphertext (binary). RSA is rejected — encryption is ed25519-only.
313pub 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    // `age::ssh::Recipient` also parses ssh-rsa, but the closed decision is
317    // ed25519-only encryption; reject an RSA recipient explicitly.
318    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
326/// Decrypt an [`encrypt_to`] ciphertext **with** an OpenSSH private key (ed25519
327/// only). The plaintext is returned in a [`Zeroizing`] buffer.
328pub 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
336// ───────────────────────────── ssh-agent ─────────────────────────────
337
338/// The ssh-agent seam (KOV-12 `ssh-add`). `ssh-add` loads a private key into a
339/// running ssh-agent **in memory only** — never to `~/.ssh` (I7). The real
340/// agent is a `[host]` piece validated on hardware by the human; tests drive
341/// [`MockSshAgent`], which records the keys it was asked to add so a test can
342/// assert nothing touched the filesystem.
343pub trait SshAgent {
344    /// Add an OpenSSH private key (with an optional comment) to the agent.
345    /// Implementations MUST NOT persist the key to disk.
346    fn add_identity(&self, private_openssh: &str, comment: &str) -> Result<(), CoreError>;
347}
348
349/// The host ssh-agent reached over `$SSH_AUTH_SOCK` (`[host]`). Speaks the
350/// minimal ssh-agent wire protocol (`SSH_AGENTC_ADD_IDENTITY`); it opens the
351/// agent's unix socket and never writes a key to the filesystem (I7).
352///
353/// This is a native piece: it is validated on real hardware by the human, not
354/// assumed working because written (CLAUDE.md rule 4). All policy/CLI logic is
355/// tested against [`MockSshAgent`].
356#[derive(Debug, Default, Clone, Copy)]
357pub struct EnvSshAgent;
358
359/// ssh-agent protocol message number: add an identity (RFC draft / OpenSSH
360/// `PROTOCOL.agent`).
361const SSH_AGENTC_ADD_IDENTITY: u8 = 17;
362/// ssh-agent success reply.
363const 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        // Build the ADD_IDENTITY body: key-type string + private key blob +
376        // comment, all in SSH wire encoding. The key bytes live only in this
377        // in-memory buffer (zeroized on drop) and are written to the agent
378        // socket — never to disk (I7).
379        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
412/// Encode an `SSH_AGENTC_ADD_IDENTITY` message body for a private key.
413fn 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    // key type (e.g. "ssh-ed25519")
419    write_string(&mut out, pk.algorithm().as_str().as_bytes());
420    // the private key blob (KeypairData encodes the public + private fields)
421    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    // comment
427    write_string(&mut out, comment.as_bytes());
428    Ok(out)
429}
430
431/// Write an SSH `string` (u32 length prefix + bytes) to a buffer. Public so the
432/// `kovra-agent` face can frame ssh-agent replies with the same helper the
433/// custody path uses (the wire encoder lives once, in `core`).
434pub 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/// In-memory ssh-agent for tests: records every key it was asked to add, so a
440/// test can assert `ssh-add` delivered the key into the agent and **not** to
441/// disk (I7).
442#[derive(Default)]
443pub struct MockSshAgent {
444    added: std::sync::Mutex<Vec<(String, String)>>,
445}
446
447impl MockSshAgent {
448    /// A fresh, empty mock agent.
449    pub fn new() -> Self {
450        Self::default()
451    }
452
453    /// The (private-key, comment) pairs added so far.
454    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        // Validate it is a real OpenSSH key (the real agent would reject garbage)
462        // but never write it anywhere but this in-memory record.
463        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
473// ───────────────────────────── helpers ─────────────────────────────
474
475/// Reconstruct an `rsa::RsaPrivateKey` from `ssh-key`'s `RsaKeypair`.
476///
477/// `ssh-key` 0.6.7's own `TryFrom<&RsaKeypair> for rsa::RsaPrivateKey` is buggy
478/// (it passes the first prime `p` twice instead of `p` and `q`, so the produced
479/// key fails RSA validation). We read the five raw components ourselves and call
480/// `from_components` with the correct primes.
481fn 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
492/// The [`KeyAlgorithm`] of an OpenSSH public key's `KeyData`.
493fn 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
503/// A `CoreError::Keypair` for malformed input.
504fn invalid(msg: &str) -> CoreError {
505    CoreError::Keypair(msg.to_string())
506}
507
508/// Lowercase-hex encode (for RSA signatures, which have no OpenSSH armor here).
509fn 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
517/// Decode a lowercase/uppercase hex string; `None` on any non-hex input.
518fn 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    // ed25519 keygen yields an OpenSSH-valid public key and a usable private.
538    #[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        // public key derived from the private matches the generated public.
547        assert_eq!(
548            public_from_private(&kp.private_openssh).unwrap(),
549            kp.public_openssh
550        );
551        // the private key parses as an OpenSSH private key.
552        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        // a tampered message does not verify
571        assert!(!verify(&kp.public_openssh, b"deploy v3", &sig).unwrap());
572        // a different key does not verify
573        let other = generate(KeyAlgorithm::Ed25519).unwrap();
574        assert!(!verify(&other.public_openssh, b"deploy v2", &sig).unwrap());
575    }
576
577    // A signature with surrounding whitespace (as printed by `kovra sign` / read
578    // back from a file) still verifies — the CLI round-trips through a file.
579    #[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        // decrypting with the wrong key fails (no plaintext leak)
604        let other = generate(KeyAlgorithm::Ed25519).unwrap();
605        assert!(decrypt(&other.private_openssh, &ct).is_err());
606    }
607
608    // RSA encryption is rejected — encryption is ed25519-only (closed decision).
609    #[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    // The mock ssh-agent records the key in memory (the seam tests use for I7).
618    #[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        // garbage is rejected (mirrors the real agent)
629        assert!(agent.add_identity("not a key", "c").is_err());
630    }
631
632    // The raw ssh-agent signature blob over a challenge verifies as a standard
633    // SSH signature (the ssh-agent SIGN_RESPONSE format) — distinct from the
634    // detached SshSig that `sign` produces.
635    #[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        // The blob is `string algorithm || string signature`.
642        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        // It verifies against the public key through `ssh-key`'s own verifier:
648        // rebuild the `ssh_key::Signature` from the wire algorithm + bytes and
649        // check it (no direct dalek dependency — `core` never names it).
650        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        // a tampered challenge does not verify
656        assert!(pk.key_data().verify(b"other challenge", &sig).is_err());
657    }
658
659    // RSA honors the SIGN_REQUEST hash flags: 256/512 select rsa-sha2-*, and the
660    // absence of a flag falls back to legacy ssh-rsa (SHA-1).
661    #[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    // The public-key blob round-trips: re-decoding it yields the same KeyData,
682    // and it equals the agent's own ADD_IDENTITY encoding of the public half.
683    #[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()); // odd length
709    }
710}