diff --git a/crates/bitwarden-ssh/src/import.rs b/crates/bitwarden-ssh/src/import.rs index e586eb99e..5d287506e 100644 --- a/crates/bitwarden-ssh/src/import.rs +++ b/crates/bitwarden-ssh/src/import.rs @@ -3,6 +3,8 @@ use ed25519; use pem_rfc7468::PemLabel; use pkcs8::{der::Decode, pkcs5, DecodePrivateKey, PrivateKeyInfo, SecretDocument}; use ssh_key::private::{Ed25519Keypair, RsaKeypair}; +use ssh_key::HashAlg; +use pkcs8::LineEnding; use crate::{error::SshKeyImportError, ssh_private_key_to_view}; @@ -36,20 +38,43 @@ fn import_pkcs8_key( encoded_key: String, password: Option, ) -> Result { - let doc = if let Some(password) = password { - SecretDocument::from_pkcs8_encrypted_pem(&encoded_key, password.as_bytes()).map_err( - |err| match err { - pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => { - SshKeyImportError::WrongPassword - } - _ => SshKeyImportError::ParsingError, - }, - )? + // Load the PKCS#8 document (decrypt if necessary) + let (doc, was_encrypted) = if let Some(ref pw) = password { + ( + SecretDocument::from_pkcs8_encrypted_pem(&encoded_key, pw.as_bytes()).map_err( + |err| match err { + pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => { + SshKeyImportError::WrongPassword + } + _ => SshKeyImportError::ParsingError, + }, + )?, + true, + ) } else { - SecretDocument::from_pkcs8_pem(&encoded_key).map_err(|_| SshKeyImportError::ParsingError)? + ( + SecretDocument::from_pkcs8_pem(&encoded_key) + .map_err(|_| SshKeyImportError::ParsingError)?, + false, + ) }; - import_pkcs8_der_key(doc.as_bytes()) + // Reuse existing DER importer to compute public key and fingerprint + let base = import_pkcs8_der_key(doc.as_bytes())?; + + // Preserve original PEM for round-trip fidelity; mark encryption status and passphrase (if provided) + Ok(SshKeyView { + private_key: if was_encrypted { + encoded_key.clone() + } else { + base.private_key + }, + public_key: base.public_key, + fingerprint: base.fingerprint, + original_private_key: Some(encoded_key), + is_encrypted: was_encrypted, + ssh_key_passphrase: if was_encrypted { password } else { None }, + }) } /// Import a DER encoded private key, and returns a decoded [SshKeyView]. This is primarily used for @@ -85,7 +110,8 @@ fn import_openssh_key( encoded_key: String, password: Option, ) -> Result { - let private_key = + // Parse the original OpenSSH PEM to determine encryption and to compute pub/fingerprint. + let parsed = ssh_key::private::PrivateKey::from_openssh(&encoded_key).map_err(|err| match err { ssh_key::Error::AlgorithmUnknown | ssh_key::Error::AlgorithmUnsupported { .. } => { SshKeyImportError::UnsupportedKeyType @@ -93,16 +119,146 @@ fn import_openssh_key( _ => SshKeyImportError::ParsingError, })?; - let private_key = if private_key.is_encrypted() { + if parsed.is_encrypted() { + // Encrypted: require password to decrypt for computing public key and fingerprint, + // but preserve the original PEM verbatim for storage/export. let password = password.ok_or(SshKeyImportError::PasswordRequired)?; - private_key + let decrypted = parsed .decrypt(password.as_bytes()) - .map_err(|_| SshKeyImportError::WrongPassword)? + .map_err(|_| SshKeyImportError::WrongPassword)?; + + let public_key = decrypted.public_key().to_string(); + let fingerprint = decrypted.fingerprint(HashAlg::Sha256).to_string(); + + Ok(SshKeyView { + private_key: encoded_key.clone(), + public_key, + fingerprint, + original_private_key: Some(encoded_key), + is_encrypted: true, + ssh_key_passphrase: Some(password), + }) } else { - private_key - }; + // Unencrypted: compute public key and fingerprint as-is, preserve original PEM verbatim. + let public_key = parsed.public_key().to_string(); + let fingerprint = parsed.fingerprint(HashAlg::Sha256).to_string(); - ssh_private_key_to_view(private_key).map_err(|_| SshKeyImportError::ParsingError) + Ok(SshKeyView { + private_key: encoded_key.clone(), + public_key, + fingerprint, + original_private_key: Some(encoded_key), + is_encrypted: false, + ssh_key_passphrase: None, + }) + } +} + +/** + * Decrypt a private key PEM into an unencrypted OpenSSH PEM for agent use. + * Supports both OpenSSH and PKCS#8 inputs. If already unencrypted OpenSSH, returns it verbatim. + */ +pub fn decrypt_openssh_key( + encoded_key: String, + password: String, +) -> Result { + // Determine the PEM label so we can support OpenSSH and PKCS#8 inputs + let label = pem_rfc7468::decode_label(encoded_key.as_bytes()) + .map_err(|_| SshKeyImportError::ParsingError)?; + + match label { + // Encrypted PKCS#8 + pkcs8::EncryptedPrivateKeyInfo::PEM_LABEL => { + let doc = SecretDocument::from_pkcs8_encrypted_pem(&encoded_key, password.as_bytes()) + .map_err(|err| match err { + pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => { + SshKeyImportError::WrongPassword + } + _ => SshKeyImportError::ParsingError, + })?; + + // Parse DER and convert to OpenSSH + let private_key_info = + PrivateKeyInfo::from_der(doc.as_bytes()).map_err(|_| SshKeyImportError::ParsingError)?; + + let private_key = match private_key_info.algorithm.oid { + ed25519::pkcs8::ALGORITHM_OID => { + let private_key: ed25519::KeypairBytes = private_key_info + .try_into() + .map_err(|_| SshKeyImportError::ParsingError)?; + ssh_key::private::PrivateKey::from(Ed25519Keypair::from(&private_key.secret_key.into())) + } + rsa::pkcs1::ALGORITHM_OID => { + let private_key: rsa::RsaPrivateKey = private_key_info + .try_into() + .map_err(|_| SshKeyImportError::ParsingError)?; + ssh_key::private::PrivateKey::from( + RsaKeypair::try_from(private_key).map_err(|_| SshKeyImportError::ParsingError)?, + ) + } + _ => return Err(SshKeyImportError::UnsupportedKeyType), + }; + + let pem = private_key + .to_openssh(LineEnding::LF) + .map_err(|_| SshKeyImportError::ParsingError)?; + Ok(pem.to_string()) + } + // Unencrypted PKCS#8: convert directly to OpenSSH + pkcs8::PrivateKeyInfo::PEM_LABEL => { + let doc = SecretDocument::from_pkcs8_pem(&encoded_key) + .map_err(|_| SshKeyImportError::ParsingError)?; + + let private_key_info = + PrivateKeyInfo::from_der(doc.as_bytes()).map_err(|_| SshKeyImportError::ParsingError)?; + + let private_key = match private_key_info.algorithm.oid { + ed25519::pkcs8::ALGORITHM_OID => { + let private_key: ed25519::KeypairBytes = private_key_info + .try_into() + .map_err(|_| SshKeyImportError::ParsingError)?; + ssh_key::private::PrivateKey::from(Ed25519Keypair::from(&private_key.secret_key.into())) + } + rsa::pkcs1::ALGORITHM_OID => { + let private_key: rsa::RsaPrivateKey = private_key_info + .try_into() + .map_err(|_| SshKeyImportError::ParsingError)?; + ssh_key::private::PrivateKey::from( + RsaKeypair::try_from(private_key).map_err(|_| SshKeyImportError::ParsingError)?, + ) + } + _ => return Err(SshKeyImportError::UnsupportedKeyType), + }; + + let pem = private_key + .to_openssh(LineEnding::LF) + .map_err(|_| SshKeyImportError::ParsingError)?; + Ok(pem.to_string()) + } + // OpenSSH input + ssh_key::PrivateKey::PEM_LABEL => { + let parsed = ssh_key::private::PrivateKey::from_openssh(&encoded_key).map_err(|err| match err { + ssh_key::Error::AlgorithmUnknown | ssh_key::Error::AlgorithmUnsupported { .. } => { + SshKeyImportError::UnsupportedKeyType + } + _ => SshKeyImportError::ParsingError, + })?; + + if !parsed.is_encrypted() { + // Already unencrypted, return as-is + return Ok(encoded_key); + } + + let decrypted = parsed + .decrypt(password.as_bytes()) + .map_err(|_| SshKeyImportError::WrongPassword)?; + let pem = decrypted + .to_openssh(LineEnding::LF) + .map_err(|_| SshKeyImportError::ParsingError)?; + Ok(pem.to_string()) + } + _ => Err(SshKeyImportError::UnsupportedKeyType), + } } #[cfg(test)] @@ -227,4 +383,18 @@ mod tests { let result = import_key(private_key.to_string(), Some("".to_string())); assert_eq!(result.unwrap_err(), SshKeyImportError::UnsupportedKeyType); } + + #[test] + fn import_key_openssh_encrypted_preserves_input() { + let original = include_str!("../resources/import/ed25519_openssh_encrypted"); + let result = import_key(original.to_string(), Some("password".to_string())).unwrap(); + assert_eq!(result.private_key, original); + } + + #[test] + fn import_key_openssh_unencrypted_preserves_input() { + let original = include_str!("../resources/import/ed25519_openssh_unencrypted"); + let result = import_key(original.to_string(), Some("".to_string())).unwrap(); + assert_eq!(result.private_key, original); + } } diff --git a/crates/bitwarden-ssh/src/lib.rs b/crates/bitwarden-ssh/src/lib.rs index 4232d0ad3..5d70663b2 100644 --- a/crates/bitwarden-ssh/src/lib.rs +++ b/crates/bitwarden-ssh/src/lib.rs @@ -24,5 +24,8 @@ fn ssh_private_key_to_view(value: PrivateKey) -> Result Result { + bitwarden_ssh::import::decrypt_openssh_key(encrypted_pem, password) + .map_err(|e| BitwardenError::E(Error::SshImport(e))) + } } diff --git a/crates/bitwarden-vault/src/cipher/ssh_key.rs b/crates/bitwarden-vault/src/cipher/ssh_key.rs index 137edca43..349806d91 100644 --- a/crates/bitwarden-vault/src/cipher/ssh_key.rs +++ b/crates/bitwarden-vault/src/cipher/ssh_key.rs @@ -15,12 +15,19 @@ use crate::{cipher::cipher::CopyableCipherFields, Cipher}; #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SshKey { - /// SSH private key (ed25519/rsa) in unencrypted openssh private key format [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key) + /// SSH private key (ed25519/rsa) in unencrypted OpenSSH private key format [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key). + /// For encrypted imports, this may be empty; see `originalPrivateKey`. pub private_key: EncString, /// SSH public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6) pub public_key: EncString, /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT` pub fingerprint: EncString, + /// Original SSH private key as provided during import (PEM). If the key was encrypted, this preserves the encrypted PEM verbatim. + pub original_private_key: Option, + /// Indicates whether the original_private_key was encrypted at import time. + pub is_encrypted: bool, + /// Optional stored passphrase for the SSH private key (if user opted-in). + pub ssh_key_passphrase: Option, } #[allow(missing_docs)] @@ -29,12 +36,19 @@ pub struct SshKey { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))] pub struct SshKeyView { - /// SSH private key (ed25519/rsa) in unencrypted openssh private key format [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key) + /// Preferred SSH private key material to display/copy. For unencrypted keys this is an OpenSSH PEM. + /// For encrypted imports this may be empty; see `original_private_key`. pub private_key: String, /// SSH public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6) pub public_key: String, /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT` pub fingerprint: String, + /// Original SSH private key as provided during import (PEM). If the key was encrypted, this preserves the encrypted PEM verbatim. + pub original_private_key: Option, + /// Indicates whether the original_private_key was encrypted at import time. + pub is_encrypted: bool, + /// Optional stored passphrase for the SSH private key (if user opted-in). + pub ssh_key_passphrase: Option, } impl CompositeEncryptable for SshKeyView { @@ -47,6 +61,9 @@ impl CompositeEncryptable for SshKeyView { private_key: self.private_key.encrypt(ctx, key)?, public_key: self.public_key.encrypt(ctx, key)?, fingerprint: self.fingerprint.encrypt(ctx, key)?, + original_private_key: self.original_private_key.encrypt(ctx, key)?, + is_encrypted: self.is_encrypted, + ssh_key_passphrase: self.ssh_key_passphrase.encrypt(ctx, key)?, }) } } @@ -61,6 +78,9 @@ impl Decryptable for SshKey { private_key: self.private_key.decrypt(ctx, key)?, public_key: self.public_key.decrypt(ctx, key)?, fingerprint: self.fingerprint.decrypt(ctx, key)?, + original_private_key: self.original_private_key.decrypt(ctx, key)?, + is_encrypted: self.is_encrypted, + ssh_key_passphrase: self.ssh_key_passphrase.decrypt(ctx, key)?, }) } } @@ -103,6 +123,9 @@ mod tests { private_key: private_key_encrypted, public_key: public_key_encrypted, fingerprint: fingerprint_encrypted, + original_private_key: None, + is_encrypted: false, + ssh_key_passphrase: None, }; assert_eq!( @@ -117,6 +140,9 @@ mod tests { private_key: "2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap(), public_key: "2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap(), fingerprint: "2.tMIugb6zQOL+EuOizna1wQ==|W5dDLoNJtajN68yeOjrr6w==|qS4hwJB0B0gNLI0o+jxn+sKMBmvtVgJCRYNEXBZoGeE=".parse().unwrap(), + original_private_key: None, + is_encrypted: false, + ssh_key_passphrase: None, }; let copyable_fields = ssh_key.get_copyable_fields(None); diff --git a/crates/bitwarden-wasm-internal/src/ssh.rs b/crates/bitwarden-wasm-internal/src/ssh.rs index 409e2760f..009e1ce8d 100644 --- a/crates/bitwarden-wasm-internal/src/ssh.rs +++ b/crates/bitwarden-wasm-internal/src/ssh.rs @@ -36,3 +36,17 @@ pub fn import_ssh_key( ) -> Result { bitwarden_ssh::import::import_key(imported_key.to_string(), password) } + +/// Decrypt an encrypted OpenSSH private key PEM into an unencrypted OpenSSH PEM for agent use. +/// If the input is already unencrypted, returns it verbatim. +/// +/// # Arguments +/// - `encrypted_pem` - The original OpenSSH private key PEM (possibly encrypted) +/// - `password` - The passphrase to decrypt the key +#[wasm_bindgen] +pub fn decrypt_ssh_key_for_agent( + encrypted_pem: &str, + password: &str, +) -> Result { + bitwarden_ssh::import::decrypt_openssh_key(encrypted_pem.to_string(), password.to_string()) +}