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 transport — a unix socket on Unix, a named pipe on Windows
352/// (`\\.\pipe\openssh-ssh-agent`) — and never writes a key to the filesystem (I7).
353///
354/// This is a native piece: it is validated on real hardware by the human, not
355/// assumed working because written (CLAUDE.md rule 4). All policy/CLI logic is
356/// tested against [`MockSshAgent`].
357#[derive(Debug, Default, Clone, Copy)]
358pub struct EnvSshAgent;
359
360/// ssh-agent protocol message number: add an identity (RFC draft / OpenSSH
361/// `PROTOCOL.agent`).
362const SSH_AGENTC_ADD_IDENTITY: u8 = 17;
363/// ssh-agent success reply.
364const 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        // Build the ADD_IDENTITY body: key-type string + private key blob +
372        // comment, all in SSH wire encoding. The key bytes live only in this
373        // in-memory buffer (zeroized on drop) and are written to the agent
374        // socket/pipe — never to disk (I7).
375        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        // The transport is the only platform-specific part — a unix-domain
381        // socket on Unix, a named pipe on Windows. The ssh-agent wire protocol
382        // over it is identical, so it lives in `add_identity_over`.
383        #[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            // OpenSSH for Windows exposes the agent as a named pipe. When set,
396            // $SSH_AUTH_SOCK holds the pipe path; otherwise use the well-known
397            // default. A Windows named pipe is opened like a file (read + write).
398            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
418/// Run the `SSH_AGENTC_ADD_IDENTITY` exchange over an already-connected
419/// ssh-agent transport (a unix socket on Unix, a named pipe on Windows). The
420/// wire protocol is transport-agnostic, so this is shared across platforms.
421fn 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
450/// Encode an `SSH_AGENTC_ADD_IDENTITY` message body for a private key.
451fn 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    // key type (e.g. "ssh-ed25519")
457    write_string(&mut out, pk.algorithm().as_str().as_bytes());
458    // the private key blob (KeypairData encodes the public + private fields)
459    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    // comment
465    write_string(&mut out, comment.as_bytes());
466    Ok(out)
467}
468
469/// Write an SSH `string` (u32 length prefix + bytes) to a buffer. Public so the
470/// `kovra-agent` face can frame ssh-agent replies with the same helper the
471/// custody path uses (the wire encoder lives once, in `core`).
472pub 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/// In-memory ssh-agent for tests: records every key it was asked to add, so a
478/// test can assert `ssh-add` delivered the key into the agent and **not** to
479/// disk (I7).
480#[derive(Default)]
481pub struct MockSshAgent {
482    added: std::sync::Mutex<Vec<(String, String)>>,
483}
484
485impl MockSshAgent {
486    /// A fresh, empty mock agent.
487    pub fn new() -> Self {
488        Self::default()
489    }
490
491    /// The (private-key, comment) pairs added so far.
492    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        // Validate it is a real OpenSSH key (the real agent would reject garbage)
500        // but never write it anywhere but this in-memory record.
501        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
511// ───────────────────────────── helpers ─────────────────────────────
512
513/// Reconstruct an `rsa::RsaPrivateKey` from `ssh-key`'s `RsaKeypair`.
514///
515/// `ssh-key` 0.6.7's own `TryFrom<&RsaKeypair> for rsa::RsaPrivateKey` is buggy
516/// (it passes the first prime `p` twice instead of `p` and `q`, so the produced
517/// key fails RSA validation). We read the five raw components ourselves and call
518/// `from_components` with the correct primes.
519fn 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
530/// The [`KeyAlgorithm`] of an OpenSSH public key's `KeyData`.
531fn 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
541/// A `CoreError::Keypair` for malformed input.
542fn invalid(msg: &str) -> CoreError {
543    CoreError::Keypair(msg.to_string())
544}
545
546/// Lowercase-hex encode (for RSA signatures, which have no OpenSSH armor here).
547fn 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
555/// Decode a lowercase/uppercase hex string; `None` on any non-hex input.
556fn 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    // ed25519 keygen yields an OpenSSH-valid public key and a usable private.
576    #[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        // public key derived from the private matches the generated public.
585        assert_eq!(
586            public_from_private(&kp.private_openssh).unwrap(),
587            kp.public_openssh
588        );
589        // the private key parses as an OpenSSH private key.
590        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        // a tampered message does not verify
609        assert!(!verify(&kp.public_openssh, b"deploy v3", &sig).unwrap());
610        // a different key does not verify
611        let other = generate(KeyAlgorithm::Ed25519).unwrap();
612        assert!(!verify(&other.public_openssh, b"deploy v2", &sig).unwrap());
613    }
614
615    // A signature with surrounding whitespace (as printed by `kovra sign` / read
616    // back from a file) still verifies — the CLI round-trips through a file.
617    #[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        // decrypting with the wrong key fails (no plaintext leak)
642        let other = generate(KeyAlgorithm::Ed25519).unwrap();
643        assert!(decrypt(&other.private_openssh, &ct).is_err());
644    }
645
646    // RSA encryption is rejected — encryption is ed25519-only (closed decision).
647    #[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    // The mock ssh-agent records the key in memory (the seam tests use for I7).
656    #[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        // garbage is rejected (mirrors the real agent)
667        assert!(agent.add_identity("not a key", "c").is_err());
668    }
669
670    // The raw ssh-agent signature blob over a challenge verifies as a standard
671    // SSH signature (the ssh-agent SIGN_RESPONSE format) — distinct from the
672    // detached SshSig that `sign` produces.
673    #[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        // The blob is `string algorithm || string signature`.
680        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        // It verifies against the public key through `ssh-key`'s own verifier:
686        // rebuild the `ssh_key::Signature` from the wire algorithm + bytes and
687        // check it (no direct dalek dependency — `core` never names it).
688        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        // a tampered challenge does not verify
694        assert!(pk.key_data().verify(b"other challenge", &sig).is_err());
695    }
696
697    // RSA honors the SIGN_REQUEST hash flags: 256/512 select rsa-sha2-*, and the
698    // absence of a flag falls back to legacy ssh-rsa (SHA-1).
699    #[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    // The public-key blob round-trips: re-decoding it yields the same KeyData,
720    // and it equals the agent's own ADD_IDENTITY encoding of the public half.
721    #[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()); // odd length
747    }
748}