From cdf2db18d8a79c320be6d7ced58fbc86761b0f34 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:31:40 -0700 Subject: [PATCH] SSH Agent v2: Add ssh key primitive types Co-authored-by: Bernd Schoolmann --- apps/desktop/desktop_native/Cargo.lock | 16 +- apps/desktop/desktop_native/Cargo.toml | 1 + .../desktop_native/ssh_agent/Cargo.toml | 19 ++ .../ssh_agent/src/crypto/mod.rs | 193 ++++++++++++++++++ .../desktop_native/ssh_agent/src/lib.rs | 7 + 5 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/desktop_native/ssh_agent/Cargo.toml create mode 100644 apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/lib.rs diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 3e5225d4b5a..c6f99b09eb0 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -351,9 +351,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "basic-toml" @@ -3083,6 +3083,9 @@ dependencies = [ "bcrypt-pbkdf", "ed25519-dalek", "num-bigint-dig", + "p256", + "p384", + "p521", "rand_core 0.6.4", "rsa", "sec1", @@ -3094,6 +3097,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ssh_agent" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64", + "ssh-key", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index facd9554af1..ee6caa7a8cc 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -9,6 +9,7 @@ members = [ "napi", "process_isolation", "proxy", + "ssh_agent", "windows_plugin_authenticator", ] diff --git a/apps/desktop/desktop_native/ssh_agent/Cargo.toml b/apps/desktop/desktop_native/ssh_agent/Cargo.toml new file mode 100644 index 00000000000..becab28c356 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ssh_agent" +edition = { workspace = true } +license = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +ssh-key = { version = "=0.6.7", features = [ + "encryption", + "ed25519", + "rsa", + "rand_core", +] } + +[lints] +workspace = true diff --git a/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs b/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs new file mode 100644 index 00000000000..442ee3a32bd --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs @@ -0,0 +1,193 @@ +//! Cryptographic key management for the SSH agent. +//! +//! This module provides the core primative types and functionality for managing +//! SSH keys in the Bitwarden SSH agent. +//! +//! The module exposes several key types: +//! +//! - [`KeyData`] - Complete SSH key with metadata (private key, public key, name, cipher ID) +//! - [`PrivateKey`] - SSH private key (Ed25519 or RSA) +//! - [`PublicKey`] - SSH public key with algorithm and blob data +//! +//! # Supported Algorithms +//! +//! - Ed25519 keys +//! - RSA keys +//! +//! ECDSA keys are not currently supported (PM-29894) + +use std::fmt; + +use anyhow::anyhow; +use ssh_key::private::{Ed25519Keypair, RsaKeypair}; + +/// Represents an SSH key with its associated metadata. +/// +/// Contains the private and public components of an SSH key, +/// along with a human-readable name and the cipher ID from the vault. +#[derive(Clone)] +pub(crate) struct KeyData { + private_key: PrivateKey, + public_key: PublicKey, + name: String, + cipher_id: String, +} + +impl KeyData { + /// Creates a new `KeyData` instance. + /// + /// # Arguments + /// + /// * `private_key` - The private key component (Ed25519 or RSA) + /// * `public_key` - The public key component with algorithm and blob + /// * `name` - A human-readable name for the key + /// * `cipher_id` - The vault cipher identifier associated with this key + /// + /// # Returns + /// + /// A new `KeyData` instance containing all provided key data and metadata. + pub(crate) fn new( + private_key: PrivateKey, + public_key: PublicKey, + name: String, + cipher_id: String, + ) -> Self { + Self { + private_key, + public_key, + name, + cipher_id, + } + } + + /// # Returns + /// + /// A reference to the [`PublicKey`] containing the algorithm and blob. + pub(crate) fn public_key(&self) -> &PublicKey { + &self.public_key + } + + /// # Returns + /// + /// A reference to the [`PrivateKey`] enum variant (Ed25519 or RSA). + pub(crate) fn private_key(&self) -> &PrivateKey { + &self.private_key + } + + /// # Returns + /// + /// A reference to the human-readable name string for this key. + pub(crate) fn name(&self) -> &String { + &self.name + } + + /// # Returns + /// + /// A reference to the cipher ID string that links this key to a vault entry. + pub(crate) fn cipher_id(&self) -> &String { + &self.cipher_id + } +} + +/// Represents an SSH private key. +/// +/// Supported key types: Ed25519, RSA. +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum PrivateKey { + Ed25519(Ed25519Keypair), + Rsa(RsaKeypair), +} + +impl TryFrom for PrivateKey { + type Error = anyhow::Error; + + fn try_from(key: ssh_key::private::PrivateKey) -> Result { + match key.algorithm() { + ssh_key::Algorithm::Ed25519 => Ok(Self::Ed25519( + key.key_data() + .ed25519() + .ok_or(anyhow!("Failed to parse ed25519 key"))? + .to_owned(), + )), + ssh_key::Algorithm::Rsa { hash: _ } => Ok(Self::Rsa( + key.key_data() + .rsa() + .ok_or(anyhow!("Failed to parse RSA key"))? + .to_owned(), + )), + _ => Err(anyhow!("Unsupported key type")), + } + } +} + +/// Represents an SSH public key. +/// +/// Contains the algorithm identifier (e.g., "ssh-ed25519", "ssh-rsa") +/// and the binary blob of the public key data. +#[derive(Clone, Ord, Eq, PartialOrd, PartialEq)] +pub(crate) struct PublicKey { + pub alg: String, + pub blob: Vec, +} + +impl PublicKey { + pub(crate) fn alg(&self) -> &str { + &self.alg + } + + pub(crate) fn blob(&self) -> &[u8] { + &self.blob + } +} + +impl fmt::Debug for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PublicKey(\"{self}\")") + } +} + +impl fmt::Display for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use base64::{prelude::BASE64_STANDARD, Engine as _}; + + write!(f, "{} {}", self.alg(), BASE64_STANDARD.encode(self.blob())) + } +} + +#[cfg(test)] +mod tests { + use ssh_key::{ + private::{Ed25519Keypair, RsaKeypair}, + rand_core::OsRng, + LineEnding, + }; + + use super::*; + + fn create_valid_ed25519_key_string() -> String { + let ed25519_keypair = Ed25519Keypair::random(&mut OsRng); + let ssh_key = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ed25519(ed25519_keypair), "") + .unwrap(); + ssh_key.to_openssh(LineEnding::LF).unwrap().to_string() + } + + #[test] + fn test_privatekey_from_ed25519() { + let key_string = create_valid_ed25519_key_string(); + let ssh_key = ssh_key::PrivateKey::from_openssh(&key_string).unwrap(); + + let private_key = PrivateKey::try_from(ssh_key).unwrap(); + assert!(matches!(private_key, PrivateKey::Ed25519(_))); + } + + #[test] + fn test_privatekey_from_rsa() { + let rsa_keypair = RsaKeypair::random(&mut OsRng, 2048).unwrap(); + let ssh_key = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Rsa(rsa_keypair), "").unwrap(); + + let private_key = PrivateKey::try_from(ssh_key).unwrap(); + assert!(matches!(private_key, PrivateKey::Rsa(_))); + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/lib.rs b/apps/desktop/desktop_native/ssh_agent/src/lib.rs new file mode 100644 index 00000000000..aaaf635e6c6 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/lib.rs @@ -0,0 +1,7 @@ +//! Bitwarden SSH Agent implementation +//! +//! + +#![allow(dead_code)] // TODO remove when all code is used in follow-up PR + +mod crypto;