Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 188 additions & 18 deletions crates/bitwarden-ssh/src/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -36,20 +38,43 @@ fn import_pkcs8_key(
encoded_key: String,
password: Option<String>,
) -> Result<SshKeyView, SshKeyImportError> {
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
Expand Down Expand Up @@ -85,24 +110,155 @@ fn import_openssh_key(
encoded_key: String,
password: Option<String>,
) -> Result<SshKeyView, SshKeyImportError> {
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
}
_ => 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<String, SshKeyImportError> {
// 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)]
Expand Down Expand Up @@ -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);
}
}
3 changes: 3 additions & 0 deletions crates/bitwarden-ssh/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ fn ssh_private_key_to_view(value: PrivateKey) -> Result<SshKeyView, SshKeyExport
private_key: private_key_openssh.to_string(),
public_key: value.public_key().to_string(),
fingerprint: value.fingerprint(HashAlg::Sha256).to_string(),
original_private_key: None,
is_encrypted: false,
ssh_key_passphrase: None,
})
}
5 changes: 5 additions & 0 deletions crates/bitwarden-uniffi/src/tool/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ impl SshClient {
bitwarden_ssh::import::import_key(imported_key, password)
.map_err(|e| BitwardenError::E(Error::SshImport(e)))
}

pub fn decrypt_ssh_key_for_agent(&self, encrypted_pem: String, password: String) -> Result<String> {
bitwarden_ssh::import::decrypt_openssh_key(encrypted_pem, password)
.map_err(|e| BitwardenError::E(Error::SshImport(e)))
}
}
30 changes: 28 additions & 2 deletions crates/bitwarden-vault/src/cipher/ssh_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EncString>,
/// 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<EncString>,
}

#[allow(missing_docs)]
Expand All @@ -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<String>,
/// 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<String>,
}

impl CompositeEncryptable<KeyIds, SymmetricKeyId, SshKey> for SshKeyView {
Expand All @@ -47,6 +61,9 @@ impl CompositeEncryptable<KeyIds, SymmetricKeyId, SshKey> 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)?,
})
}
}
Expand All @@ -61,6 +78,9 @@ impl Decryptable<KeyIds, SymmetricKeyId, SshKeyView> 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)?,
})
}
}
Expand Down Expand Up @@ -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!(
Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions crates/bitwarden-wasm-internal/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,17 @@ pub fn import_ssh_key(
) -> Result<SshKeyView, bitwarden_ssh::error::SshKeyImportError> {
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<String, bitwarden_ssh::error::SshKeyImportError> {
bitwarden_ssh::import::decrypt_openssh_key(encrypted_pem.to_string(), password.to_string())
}
Loading