From 20bb09cbfef67405c114da640561a6788955f7fb Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:27:56 -0700 Subject: [PATCH] SSH Agent v2: In-memory encrypted key store --- apps/desktop/desktop_native/Cargo.lock | 144 ++++++++- .../desktop_native/ssh_agent/Cargo.toml | 2 + .../ssh_agent/src/crypto/keystore.rs | 271 ++++++++++++++++ .../ssh_agent/src/crypto/mod.rs | 6 +- .../ssh_agent/src/crypto/serialization.rs | 294 ++++++++++++++++++ 5 files changed, 714 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/desktop_native/ssh_agent/src/crypto/keystore.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/crypto/serialization.rs diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index c6f99b09eb0..e3a8ce4b72b 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -478,6 +478,29 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -1419,13 +1442,19 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashlink" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.3", ] [[package]] @@ -1590,7 +1619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.3", ] [[package]] @@ -1863,6 +1892,26 @@ dependencies = [ "syn", ] +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "napi" version = "3.3.0" @@ -2513,6 +2562,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -2537,6 +2606,15 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" version = "0.8.5" @@ -2639,6 +2717,15 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2649,6 +2736,36 @@ dependencies = [ "subtle", ] +[[package]] +name = "rkyv" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360b333c61ae24e5af3ae7c8660bd6b21ccd8200dbbc5d33c2454421e85b9c69" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.16.1", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02f8cdd12b307ab69fe0acf4cd2249c7460eb89dce64a0febadf934ebb6a9e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "rsa" version = "0.9.10" @@ -2993,6 +3110,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "0.3.11" @@ -3103,6 +3226,8 @@ version = "0.0.0" dependencies = [ "anyhow", "base64", + "desktop_core", + "rkyv", "ssh-key", ] @@ -3253,6 +3378,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.48.0" diff --git a/apps/desktop/desktop_native/ssh_agent/Cargo.toml b/apps/desktop/desktop_native/ssh_agent/Cargo.toml index becab28c356..db02cdefe5c 100644 --- a/apps/desktop/desktop_native/ssh_agent/Cargo.toml +++ b/apps/desktop/desktop_native/ssh_agent/Cargo.toml @@ -8,6 +8,8 @@ publish = { workspace = true } [dependencies] anyhow = { workspace = true } base64 = { workspace = true } +desktop_core = { path = "../core" } +rkyv = "=0.8" ssh-key = { version = "=0.6.7", features = [ "encryption", "ed25519", diff --git a/apps/desktop/desktop_native/ssh_agent/src/crypto/keystore.rs b/apps/desktop/desktop_native/ssh_agent/src/crypto/keystore.rs new file mode 100644 index 00000000000..825c3cc4e0e --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/crypto/keystore.rs @@ -0,0 +1,271 @@ +//! This module defines the [`KeyStore`] trait and provides an encrypted in-memory +//! implementation for storing SSH keys securely.All stored data is ephemeral and +//! lost when the store is dropped. + +use std::sync::{Arc, Mutex}; + +use anyhow::Result; + +use desktop_core::secure_memory::{EncryptedMemoryStore, SecureMemoryStore}; + +use super::{KeyData, PublicKey}; + +/// Securely store and retrieve SSH key data. +/// +/// Provides an abstraction over key storage mechanisms, allowing for different +/// implementations or mocks. +pub(crate) trait KeyStore { + /// Stores or updates an SSH key in the keystore. + /// If a key with the same public key already exists, it will be overwritten. + /// + /// # Arguments + /// + /// * `key_data` - The SSH key data to store, including private key, public key, name, and cipher ID + /// + /// # Returns + /// + /// `Ok(())` if the key was successfully stored, or an error if the operation failed. + fn insert(&self, key_data: KeyData) -> Result<()>; + + /// Retrieves SSH key data by its public key. + /// + /// # Arguments + /// + /// * `public_key` - The public key to search for + /// + /// # Returns + /// + /// * `Ok(Some(KeyData))` if the key was found + /// * `Ok(None)` if no key with the given public key exists + /// * `Err(_)` if an error occurred during retrieval + fn get(&self, public_key: &PublicKey) -> Result>; + + /// # Returns + /// + /// A vector of tuples containing each key's public key and human-readable name, + /// or an error if the operation failed. + fn get_all_public_keys_and_names(&mut self) -> Result>; +} + +/// A thread-safe, in-memory, and encrypted implementation of the [`KeyStore`] trait. +/// +/// Stores SSH keys in encrypted form in memory using [`EncryptedMemoryStore`]. +/// Keys are encrypted when inserted and decrypted when retrieved. +/// All data is lost when the instance is dropped. +pub(crate) struct InMemoryEncryptedKeyStore { + secure_memory: Arc>>, +} + +impl InMemoryEncryptedKeyStore { + pub(crate) fn new() -> Self { + Self { + secure_memory: Arc::new(Mutex::new(EncryptedMemoryStore::new())), + } + } +} + +impl KeyStore for InMemoryEncryptedKeyStore { + fn insert(&self, key_data: KeyData) -> Result<()> { + let pub_key = key_data.public_key().clone(); + let bytes: Vec = key_data.try_into()?; + + self.secure_memory + .lock() + .expect("Mutex is not poisoned") + .put(pub_key, bytes.as_slice()); + + Ok(()) + } + + fn get(&self, public_key: &PublicKey) -> Result> { + self.secure_memory + .lock() + .expect("Mutex is not poisoned.") + .get(public_key)? + .map(KeyData::try_from) + .transpose() + } + + fn get_all_public_keys_and_names(&mut self) -> Result> { + self.secure_memory + .lock() + .expect("Mutex is not poisoned") + .to_vec()? + .into_iter() + .map(|bytes| { + KeyData::try_from(bytes) + .map(|key_data| (key_data.public_key().clone(), key_data.name().clone())) + }) + .collect::, _>>() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::PrivateKey; + use ssh_key::{ + private::{Ed25519Keypair, RsaKeypair}, + rand_core::OsRng, + }; + + fn create_test_keydata_ed25519(name: &str, cipher_id: &str) -> KeyData { + let ed25519_keypair = Ed25519Keypair::random(&mut OsRng); + let ssh_key = ssh_key::PrivateKey::new( + ssh_key::private::KeypairData::Ed25519(ed25519_keypair.clone()), + "", + ) + .unwrap(); + let public_key_bytes = ssh_key.public_key().to_bytes().unwrap(); + + KeyData::new( + PrivateKey::Ed25519(ed25519_keypair), + PublicKey { + alg: "ssh-ed25519".to_string(), + blob: public_key_bytes, + }, + name.to_string(), + cipher_id.to_string(), + ) + } + + fn create_test_keydata_rsa(name: &str, cipher_id: &str) -> KeyData { + let rsa_keypair = RsaKeypair::random(&mut OsRng, 2048).unwrap(); + let ssh_key = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Rsa(rsa_keypair.clone()), "") + .unwrap(); + let public_key_bytes = ssh_key.public_key().to_bytes().unwrap(); + + KeyData::new( + PrivateKey::Rsa(rsa_keypair), + PublicKey { + alg: "ssh-rsa".to_string(), + blob: public_key_bytes, + }, + name.to_string(), + cipher_id.to_string(), + ) + } + + #[test] + fn test_new_creates_empty_store() { + let mut ks = InMemoryEncryptedKeyStore::new(); + + let result = ks.get_all_public_keys_and_names(); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 0); + } + + #[test] + fn test_insert_multiple_keys_and_keytypes() { + let ks = InMemoryEncryptedKeyStore::new(); + + let key1 = create_test_keydata_ed25519("key1", "cipher-1"); + let key2 = create_test_keydata_rsa("key2", "cipher-2"); + let key3 = create_test_keydata_ed25519("key3", "cipher-3"); + + assert!(ks.insert(key1).is_ok()); + assert!(ks.insert(key2).is_ok()); + assert!(ks.insert(key3).is_ok()); + } + + #[test] + fn test_insert_overwrites_existing_key() { + let ks = InMemoryEncryptedKeyStore::new(); + + let key_data1 = create_test_keydata_ed25519("original-name", "original-cipher"); + let public_key = key_data1.public_key().clone(); + + // insert first key + ks.insert(key_data1).unwrap(); + + // Create new KeyData with same public key but different name/cipher_id + let ed25519_keypair = Ed25519Keypair::random(&mut OsRng); + let key_data2 = KeyData::new( + PrivateKey::Ed25519(ed25519_keypair), + public_key.clone(), + "updated-name".to_string(), + "updated-cipher".to_string(), + ); + + // insert second key with same public key + ks.insert(key_data2).unwrap(); + + // the name was updated + let key_data = ks.get(&public_key).unwrap().unwrap(); + assert_eq!(key_data.name(), &String::from("updated-name")); + } + + #[test] + fn test_get_nonexistent_key() { + let ks = InMemoryEncryptedKeyStore::new(); + + let dummy_public_key = PublicKey { + alg: "ssh-ed25519".to_string(), + blob: vec![1, 2, 3, 4, 5], + }; + + let result = ks.get(&dummy_public_key); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_get_preserves_all_fields() { + let ks = InMemoryEncryptedKeyStore::new(); + + let original = create_test_keydata_ed25519("test-key", "cipher-123"); + let public_key = original.public_key().clone(); + let private_key = original.private_key().clone(); + let expected_name = original.name().clone(); + let expected_cipher_id = original.cipher_id().clone(); + + ks.insert(original).unwrap(); + let retrieved = ks.get(&public_key).unwrap().unwrap(); + + assert_eq!(retrieved.name(), &expected_name); + assert_eq!(retrieved.cipher_id(), &expected_cipher_id); + assert_eq!(retrieved.public_key(), &public_key); + assert_eq!(retrieved.private_key(), &private_key); + } + + #[test] + fn test_get_all_empty_store() { + let mut ks = InMemoryEncryptedKeyStore::new(); + let result = ks.get_all_public_keys_and_names(); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 0); + } + + #[test] + fn test_get_all_multiple_keys() { + let mut ks = InMemoryEncryptedKeyStore::new(); + + let key1 = create_test_keydata_ed25519("key1", "cipher-1"); + let key2 = create_test_keydata_rsa("key2", "cipher-2"); + let key3 = create_test_keydata_ed25519("key3", "cipher-3"); + let pub_key1 = key1.public_key().clone(); + let pub_key2 = key2.public_key().clone(); + let pub_key3 = key3.public_key().clone(); + + ks.insert(key1).unwrap(); + ks.insert(key2).unwrap(); + ks.insert(key3).unwrap(); + + let result = ks.get_all_public_keys_and_names().unwrap(); + assert_eq!(result.len(), 3); + + let names: Vec = result.iter().map(|(_, name)| name.clone()).collect(); + + assert!(names.contains(&"key1".to_string())); + assert!(names.contains(&"key2".to_string())); + assert!(names.contains(&"key3".to_string())); + + let public_keys: Vec = result.iter().map(|(pk, _)| pk.clone()).collect(); + + assert!(public_keys.contains(&pub_key1)); + assert!(public_keys.contains(&pub_key2)); + assert!(public_keys.contains(&pub_key3)); + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs b/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs index 442ee3a32bd..8dcd74bd6ca 100644 --- a/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs +++ b/apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs @@ -19,8 +19,12 @@ use std::fmt; use anyhow::anyhow; +use rkyv::{Archive, Deserialize, Serialize}; use ssh_key::private::{Ed25519Keypair, RsaKeypair}; +mod keystore; +mod serialization; + /// Represents an SSH key with its associated metadata. /// /// Contains the private and public components of an SSH key, @@ -124,7 +128,7 @@ impl TryFrom for PrivateKey { /// /// 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)] +#[derive(Clone, Ord, Eq, PartialOrd, PartialEq, Archive, Serialize, Deserialize)] pub(crate) struct PublicKey { pub alg: String, pub blob: Vec, diff --git a/apps/desktop/desktop_native/ssh_agent/src/crypto/serialization.rs b/apps/desktop/desktop_native/ssh_agent/src/crypto/serialization.rs new file mode 100644 index 00000000000..f48b17b4efa --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/crypto/serialization.rs @@ -0,0 +1,294 @@ +//! Serialization and deserialization of SSH key data. +//! +//! This module handles the conversion of [`KeyData`] to and from binary format +//! for secure storage. It uses the `rkyv` crate for efficient zero-copy +//! serialization and deserialization. +//! +//! # Key Format +//! +//! Private keys are stored in OpenSSH PEM format with LF line endings. +//! The serialization process: +//! +//! 1. Converts [`PrivateKey`] to OpenSSH PEM string format +//! 2. Combines with [`PublicKey`], name, and cipher ID into [`KeyDataSerializable`] +//! 3. Serializes to binary using `rkyv` +//! +//! Deserialization reverses this process and validates the key format. + +use anyhow::{anyhow, Error}; +use rkyv::{deserialize, rancor::Error as RancorError, Archive, Deserialize, Serialize}; +use ssh_key::{private::KeypairData, LineEnding}; + +use super::{KeyData, PrivateKey, PublicKey}; + +#[derive(Archive, Serialize, Deserialize, PartialEq)] +struct KeyDataSerializable { + private_key: String, + public_key: PublicKey, + name: String, + cipher_id: String, +} + +impl TryFrom for KeyData { + type Error = anyhow::Error; + + fn try_from(key_data: KeyDataSerializable) -> Result { + let private_key = parse_key(&key_data.private_key)?; + let private_key = PrivateKey::try_from(private_key)?; + + Ok(Self { + private_key, + public_key: key_data.public_key, + name: key_data.name, + cipher_id: key_data.cipher_id, + }) + } +} + +fn parse_key(pem: &str) -> Result { + match ssh_key::private::PrivateKey::from_openssh(pem) { + Ok(key) => match key.public_key().to_bytes() { + Ok(_) => Ok(key), + Err(e) => Err(anyhow!("Failed to parse public key: {e}")), + }, + Err(e) => Err(anyhow!("Failed to parse key: {e}")), + } +} + +impl TryFrom> for KeyData { + type Error = anyhow::Error; + + fn try_from(bytes: Vec) -> Result { + let archived = rkyv::access::(&bytes[..])?; + let serializable = deserialize::(archived)?; + KeyData::try_from(serializable) + } +} + +impl TryFrom for Vec { + type Error = anyhow::Error; + + fn try_from(key_data: KeyData) -> Result { + let private_key = String::try_from(key_data.private_key)?; + + let serializable = KeyDataSerializable { + private_key, + public_key: key_data.public_key, + name: key_data.name, + cipher_id: key_data.cipher_id, + }; + + Ok(rkyv::to_bytes::(&serializable)?.to_vec()) + } +} + +impl TryFrom for String { + type Error = anyhow::Error; + + fn try_from(key: PrivateKey) -> Result { + let keypair_data = match key { + PrivateKey::Ed25519(kp) => KeypairData::Ed25519(kp), + PrivateKey::Rsa(kp) => KeypairData::Rsa(kp), + }; + let private_key = ssh_key::PrivateKey::new(keypair_data, "")?; + Ok(private_key + .to_openssh(LineEnding::LF) + .map(|s| s.to_string())?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ssh_key::{ + private::{Ed25519Keypair, RsaKeypair}, + rand_core::OsRng, + LineEnding, + }; + + const INVALID_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +invalid_base64_data!!! +-----END OPENSSH PRIVATE KEY-----"; + + 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() + } + + fn create_test_keydata_ed25519() -> KeyData { + let ed25519_keypair = Ed25519Keypair::random(&mut OsRng); + let ssh_key = ssh_key::PrivateKey::new( + ssh_key::private::KeypairData::Ed25519(ed25519_keypair.clone()), + "", + ) + .unwrap(); + let public_key_bytes = ssh_key.public_key().to_bytes().unwrap(); + + KeyData { + private_key: PrivateKey::Ed25519(ed25519_keypair), + public_key: PublicKey { + alg: "ssh-ed25519".to_string(), + blob: public_key_bytes, + }, + name: "test-key".to_string(), + cipher_id: "test-cipher-123".to_string(), + } + } + + fn create_test_keydata_rsa() -> KeyData { + let rsa_keypair = RsaKeypair::random(&mut OsRng, 2048).unwrap(); + let ssh_key = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Rsa(rsa_keypair.clone()), "") + .unwrap(); + let public_key_bytes = ssh_key.public_key().to_bytes().unwrap(); + + KeyData { + private_key: PrivateKey::Rsa(rsa_keypair), + public_key: PublicKey { + alg: "ssh-rsa".to_string(), + blob: public_key_bytes, + }, + name: "test-rsa-key".to_string(), + cipher_id: "test-cipher-456".to_string(), + } + } + + #[test] + fn test_parse_key_valid_ed25519() { + let key_string = create_valid_ed25519_key_string(); + let result = parse_key(&key_string); + assert!(result.is_ok()); + + let key = result.unwrap(); + assert_eq!(key.algorithm(), ssh_key::Algorithm::Ed25519); + } + + #[test] + fn test_parse_key_valid_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 pem = ssh_key.to_openssh(LineEnding::LF).unwrap().to_string(); + + let result = parse_key(&pem); + assert!(result.is_ok()); + } + + #[test] + #[should_panic(expected = "Failed to parse key")] + fn test_parse_key_invalid_key() { + parse_key(INVALID_KEY).unwrap(); + } + + #[test] + #[should_panic(expected = "Failed to parse key")] + fn test_parse_key_empty_string() { + parse_key("").unwrap(); + } + + #[test] + fn test_privatekey_ed25519_to_string() { + let ed25519_keypair = Ed25519Keypair::random(&mut OsRng); + let private_key = PrivateKey::Ed25519(ed25519_keypair); + + let pem = String::try_from(private_key).unwrap(); + assert!(pem.contains("BEGIN OPENSSH PRIVATE KEY")); + assert!(pem.contains("END OPENSSH PRIVATE KEY")); + } + + #[test] + fn test_privatekey_rsa_to_string() { + let rsa_keypair = RsaKeypair::random(&mut OsRng, 2048).unwrap(); + let private_key = PrivateKey::Rsa(rsa_keypair); + + let pem = String::try_from(private_key).unwrap(); + assert!(pem.contains("BEGIN OPENSSH PRIVATE KEY")); + assert!(pem.contains("END OPENSSH PRIVATE KEY")); + } + + #[test] + fn test_privatekey_to_string_uses_lf_line_ending() { + let ed25519_keypair = Ed25519Keypair::random(&mut OsRng); + let private_key = PrivateKey::Ed25519(ed25519_keypair); + + let pem = String::try_from(private_key).unwrap(); + // Should use LF (\n), not CRLF (\r\n) + assert!(!pem.contains("\r\n")); + } + + #[test] + fn test_keydataserializable_to_keydata_valid() { + let key_string = create_valid_ed25519_key_string(); + let serializable = KeyDataSerializable { + private_key: key_string, + public_key: PublicKey { + alg: "ssh-ed25519".to_string(), + blob: vec![1, 2, 3, 4], + }, + name: "test".to_string(), + cipher_id: "cipher-123".to_string(), + }; + + let result = KeyData::try_from(serializable); + assert!(result.is_ok()); + } + + #[test] + #[should_panic(expected = "Failed to parse key")] + fn test_keydataserializable_to_keydata_invalid_key() { + let serializable = KeyDataSerializable { + private_key: INVALID_KEY.to_string(), + public_key: PublicKey { + alg: "ssh-ed25519".to_string(), + blob: vec![1, 2, 3, 4], + }, + name: "test".to_string(), + cipher_id: "cipher-123".to_string(), + }; + + KeyData::try_from(serializable).unwrap(); + } + + #[test] + #[should_panic(expected = "subtree pointer overran range")] + fn test_keydata_from_corrupted_bytes() { + let corrupted_bytes = vec![0u8, 1, 2, 3, 4, 5]; + KeyData::try_from(corrupted_bytes).unwrap(); + } + + #[test] + #[should_panic(expected = "subtree pointer overran range")] + fn test_keydata_from_empty_bytes() { + let empty_bytes: Vec = vec![]; + KeyData::try_from(empty_bytes).unwrap(); + } + + #[test] + fn test_keydata_ed25519_to_from_bytes() { + let original = create_test_keydata_ed25519(); + + let bytes: Vec = original.clone().try_into().unwrap(); + let restored: KeyData = bytes.try_into().unwrap(); + + assert_eq!(restored.name(), original.name()); + assert_eq!(restored.cipher_id(), original.cipher_id()); + assert_eq!(restored.public_key(), original.public_key()); + assert_eq!(restored.private_key(), original.private_key()); + } + + #[test] + fn test_keydata_rsa_to_from_bytes() { + let original = create_test_keydata_rsa(); + + let bytes: Vec = original.clone().try_into().unwrap(); + let restored: KeyData = bytes.try_into().unwrap(); + + assert_eq!(restored.name(), original.name()); + assert_eq!(restored.cipher_id(), original.cipher_id()); + assert_eq!(restored.public_key(), original.public_key()); + assert_eq!(restored.private_key(), original.private_key()); + } +}