mirror of
https://github.com/bitwarden/browser
synced 2026-01-28 23:33:27 +00:00
SSH Agent v2: In-memory encrypted key store
This commit is contained in:
144
apps/desktop/desktop_native/Cargo.lock
generated
144
apps/desktop/desktop_native/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
271
apps/desktop/desktop_native/ssh_agent/src/crypto/keystore.rs
Normal file
271
apps/desktop/desktop_native/ssh_agent/src/crypto/keystore.rs
Normal file
@@ -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<Option<KeyData>>;
|
||||
|
||||
/// # 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<Vec<(PublicKey, String)>>;
|
||||
}
|
||||
|
||||
/// 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<Mutex<EncryptedMemoryStore<PublicKey>>>,
|
||||
}
|
||||
|
||||
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<u8> = 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<Option<KeyData>> {
|
||||
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<Vec<(PublicKey, String)>> {
|
||||
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::<Result<Vec<_>, _>>()
|
||||
}
|
||||
}
|
||||
|
||||
#[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<String> = 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<PublicKey> = 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));
|
||||
}
|
||||
}
|
||||
@@ -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<ssh_key::private::PrivateKey> 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<u8>,
|
||||
|
||||
@@ -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<KeyDataSerializable> for KeyData {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(key_data: KeyDataSerializable) -> Result<Self, Self::Error> {
|
||||
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<ssh_key::PrivateKey, Error> {
|
||||
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<Vec<u8>> for KeyData {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(bytes: Vec<u8>) -> Result<Self, Self::Error> {
|
||||
let archived = rkyv::access::<ArchivedKeyDataSerializable, RancorError>(&bytes[..])?;
|
||||
let serializable = deserialize::<KeyDataSerializable, RancorError>(archived)?;
|
||||
KeyData::try_from(serializable)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<KeyData> for Vec<u8> {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(key_data: KeyData) -> Result<Self, Self::Error> {
|
||||
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::<RancorError>(&serializable)?.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PrivateKey> for String {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(key: PrivateKey) -> Result<Self, Self::Error> {
|
||||
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<u8> = vec![];
|
||||
KeyData::try_from(empty_bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keydata_ed25519_to_from_bytes() {
|
||||
let original = create_test_keydata_ed25519();
|
||||
|
||||
let bytes: Vec<u8> = 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<u8> = 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user