diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 67b770230cd..ae82892f1ed 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5128,6 +5128,33 @@ "extraWide": { "message": "Extra wide" }, + "sshKeyWrongPassword": { + "message": "The password you entered is incorrect." + }, + "importSshKey": { + "message": "Import" + }, + "confirmSshKeyPassword": { + "message": "Confirm password" + }, + "enterSshKeyPasswordDesc": { + "message": "Enter the password for the SSH key." + }, + "enterSshKeyPassword": { + "message": "Enter password" + }, + "invalidSshKey": { + "message": "The SSH key is invalid" + }, + "sshKeyTypeUnsupported": { + "message": "The SSH key type is not supported" + }, + "importSshKeyFromClipboard": { + "message": "Import key from clipboard" + }, + "sshKeyImported": { + "message": "SSH key imported successfully" + }, "cannotRemoveViewOnlyCollections": { "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", "placeholders": { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cd65220936e..a5967b5fe76 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1012,6 +1012,7 @@ export default class MainBackground { this.encryptService, this.pinService, this.accountService, + this.sdkService, ); this.individualVaultExportService = new IndividualVaultExportService( diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index d49f48c0c64..5a2adfcf62b 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -130,7 +130,11 @@ import { KeyService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { + DefaultSshImportPromptService, + PasswordRepromptService, + SshImportPromptService, +} from "@bitwarden/vault"; import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; @@ -653,6 +657,11 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionLoginDecryptionOptionsService, deps: [MessagingServiceAbstraction, Router], }), + safeProvider({ + provide: SshImportPromptService, + useClass: DefaultSshImportPromptService, + deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 0e776375e6a..555337736c7 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -780,6 +780,7 @@ export class ServiceContainer { this.encryptService, this.pinService, this.accountService, + this.sdkService, ); this.individualExportService = new IndividualVaultExportService( diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs b/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs deleted file mode 100644 index 52464487ec5..00000000000 --- a/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs +++ /dev/null @@ -1,402 +0,0 @@ -use ed25519; -use pkcs8::{ - der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument, -}; -use ssh_key::{ - private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair}, - HashAlg, LineEnding, -}; - -const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----"; -const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----"; -const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----"; -const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----"; - -pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier = - ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); - -#[derive(Debug)] -enum KeyType { - Ed25519, - Rsa, - Unknown, -} - -pub fn import_key( - encoded_key: String, - password: String, -) -> Result { - match encoded_key.lines().next() { - Some(PKCS1_HEADER) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }), - Some(PKCS8_UNENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, None) { - Ok(result) => Ok(result), - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - }, - Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) { - Ok(result) => Ok(result), - Err(err) => match err { - SshKeyImportError::PasswordRequired => Ok(SshKeyImportResult { - status: SshKeyImportStatus::PasswordRequired, - ssh_key: None, - }), - SshKeyImportError::WrongPassword => Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }), - SshKeyImportError::ParsingError => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - }, - }, - Some(OPENSSH_HEADER) => import_openssh_key(encoded_key, password), - Some(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - None => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } -} - -fn import_pkcs8_key( - encoded_key: String, - password: Option, -) -> Result { - let der = match SecretDocument::from_pem(&encoded_key) { - Ok((_, doc)) => doc, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - - let decrypted_der = match password.clone() { - Some(password) => { - let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes()) - { - Ok(info) => info, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - match encrypted_private_key_info.decrypt(password.as_bytes()) { - Ok(der) => der, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }); - } - } - } - None => der, - }; - - let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes()) - .map_err(|_| SshKeyImportError::ParsingError)? - .algorithm - .oid - { - ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519, - RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa, - _ => KeyType::Unknown, - }; - - match key_type { - KeyType::Ed25519 => { - let pk: ed25519::KeypairBytes = match password { - Some(password) => { - pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password) - .map_err(|err| match err { - ed25519::pkcs8::Error::EncryptedPrivateKey(_) => { - SshKeyImportError::WrongPassword - } - _ => SshKeyImportError::ParsingError, - })? - } - None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key) - .map_err(|_| SshKeyImportError::ParsingError)?, - }; - let pk: Ed25519Keypair = - Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key)); - let private_key = ssh_key::private::PrivateKey::from(pk); - Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }) - } - KeyType::Rsa => { - let pk: rsa::RsaPrivateKey = match password { - Some(password) => { - pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password) - .map_err(|err| match err { - pkcs8::Error::EncryptedPrivateKey(_) => { - SshKeyImportError::WrongPassword - } - _ => SshKeyImportError::ParsingError, - })? - } - None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key) - .map_err(|_| SshKeyImportError::ParsingError)?, - }; - let rsa_keypair: Result = RsaKeypair::try_from(pk); - match rsa_keypair { - Ok(rsa_keypair) => { - let private_key = ssh_key::private::PrivateKey::from(rsa_keypair); - Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key - .to_openssh(LineEnding::LF) - .unwrap() - .to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }) - } - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } - } - _ => Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }), - } -} - -fn import_openssh_key( - encoded_key: String, - password: String, -) -> Result { - let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key); - let private_key = match private_key { - Ok(k) => k, - Err(err) => { - match err { - ssh_key::Error::AlgorithmUnknown - | ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }); - } - _ => {} - } - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - - if private_key.is_encrypted() && password.is_empty() { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::PasswordRequired, - ssh_key: None, - }); - } - let private_key = if private_key.is_encrypted() { - match private_key.decrypt(password.as_bytes()) { - Ok(k) => k, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }); - } - } - } else { - private_key - }; - - match private_key.to_openssh(LineEnding::LF) { - Ok(private_key_openssh) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key_openssh.to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }), - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } -} - -#[derive(PartialEq, Debug)] -pub enum SshKeyImportStatus { - /// ssh key was parsed correctly and will be returned in the result - Success, - /// ssh key was parsed correctly but is encrypted and requires a password - PasswordRequired, - /// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect - WrongPassword, - /// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key - ParsingError, - /// ssh key type is not supported - UnsupportedKeyType, -} - -pub enum SshKeyImportError { - ParsingError, - PasswordRequired, - WrongPassword, -} - -pub struct SshKeyImportResult { - pub status: SshKeyImportStatus, - pub ssh_key: Option, -} - -pub struct SshKey { - pub private_key: String, - pub public_key: String, - pub key_fingerprint: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn import_key_ed25519_openssh_unencrypted() { - let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted"); - let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_openssh_encrypted() { - let private_key = include_str!("./test_keys/ed25519_openssh_encrypted"); - let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_openssh_unencrypted() { - let private_key = include_str!("./test_keys/rsa_openssh_unencrypted"); - let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_openssh_encrypted() { - let private_key = include_str!("./test_keys/rsa_openssh_encrypted"); - let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_pkcs8_unencrypted() { - let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted"); - let public_key = - include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_pkcs8_unencrypted() { - let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted"); - // for whatever reason pkcs8 + rsa does not include the comment in the public key - let public_key = - include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_pkcs8_encrypted() { - let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted"); - let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_openssh_encrypted_wrong_password() { - let private_key = include_str!("./test_keys/ed25519_openssh_encrypted"); - let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::WrongPassword); - } - - #[test] - fn import_non_key_error() { - let result = import_key("not a key".to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - #[test] - fn import_ecdsa_error() { - let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType); - } - - // Putty-exported keys should be supported, but are not due to a parser incompatibility. - // Should this test start failing, please change it to expect a correct key, and - // make sure the documentation support for putty-exported keys this is updated. - // https://bitwarden.atlassian.net/browse/PM-14989 - #[test] - fn import_key_ed25519_putty() { - let private_key = include_str!("./test_keys/ed25519_putty_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - // Putty-exported keys should be supported, but are not due to a parser incompatibility. - // Should this test start failing, please change it to expect a correct key, and - // make sure the documentation support for putty-exported keys this is updated. - // https://bitwarden.atlassian.net/browse/PM-14989 - #[test] - fn import_key_rsa_openssh_putty() { - let private_key = include_str!("./test_keys/rsa_putty_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - #[test] - fn import_key_rsa_pkcs8_putty() { - let private_key = include_str!("./test_keys/rsa_putty_pkcs1_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType); - } -} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 3fe327948f8..5f794b49c73 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -16,7 +16,6 @@ mod platform_ssh_agent; #[cfg(any(target_os = "linux", target_os = "macos"))] mod peercred_unix_listener_stream; -pub mod importer; pub mod peerinfo; mod request_parser; diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index c40b7aed487..92f31cf5f89 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -51,22 +51,6 @@ export declare namespace sshagent { publicKey: string keyFingerprint: string } - export const enum SshKeyImportStatus { - /** ssh key was parsed correctly and will be returned in the result */ - Success = 0, - /** ssh key was parsed correctly but is encrypted and requires a password */ - PasswordRequired = 1, - /** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */ - WrongPassword = 2, - /** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */ - ParsingError = 3, - /** ssh key type is not supported (e.g. ecdsa) */ - UnsupportedKeyType = 4 - } - export interface SshKeyImportResult { - status: SshKeyImportStatus - sshKey?: SshKey - } export interface SshUiRequest { cipherId?: string isList: boolean @@ -79,7 +63,6 @@ export declare namespace sshagent { export function isRunning(agentState: SshAgentState): boolean export function setKeys(agentState: SshAgentState, newKeys: Array): void export function lock(agentState: SshAgentState): void - export function importKey(encodedKey: string, password: string): SshKeyImportResult export function clearKeys(agentState: SshAgentState): void export class SshAgentState { } } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 7d20bd50699..d0c859d427c 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -182,67 +182,6 @@ pub mod sshagent { pub key_fingerprint: String, } - impl From for SshKey { - fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self { - SshKey { - private_key: key.private_key, - public_key: key.public_key, - key_fingerprint: key.key_fingerprint, - } - } - } - - #[napi] - pub enum SshKeyImportStatus { - /// ssh key was parsed correctly and will be returned in the result - Success, - /// ssh key was parsed correctly but is encrypted and requires a password - PasswordRequired, - /// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect - WrongPassword, - /// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key - ParsingError, - /// ssh key type is not supported (e.g. ecdsa) - UnsupportedKeyType, - } - - impl From for SshKeyImportStatus { - fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self { - match status { - desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => { - SshKeyImportStatus::Success - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => { - SshKeyImportStatus::PasswordRequired - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => { - SshKeyImportStatus::WrongPassword - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => { - SshKeyImportStatus::ParsingError - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => { - SshKeyImportStatus::UnsupportedKeyType - } - } - } - } - - #[napi(object)] - pub struct SshKeyImportResult { - pub status: SshKeyImportStatus, - pub ssh_key: Option, - } - - impl From for SshKeyImportResult { - fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self { - SshKeyImportResult { - status: result.status.into(), - ssh_key: result.ssh_key.map(|k| k.into()), - } - } - } - #[napi(object)] pub struct SshUIRequest { pub cipher_id: Option, @@ -359,13 +298,6 @@ pub mod sshagent { .map_err(|e| napi::Error::from_reason(e.to_string())) } - #[napi] - pub fn import_key(encoded_key: String, password: String) -> napi::Result { - let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - Ok(result.into()) - } - #[napi] pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> { let bitwarden_agent_state = &mut agent_state.state; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index edd07097b54..5f8b9762594 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -102,6 +102,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; +import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; @@ -430,6 +431,11 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopLoginApprovalComponentService, deps: [I18nServiceAbstraction], }), + safeProvider({ + provide: SshImportPromptService, + useClass: DefaultSshImportPromptService, + deps: [DialogService, ToastService, PlatformUtilsServiceAbstraction, I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts index af79d9d7316..595ef778bcf 100644 --- a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts +++ b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts @@ -25,16 +25,6 @@ export class MainSshAgentService { private logService: LogService, private messagingService: MessagingService, ) { - ipcMain.handle( - "sshagent.importkey", - async ( - event: any, - { privateKey, password }: { privateKey: string; password?: string }, - ): Promise => { - return sshagent.importKey(privateKey, password); - }, - ); - ipcMain.handle("sshagent.init", async (event: any, message: any) => { this.init(); }); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index b485b471ccb..7739ab84577 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3532,9 +3532,6 @@ "unknownApplication": { "message": "An application" }, - "sshKeyPasswordUnsupported": { - "message": "Importing password protected SSH keys is not yet supported" - }, "invalidSshKey": { "message": "The SSH key is invalid" }, @@ -3544,7 +3541,7 @@ "importSshKeyFromClipboard": { "message": "Import key from clipboard" }, - "sshKeyPasted": { + "sshKeyImported": { "message": "SSH key imported successfully" }, "fileSavedToDevice": { diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 05dcd484def..bf81021922f 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -1,4 +1,3 @@ -import { sshagent as ssh } from "desktop_native/napi"; import { ipcRenderer } from "electron"; import { DeviceType } from "@bitwarden/common/enums"; @@ -64,13 +63,6 @@ const sshAgent = { clearKeys: async () => { return await ipcRenderer.invoke("sshagent.clearkeys"); }, - importKey: async (key: string, password: string): Promise => { - const res = await ipcRenderer.invoke("sshagent.importkey", { - privateKey: key, - password: password, - }); - return res; - }, isLoaded(): Promise { return ipcRenderer.invoke("sshagent.isloaded"); }, diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.html b/apps/desktop/src/vault/app/vault/add-edit.component.html index 6244f585bae..d79e15ebe6a 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.html +++ b/apps/desktop/src/vault/app/vault/add-edit.component.html @@ -512,6 +512,15 @@ [ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }" > +
@@ -559,16 +568,6 @@
-
- -
diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index ae332c9723b..2c8b5a8321a 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -3,8 +3,6 @@ import { DatePipe } from "@angular/common"; import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { NgForm } from "@angular/forms"; -import { sshagent as sshAgent } from "desktop_native/napi"; -import { lastValueFrom } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; @@ -25,8 +23,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; const BroadcasterSubscriptionId = "AddEditComponent"; @@ -60,6 +57,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On toastService: ToastService, cipherAuthorizationService: CipherAuthorizationService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -82,6 +80,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } @@ -159,69 +158,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On this.cipher.revisionDate = cipher.revisionDate; } - async importSshKeyFromClipboard(password: string = "") { - const key = await this.platformUtilsService.readFromClipboard(); - const parsedKey = await ipc.platform.sshAgent.importKey(key, password); - if (parsedKey == null) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("invalidSshKey"), - }); - return; - } - - switch (parsedKey.status) { - case sshAgent.SshKeyImportStatus.ParsingError: - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("invalidSshKey"), - }); - return; - case sshAgent.SshKeyImportStatus.UnsupportedKeyType: - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyTypeUnsupported"), - }); - return; - case sshAgent.SshKeyImportStatus.PasswordRequired: - case sshAgent.SshKeyImportStatus.WrongPassword: - if (password !== "") { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyWrongPassword"), - }); - } else { - password = await this.getSshKeyPassword(); - if (password === "") { - return; - } - await this.importSshKeyFromClipboard(password); - } - return; - default: - this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey; - this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey; - this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint; - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("sshKeyPasted"), - }); - } - } - - async getSshKeyPassword(): Promise { - const dialog = this.dialogService.open(SshKeyPasswordPromptComponent, { - ariaModal: true, - }); - - return await lastValueFrom(dialog.closed); - } - truncateString(value: string, length: number) { return value.length > length ? value.substring(0, length) + "..." : value; } diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 2cb1a4ee923..d0e876026d2 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -96,6 +96,7 @@ import { DefaultThemeStateService, ThemeStateService, } from "@bitwarden/common/platform/theming/theme-state.service"; +import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KdfConfigService, @@ -103,6 +104,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; +import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { flagEnabled } from "../../utils/flags"; import { PolicyListService } from "../admin-console/core/policy-list.service"; @@ -349,6 +351,11 @@ const safeProviders: SafeProvider[] = [ useClass: WebLoginDecryptionOptionsService, deps: [MessagingService, RouterService, AcceptOrganizationInviteService], }), + safeProvider({ + provide: SshImportPromptService, + useClass: DefaultSshImportPromptService, + deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 3df2b9a83c9..68fcee367f1 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -30,7 +30,7 @@ import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; @Component({ selector: "app-vault-add-edit", @@ -76,6 +76,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService: CipherAuthorizationService, toastService: ToastService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -98,6 +99,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 8490ec6c9db..89f3b79f1fb 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -28,7 +28,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; import { AddEditComponent as BaseAddEditComponent } from "../individual-vault/add-edit.component"; @@ -64,6 +64,7 @@ export class AddEditComponent extends BaseAddEditComponent { cipherAuthorizationService: CipherAuthorizationService, toastService: ToastService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -88,6 +89,7 @@ export class AddEditComponent extends BaseAddEditComponent { cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 229cca65ae5..3888b42fe76 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -38,7 +38,7 @@ "restoreMembers": { "message": "Restore members" }, - "cannotRestoreAccessError":{ + "cannotRestoreAccessError": { "message": "Cannot restore organization access" }, "allApplicationsWithCount": { @@ -1355,8 +1355,8 @@ "yourAccountIsLocked": { "message": "Your account is locked" }, - "uuid":{ - "message" : "UUID" + "uuid": { + "message": "UUID" }, "unlock": { "message": "Unlock" @@ -5904,10 +5904,10 @@ "bulkFilteredMessage": { "message": "Excluded, not applicable for this action" }, - "nonCompliantMembersTitle":{ + "nonCompliantMembersTitle": { "message": "Non-compliant members" }, - "nonCompliantMembersError":{ + "nonCompliantMembersError": { "message": "Members that are non-compliant with the Single organization or Two-step login policy cannot be restored until they adhere to the policy requirements" }, "fingerprint": { @@ -9330,7 +9330,7 @@ "message": "for Bitwarden using the implementation guide for your Identity Provider.", "description": "This represents the end of a sentence, broken up to include links. The full sentence will be 'Configure single sign-on for Bitwarden using the implementation guide for your Identity Provider." }, - "userProvisioning":{ + "userProvisioning": { "message": "User provisioning" }, "scimIntegration": { @@ -9344,22 +9344,22 @@ "message": "(System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider.", "description": "This represents the end of a sentence, broken up to include links. The full sentence will be 'Configure SCIM (System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider" }, - "bwdc":{ + "bwdc": { "message": "Bitwarden Directory Connector" }, "bwdcDesc": { "message": "Configure Bitwarden Directory Connector to automatically provision users and groups using the implementation guide for your Identity Provider." }, - "eventManagement":{ + "eventManagement": { "message": "Event management" }, - "eventManagementDesc":{ + "eventManagementDesc": { "message": "Integrate Bitwarden event logs with your SIEM (system information and event management) system by using the implementation guide for your platform." }, - "deviceManagement":{ + "deviceManagement": { "message": "Device management" }, - "deviceManagementDesc":{ + "deviceManagementDesc": { "message": "Configure device management for Bitwarden using the implementation guide for your platform." }, "desktopRequired": { @@ -9368,7 +9368,7 @@ "reopenLinkOnDesktop": { "message": "Reopen this link from your email on a desktop." }, - "integrationCardTooltip":{ + "integrationCardTooltip": { "message": "Launch $INTEGRATION$ implementation guide.", "placeholders": { "integration": { @@ -9377,7 +9377,7 @@ } } }, - "smIntegrationTooltip":{ + "smIntegrationTooltip": { "message": "Set up $INTEGRATION$.", "placeholders": { "integration": { @@ -9386,7 +9386,7 @@ } } }, - "smSdkTooltip":{ + "smSdkTooltip": { "message": "View $SDK$ repository", "placeholders": { "sdk": { @@ -9395,7 +9395,7 @@ } } }, - "integrationCardAriaLabel":{ + "integrationCardAriaLabel": { "message": "open $INTEGRATION$ implementation guide in a new tab.", "placeholders": { "integration": { @@ -9404,7 +9404,7 @@ } } }, - "smSdkAriaLabel":{ + "smSdkAriaLabel": { "message": "view $SDK$ repository in a new tab.", "placeholders": { "sdk": { @@ -9413,7 +9413,7 @@ } } }, - "smIntegrationCardAriaLabel":{ + "smIntegrationCardAriaLabel": { "message": "set up $INTEGRATION$ implementation guide in a new tab.", "placeholders": { "integration": { @@ -9820,7 +9820,7 @@ "message": "Config" }, "learnMoreAboutEmergencyAccess": { - "message":"Learn more about emergency access" + "message": "Learn more about emergency access" }, "learnMoreAboutMatchDetection": { "message": "Learn more about match detection" @@ -10122,7 +10122,7 @@ "selfHostingTitleProper": { "message": "Self-Hosting" }, - "claim-domain-single-org-warning" : { + "claim-domain-single-org-warning": { "message": "Claiming a domain will turn on the single organization policy." }, "single-org-revoked-user-warning": { @@ -10363,6 +10363,36 @@ "organizationNameMaxLength": { "message": "Organization name cannot exceed 50 characters." }, + "sshKeyWrongPassword": { + "message": "The password you entered is incorrect." + }, + "importSshKey": { + "message": "Import" + }, + "confirmSshKeyPassword": { + "message": "Confirm password" + }, + "enterSshKeyPasswordDesc": { + "message": "Enter the password for the SSH key." + }, + "enterSshKeyPassword": { + "message": "Enter password" + }, + "invalidSshKey": { + "message": "The SSH key is invalid" + }, + "sshKeyTypeUnsupported": { + "message": "The SSH key type is not supported" + }, + "importSshKeyFromClipboard": { + "message": "Import key from clipboard" + }, + "sshKeyImported": { + "message": "SSH key imported successfully" + }, + "copySSHPrivateKey": { + "message": "Copy private key" + }, "openingExtension": { "message": "Opening the Bitwarden browser extension" }, diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index c309aa9624a..c843d186625 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -41,7 +41,7 @@ import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { generate_ssh_key } from "@bitwarden/sdk-internal"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; @Directive() export class AddEditComponent implements OnInit, OnDestroy { @@ -131,7 +131,8 @@ export class AddEditComponent implements OnInit, OnDestroy { protected configService: ConfigService, protected cipherAuthorizationService: CipherAuthorizationService, protected toastService: ToastService, - private sdkService: SdkService, + protected sdkService: SdkService, + private sshImportPromptService: SshImportPromptService, ) { this.typeOptions = [ { name: i18nService.t("typeLogin"), value: CipherType.Login }, @@ -824,6 +825,15 @@ export class AddEditComponent implements OnInit, OnDestroy { return true; } + async importSshKeyFromClipboard() { + const key = await this.sshImportPromptService.importSshKeyFromClipboard(); + if (key != null) { + this.cipher.sshKey.privateKey = key.privateKey; + this.cipher.sshKey.publicKey = key.publicKey; + this.cipher.sshKey.keyFingerprint = key.keyFingerprint; + } + } + private async generateSshKey(showNotification: boolean = true) { await firstValueFrom(this.sdkService.client$); const sshKey = generate_ssh_key("Ed25519"); diff --git a/libs/angular/tsconfig.json b/libs/angular/tsconfig.json index c603e5cf170..d77e56d778e 100644 --- a/libs/angular/tsconfig.json +++ b/libs/angular/tsconfig.json @@ -13,6 +13,8 @@ "@bitwarden/generator-history": ["../tools/generator/extensions/history/src"], "@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"], "@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"], + "@bitwarden/importer/core": ["../importer/src"], + "@bitwarden/importer-ui": ["../importer/src/components"], "@bitwarden/key-management": ["../key-management/src"], "@bitwarden/platform": ["../platform/src"], "@bitwarden/ui-common": ["../ui/common/src"], diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index e542d0dfc1f..7d0ef9e9c34 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -73,6 +73,7 @@ export class CipherExport { break; case CipherType.SshKey: view.sshKey = SshKeyExport.toView(req.sshKey); + break; } if (req.passwordHistory != null) { diff --git a/libs/common/src/models/export/ssh-key.export.ts b/libs/common/src/models/export/ssh-key.export.ts index a99ebac34b3..5387daf7dd0 100644 --- a/libs/common/src/models/export/ssh-key.export.ts +++ b/libs/common/src/models/export/ssh-key.export.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { import_ssh_key } from "@bitwarden/sdk-internal"; import { EncString } from "../../platform/models/domain/enc-string"; import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key"; @@ -17,16 +18,18 @@ export class SshKeyExport { } static toView(req: SshKeyExport, view = new SshKeyView()) { - view.privateKey = req.privateKey; - view.publicKey = req.publicKey; - view.keyFingerprint = req.keyFingerprint; + const parsedKey = import_ssh_key(req.privateKey); + view.privateKey = parsedKey.privateKey; + view.publicKey = parsedKey.publicKey; + view.keyFingerprint = parsedKey.fingerprint; return view; } static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) { - domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null; - domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null; - domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null; + const parsedKey = import_ssh_key(req.privateKey); + domain.privateKey = new EncString(parsedKey.privateKey); + domain.publicKey = new EncString(parsedKey.publicKey); + domain.keyFingerprint = new EncString(parsedKey.fingerprint); return domain; } diff --git a/libs/common/tsconfig.json b/libs/common/tsconfig.json index 2d1379f9c5f..dacc7d65ea5 100644 --- a/libs/common/tsconfig.json +++ b/libs/common/tsconfig.json @@ -9,6 +9,7 @@ // TODO: Remove once billing stops depending on components "@bitwarden/components": ["../components/src"], "@bitwarden/key-management": ["../key-management/src"], + "@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"], "@bitwarden/platform": ["../platform/src"], // TODO: Remove once billing stops depending on components "@bitwarden/ui-common": ["../ui/common/src"] diff --git a/libs/importer/jest.config.js b/libs/importer/jest.config.js index ab449dc7757..ee5ae302b99 100644 --- a/libs/importer/jest.config.js +++ b/libs/importer/jest.config.js @@ -7,7 +7,7 @@ const sharedConfig = require("../shared/jest.config.ts"); /** @type {import('jest').Config} */ module.exports = { ...sharedConfig, - preset: "ts-jest", + preset: "jest-preset-angular", testEnvironment: "jsdom", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { prefix: "/", diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 6ea58545352..79d194b87bc 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -37,6 +37,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -96,6 +97,7 @@ const safeProviders: SafeProvider[] = [ EncryptService, PinServiceAbstraction, AccountService, + SdkService, ], }), ]; diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index 4de0bf70b07..908f062ecc1 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -1,16 +1,19 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { KeyService } from "@bitwarden/key-management"; +import { BitwardenClient } from "@bitwarden/sdk-internal"; import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer"; import { Importer } from "../importers/importer"; @@ -30,6 +33,7 @@ describe("ImportService", () => { let encryptService: MockProxy; let pinService: MockProxy; let accountService: MockProxy; + let sdkService: MockProxy; beforeEach(() => { cipherService = mock(); @@ -40,6 +44,9 @@ describe("ImportService", () => { keyService = mock(); encryptService = mock(); pinService = mock(); + const mockClient = mock(); + sdkService = mock(); + sdkService.client$ = of(mockClient, mockClient, mockClient); importService = new ImportService( cipherService, @@ -51,6 +58,7 @@ describe("ImportService", () => { encryptService, pinService, accountService, + sdkService, ); }); diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 781b4f75e56..cc9cdc39320 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -15,6 +15,7 @@ import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/reque import { KvpRequest } from "@bitwarden/common/models/request/kvp.request"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -114,6 +115,7 @@ export class ImportService implements ImportServiceAbstraction { private encryptService: EncryptService, private pinService: PinServiceAbstraction, private accountService: AccountService, + private sdkService: SdkService, ) {} getImportOptions(): ImportOption[] { diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index fdcfa200321..5d6464b4c79 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -24,6 +24,7 @@ import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -39,6 +40,8 @@ import { // eslint-disable-next-line no-restricted-imports import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests"; +import { SshImportPromptService } from "../services/ssh-import-prompt.service"; + import { CipherFormService } from "./abstractions/cipher-form.service"; import { TotpCaptureService } from "./abstractions/totp-capture.service"; import { CipherFormModule } from "./cipher-form.module"; @@ -146,6 +149,12 @@ export default { enabled$: new BehaviorSubject(true), }, }, + { + provide: SshImportPromptService, + useValue: { + importSshKeyFromClipboard: () => Promise.resolve(new SshKeyData()), + }, + }, { provide: CipherFormGenerationService, useValue: { diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html index 51b07a1cbf3..1b11b37084b 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html @@ -15,6 +15,14 @@ data-testid="toggle-privateKey-visibility" bitPasswordInputToggle > + diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts index 773ddd4ad66..500bb886f7a 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -7,7 +7,8 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ClientType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; @@ -22,6 +23,7 @@ import { } from "@bitwarden/components"; import { generate_ssh_key } from "@bitwarden/sdk-internal"; +import { SshImportPromptService } from "../../../services/ssh-import-prompt.service"; import { CipherFormContainer } from "../../cipher-form-container"; @Component({ @@ -60,11 +62,14 @@ export class SshKeySectionComponent implements OnInit { keyFingerprint: [""], }); + showImport = false; + constructor( private cipherFormContainer: CipherFormContainer, private formBuilder: FormBuilder, - private i18nService: I18nService, private sdkService: SdkService, + private sshImportPromptService: SshImportPromptService, + private platformUtilsService: PlatformUtilsService, ) { this.cipherFormContainer.registerChildForm("sshKeyDetails", this.sshKeyForm); this.sshKeyForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { @@ -87,6 +92,11 @@ export class SshKeySectionComponent implements OnInit { } this.sshKeyForm.disable(); + + // Web does not support clipboard access + if (this.platformUtilsService.getClientType() !== ClientType.Web) { + this.showImport = true; + } } /** Set form initial form values from the current cipher */ @@ -100,6 +110,17 @@ export class SshKeySectionComponent implements OnInit { }); } + async importSshKeyFromClipboard() { + const key = await this.sshImportPromptService.importSshKeyFromClipboard(); + if (key != null) { + this.sshKeyForm.setValue({ + privateKey: key.privateKey, + publicKey: key.publicKey, + keyFingerprint: key.keyFingerprint, + }); + } + } + private async generateSshKey() { await firstValueFrom(this.sdkService.client$); const sshKey = generate_ssh_key("Ed25519"); diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index e4857411d05..d21e430f0a3 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -25,8 +25,10 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon export * from "./components/carousel"; export * as VaultIcons from "./icons"; - export * from "./tasks"; +export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; +export { SshImportPromptService } from "./services/ssh-import-prompt.service"; + export * from "./abstractions/change-login-password.service"; export * from "./services/default-change-login-password.service"; diff --git a/libs/vault/src/services/default-ssh-import-prompt.service.ts b/libs/vault/src/services/default-ssh-import-prompt.service.ts new file mode 100644 index 00000000000..c4e51dd3638 --- /dev/null +++ b/libs/vault/src/services/default-ssh-import-prompt.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api"; +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui"; +import { import_ssh_key, SshKeyImportError, SshKeyView } from "@bitwarden/sdk-internal"; + +import { SshImportPromptService } from "./ssh-import-prompt.service"; + +/** + * Used to import ssh keys and prompt for their password. + */ +@Injectable() +export class DefaultSshImportPromptService implements SshImportPromptService { + constructor( + private dialogService: DialogService, + private toastService: ToastService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + ) {} + + async importSshKeyFromClipboard(): Promise { + const key = await this.platformUtilsService.readFromClipboard(); + + let isPasswordProtectedSshKey = false; + + let parsedKey: SshKeyView | null = null; + + try { + parsedKey = import_ssh_key(key); + } catch (e) { + const error = e as SshKeyImportError; + if (error.variant === "PasswordRequired" || error.variant === "WrongPassword") { + isPasswordProtectedSshKey = true; + } else { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)), + }); + return null; + } + } + + if (isPasswordProtectedSshKey) { + for (;;) { + const password = await this.getSshKeyPassword(); + if (password === "" || password == null) { + return null; + } + + try { + parsedKey = import_ssh_key(key, password); + break; + } catch (e) { + const error = e as SshKeyImportError; + if (error.variant !== "WrongPassword") { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)), + }); + return null; + } + } + } + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyImported"), + }); + + return new SshKeyData( + new SshKeyApi({ + privateKey: parsedKey!.privateKey, + publicKey: parsedKey!.publicKey, + keyFingerprint: parsedKey!.fingerprint, + }), + ); + } + + private sshImportErrorVariantToI18nKey(variant: string): string { + switch (variant) { + case "ParsingError": + return "invalidSshKey"; + case "UnsupportedKeyType": + return "sshKeyTypeUnsupported"; + case "PasswordRequired": + case "WrongPassword": + return "sshKeyWrongPassword"; + default: + return "errorOccurred"; + } + } + + private async getSshKeyPassword(): Promise { + const dialog = this.dialogService.open(SshKeyPasswordPromptComponent, { + ariaModal: true, + }); + + return await firstValueFrom(dialog.closed); + } +} diff --git a/libs/vault/src/services/ssh-import-prompt.service.spec.ts b/libs/vault/src/services/ssh-import-prompt.service.spec.ts new file mode 100644 index 00000000000..49b2b898d7a --- /dev/null +++ b/libs/vault/src/services/ssh-import-prompt.service.spec.ts @@ -0,0 +1,111 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api"; +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; +import { DialogService, ToastService } from "@bitwarden/components"; +import * as sdkInternal from "@bitwarden/sdk-internal"; + +import { DefaultSshImportPromptService } from "./default-ssh-import-prompt.service"; + +jest.mock("@bitwarden/sdk-internal"); + +const exampleSshKey = { + privateKey: "private_key", + publicKey: "public_key", + fingerprint: "key_fingerprint", +} as sdkInternal.SshKeyView; + +const exampleSshKeyData = new SshKeyData( + new SshKeyApi({ + publicKey: exampleSshKey.publicKey, + privateKey: exampleSshKey.privateKey, + keyFingerprint: exampleSshKey.fingerprint, + }), +); + +describe("SshImportPromptService", () => { + let sshImportPromptService: DefaultSshImportPromptService; + + let dialogService: MockProxy; + let toastService: MockProxy; + let platformUtilsService: MockProxy; + let i18nService: MockProxy; + + beforeEach(() => { + dialogService = mock(); + toastService = mock(); + platformUtilsService = mock(); + i18nService = mock(); + + sshImportPromptService = new DefaultSshImportPromptService( + dialogService, + toastService, + platformUtilsService, + i18nService, + ); + jest.clearAllMocks(); + }); + + describe("importSshKeyFromClipboard()", () => { + it("imports unencrypted ssh key", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(exampleSshKey); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData); + }); + + it("requests password for encrypted ssh key", async () => { + jest + .spyOn(sdkInternal, "import_ssh_key") + .mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }) + .mockImplementationOnce(() => exampleSshKey); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it("cancels when no password was provided", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("") } as any); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it("passes through error on no password", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => { + throw { variant: "UnsupportedKeyType" }; + }); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported"); + }); + + it("passes through error with password", async () => { + jest + .spyOn(sdkInternal, "import_ssh_key") + .mockClear() + .mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }) + .mockImplementationOnce(() => { + throw { variant: "UnsupportedKeyType" }; + }); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported"); + }); + }); +}); diff --git a/libs/vault/src/services/ssh-import-prompt.service.ts b/libs/vault/src/services/ssh-import-prompt.service.ts new file mode 100644 index 00000000000..aae5159895b --- /dev/null +++ b/libs/vault/src/services/ssh-import-prompt.service.ts @@ -0,0 +1,5 @@ +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; + +export abstract class SshImportPromptService { + abstract importSshKeyFromClipboard: () => Promise; +} diff --git a/libs/vault/tsconfig.json b/libs/vault/tsconfig.json index e1515183f22..6039dccd811 100644 --- a/libs/vault/tsconfig.json +++ b/libs/vault/tsconfig.json @@ -8,11 +8,13 @@ "@bitwarden/auth/common": ["../auth/src/common"], "@bitwarden/common/*": ["../common/src/*"], "@bitwarden/components": ["../components/src"], + "@bitwarden/importer-ui": ["../importer/src/components"], "@bitwarden/generator-components": ["../tools/generator/components/src"], "@bitwarden/generator-core": ["../tools/generator/core/src"], "@bitwarden/generator-history": ["../tools/generator/extensions/history/src"], "@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"], "@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"], + "@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"], "@bitwarden/key-management": ["../key-management/src"], "@bitwarden/platform": ["../platform/src"], "@bitwarden/ui-common": ["../ui/common/src"],