diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f784f375086..8affac3387b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -30,7 +30,7 @@ libs/common/src/auth @bitwarden/team-auth-dev apps/browser/src/tools @bitwarden/team-tools-dev apps/cli/src/tools @bitwarden/team-tools-dev apps/desktop/src/app/tools @bitwarden/team-tools-dev -apps/desktop/desktop_native/bitwarden_chromium_importer @bitwarden/team-tools-dev +apps/desktop/desktop_native/chromium_importer @bitwarden/team-tools-dev apps/web/src/app/tools @bitwarden/team-tools-dev libs/angular/src/tools @bitwarden/team-tools-dev libs/common/src/models/export @bitwarden/team-tools-dev diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 5e658546671..a0cd1b3dcbf 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -440,33 +440,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "bitwarden_chromium_importer" -version = "0.0.0" -dependencies = [ - "aes", - "aes-gcm", - "anyhow", - "async-trait", - "base64", - "cbc", - "hex", - "homedir", - "napi", - "napi-derive", - "oo7", - "pbkdf2", - "rand 0.9.1", - "rusqlite", - "security-framework", - "serde", - "serde_json", - "sha1", - "tokio", - "winapi", - "windows 0.61.1", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -606,6 +579,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chromium_importer" +version = "0.0.0" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "cbc", + "hex", + "homedir", + "oo7", + "pbkdf2", + "rand 0.9.1", + "rusqlite", + "security-framework", + "serde", + "serde_json", + "sha1", + "tokio", + "winapi", + "windows 0.61.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -968,7 +966,7 @@ dependencies = [ "anyhow", "autotype", "base64", - "bitwarden_chromium_importer", + "chromium_importer", "desktop_core", "hex", "napi", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 2168eaa0068..6a366316328 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -2,7 +2,7 @@ resolver = "2" members = [ "autotype", - "bitwarden_chromium_importer", + "chromium_importer", "core", "macos_provider", "napi", diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs deleted file mode 100644 index e6442e21742..00000000000 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Cryptographic primitives used in the SDK - -use anyhow::{anyhow, Result}; - -use aes::cipher::{ - block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut, KeyIvInit, -}; - -pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> Result> { - let iv = GenericArray::from_slice(iv); - let mut data = data.to_vec(); - cbc::Decryptor::::new(&key, iv) - .decrypt_padded_mut::(&mut data) - .map_err(|_| anyhow!("Failed to decrypt data"))?; - - Ok(data) -} - -#[cfg(test)] -mod tests { - use aes::cipher::{ - generic_array::{sequence::GenericSequence, GenericArray}, - ArrayLength, - }; - use base64::{engine::general_purpose::STANDARD, Engine}; - - pub fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { - (0..length).map(|i| offset + i as u8 * increment).collect() - } - pub fn generate_generic_array>( - offset: u8, - increment: u8, - ) -> GenericArray { - GenericArray::generate(|i| offset + i as u8 * increment) - } - - #[test] - fn test_decrypt_aes256() { - let iv = generate_vec(16, 0, 1); - let iv: &[u8; 16] = iv.as_slice().try_into().unwrap(); - let key = generate_generic_array(0, 1); - let data: Vec = STANDARD.decode("ByUF8vhyX4ddU9gcooznwA==").unwrap(); - - let decrypted = super::decrypt_aes256(iv, &data, key).unwrap(); - - assert_eq!(String::from_utf8(decrypted).unwrap(), "EncryptMe!\u{6}\u{6}\u{6}\u{6}\u{6}\u{6}"); - } -} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs deleted file mode 100644 index 84f140d2341..00000000000 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[macro_use] -extern crate napi_derive; - -pub mod chromium; -pub mod metadata; -pub mod util; - -pub use crate::chromium::platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml similarity index 92% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml rename to apps/desktop/desktop_native/chromium_importer/Cargo.toml index 656c3ad1504..648a36543c2 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "bitwarden_chromium_importer" +name = "chromium_importer" edition = { workspace = true } license = { workspace = true } version = { workspace = true } @@ -14,8 +14,6 @@ base64 = { workspace = true } cbc = { workspace = true, features = ["alloc"] } hex = { workspace = true } homedir = { workspace = true } -napi = { workspace = true } -napi-derive = { workspace = true } pbkdf2 = "=0.12.2" rand = { workspace = true } rusqlite = { version = "=0.37.0", features = ["bundled"] } @@ -36,4 +34,3 @@ oo7 = { workspace = true } [lints] workspace = true - diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md b/apps/desktop/desktop_native/chromium_importer/README.md similarity index 94% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/README.md rename to apps/desktop/desktop_native/chromium_importer/README.md index 498dd3ac67d..dd563697e5b 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md +++ b/apps/desktop/desktop_native/chromium_importer/README.md @@ -1,6 +1,13 @@ -# Windows ABE Architecture +# Chromium Direct Importer -## Overview +A rust library that allows you to directly import credentials from Chromium-based browsers. + +## Windows ABE Architecture + +On Windows chrome has additional protection measurements which needs to be circumvented in order to +get access to the passwords. + +### Overview The Windows Application Bound Encryption (ABE) consists of three main components that work together: @@ -10,7 +17,7 @@ The Windows Application Bound Encryption (ABE) consists of three main components _(The names of the binaries will be changed for the released product.)_ -## The goal +### The goal The goal of this subsystem is to decrypt the master encryption key with which the login information is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and @@ -24,7 +31,7 @@ Protection API at the system level on top of that. This triply encrypted key is The next paragraphs describe what is done at each level to decrypt the key. -## 1. Client library +### 1. Client library This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows (see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges @@ -52,7 +59,7 @@ admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXRE **At this point, the user must permit the action to be performed on the UAC screen.** -## 2. Admin executable +### 2. Admin executable This executable receives the full path of `service.exe` and the data to be decrypted. @@ -67,7 +74,7 @@ is sent to the named pipe server created by the user. The user responds with `ok After that, the executable stops and uninstalls the service and then exits. -## 3. System service +### 3. System service The service starts and creates a named pipe server for communication between `admin.exe` and the system service. Please note that it is not possible to communicate between the user and the system @@ -83,7 +90,7 @@ removed from the system. Even though we send only one request, the service is de many clients with as many messages as needed and could be installed on the system permanently if necessary. -## 4. Back to client library +### 4. Back to client library The decrypted base64-encoded string comes back from the admin executable to the named pipe server at the user level. At this point, it has been decrypted only once at the system level. @@ -99,7 +106,7 @@ itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The deta After all of these steps, we have the master key which can be used to decrypt the password information stored in the local database. -## Summary +### Summary The Windows ABE decryption process involves a three-tier architecture with named pipe communication: diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs similarity index 89% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs index 094500e6d42..55728460436 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs @@ -7,11 +7,9 @@ use hex::decode; use homedir::my_home; use rusqlite::{params, Connection}; -// Platform-specific code -#[cfg_attr(target_os = "linux", path = "linux.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] -#[cfg_attr(target_os = "macos", path = "macos.rs")] -pub mod platform; +mod platform; + +pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; // // Public API @@ -22,10 +20,7 @@ pub struct ProfileInfo { pub name: String, pub folder: String, - #[allow(dead_code)] pub account_name: Option, - - #[allow(dead_code)] pub account_email: Option, } @@ -113,12 +108,12 @@ pub async fn import_logins( // #[derive(Debug, Clone, Copy)] -pub struct BrowserConfig { +pub(crate) struct BrowserConfig { pub name: &'static str, pub data_dir: &'static str, } -pub static SUPPORTED_BROWSER_MAP: LazyLock< +pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock< std::collections::HashMap<&'static str, &'static BrowserConfig>, > = LazyLock::new(|| { platform::SUPPORTED_BROWSERS @@ -140,12 +135,12 @@ fn get_browser_data_dir(config: &BrowserConfig) -> Result { // #[async_trait] -pub trait CryptoService: Send { +pub(crate) trait CryptoService: Send { async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result; } #[derive(serde::Deserialize, Clone)] -pub struct LocalState { +pub(crate) struct LocalState { profile: AllProfiles, #[allow(dead_code)] os_crypt: Option, @@ -198,16 +193,17 @@ fn load_local_state(browser_dir: &Path) -> Result { } fn get_profile_info(local_state: &LocalState) -> Vec { - let mut profile_infos = Vec::new(); - for (name, info) in local_state.profile.info_cache.iter() { - profile_infos.push(ProfileInfo { + local_state + .profile + .info_cache + .iter() + .map(|(name, info)| ProfileInfo { name: info.name.clone(), folder: name.clone(), account_name: info.gaia_name.clone(), account_email: info.user_name.clone(), - }); - } - profile_infos + }) + .collect() } struct EncryptedLogin { @@ -264,17 +260,16 @@ fn hex_to_bytes(hex: &str) -> Vec { decode(hex).unwrap_or_default() } -fn does_table_exist(conn: &Connection, table_name: &str) -> Result { - let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?; - let exists = stmt.exists(params![table_name])?; - Ok(exists) +fn table_exist(conn: &Connection, table_name: &str) -> Result { + conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")? + .exists(params![table_name]) } fn query_logins(db_path: &str) -> Result, rusqlite::Error> { let conn = Connection::open(db_path)?; - let have_logins = does_table_exist(&conn, "logins")?; - let have_password_notes = does_table_exist(&conn, "password_notes")?; + let have_logins = table_exist(&conn, "logins")?; + let have_password_notes = table_exist(&conn, "password_notes")?; if !have_logins || !have_password_notes { return Ok(vec![]); } @@ -308,10 +303,7 @@ fn query_logins(db_path: &str) -> Result, rusqlite::Error> { }) })?; - let mut logins = Vec::new(); - for login in logins_iter { - logins.push(login?); - } + let logins = logins_iter.collect::, _>>()?; Ok(logins) } diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs similarity index 97% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs index be3bcdb1e1d..227dffdcca7 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs @@ -13,7 +13,7 @@ use crate::util; // // TODO: It's possible that there might be multiple possible data directories, depending on the installation method (e.g., snap, flatpak, etc.). -pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", data_dir: ".config/google-chrome", @@ -32,7 +32,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ }, ]; -pub fn get_crypto_service( +pub(crate) fn get_crypto_service( browser_name: &String, _local_state: &LocalState, ) -> Result> { diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs similarity index 97% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs index bcb2c005000..c0e770c161b 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs @@ -10,7 +10,7 @@ use crate::util; // Public API // -pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", data_dir: "Library/Application Support/Google/Chrome", @@ -41,7 +41,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ }, ]; -pub fn get_crypto_service( +pub(crate) fn get_crypto_service( browser_name: &String, _local_state: &LocalState, ) -> Result> { diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs new file mode 100644 index 00000000000..2a21ef23d82 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs @@ -0,0 +1,7 @@ +// Platform-specific code +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod native; + +pub(crate) use native::*; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs similarity index 97% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs rename to apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs index 096808aafb6..79c462c29a1 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs @@ -15,8 +15,7 @@ use crate::util; // Public API // -// IMPORTANT adjust array size when enabling / disabling chromium importers here -pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Brave", data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", @@ -43,7 +42,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ }, ]; -pub fn get_crypto_service( +pub(crate) fn get_crypto_service( _browser_name: &str, local_state: &LocalState, ) -> Result> { diff --git a/apps/desktop/desktop_native/chromium_importer/src/lib.rs b/apps/desktop/desktop_native/chromium_importer/src/lib.rs new file mode 100644 index 00000000000..d92515c39f9 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/lib.rs @@ -0,0 +1,5 @@ +#![doc = include_str!("../README.md")] + +pub mod chromium; +pub mod metadata; +mod util; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs similarity index 96% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs rename to apps/desktop/desktop_native/chromium_importer/src/metadata.rs index 28f13cd9863..bfd7f184621 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -1,8 +1,7 @@ use std::collections::{HashMap, HashSet}; -use crate::{chromium::InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS}; +use crate::chromium::{InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS}; -#[napi(object)] /// Mechanisms that load data into the importer pub struct NativeImporterMetadata { /// Identifies the importer @@ -24,7 +23,7 @@ pub fn get_supported_importers( // Check for installed browsers let installed_browsers = T::get_installed_browsers().unwrap_or_default(); - const IMPORTERS: [(&str, &str); 6] = [ + const IMPORTERS: &[(&str, &str)] = &[ ("chromecsv", "Chrome"), ("chromiumcsv", "Chromium"), ("bravecsv", "Brave"), @@ -57,9 +56,7 @@ pub fn get_supported_importers( map } -/* - Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage -*/ +// Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs b/apps/desktop/desktop_native/chromium_importer/src/util.rs similarity index 77% rename from apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs rename to apps/desktop/desktop_native/chromium_importer/src/util.rs index e9c20ab621d..f346d7e6dd0 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/util.rs @@ -1,9 +1,6 @@ -use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; use anyhow::{anyhow, Result}; -use pbkdf2::{hmac::Hmac, pbkdf2}; -use sha1::Sha1; -pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { +fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { if encrypted.len() < 3 { return Err(anyhow!( "Corrupted entry: invalid encrypted string length, expected at least 3 bytes, got {}", @@ -15,7 +12,14 @@ pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { Ok((std::str::from_utf8(version)?, password)) } -pub fn split_encrypted_string_and_validate<'a>( +/// A Chromium password consists of three parts: +/// - Version (3 bytes): "v10", "v11", etc. +/// - Cipher text (chunks of 16 bytes) +/// - Padding (1-15 bytes) +/// +/// This function splits the encrypted byte slice into version and cipher text. +/// Padding is included and handled by the underlying cryptographic library. +pub(crate) fn split_encrypted_string_and_validate<'a>( encrypted: &'a [u8], supported_versions: &[&str], ) -> Result<(&'a str, &'a [u8])> { @@ -27,15 +31,22 @@ pub fn split_encrypted_string_and_validate<'a>( Ok((version, password)) } -pub fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { - let decryptor = cbc::Decryptor::::new_from_slices(key, iv)?; - let plaintext: Vec = decryptor +/// Decrypt using AES-128 in CBC mode. +#[cfg(any(target_os = "linux", target_os = "macos", test))] +pub(crate) fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { + use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; + + cbc::Decryptor::::new_from_slices(key, iv)? .decrypt_padded_vec_mut::(ciphertext) - .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; - Ok(plaintext) + .map_err(|e| anyhow!("Failed to decrypt: {}", e)) } -pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { +/// Derives a PBKDF2 key from the static "saltysalt" salt with the given password and iteration count. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { + use pbkdf2::{hmac::Hmac, pbkdf2}; + use sha1::Sha1; + let mut key = vec![0u8; 16]; pbkdf2::>(password, b"saltysalt", iterations, &mut key) .map_err(|e| anyhow!("Failed to derive master key: {}", e))?; @@ -44,16 +55,6 @@ pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { #[cfg(test)] mod tests { - pub fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { - (0..length).map(|i| offset + i as u8 * increment).collect() - } - pub fn generate_generic_array>( - offset: u8, - increment: u8, - ) -> GenericArray { - GenericArray::generate(|i| offset + i as u8 * increment) - } - use aes::cipher::{ block_padding::Pkcs7, generic_array::{sequence::GenericSequence, GenericArray}, @@ -64,6 +65,17 @@ mod tests { const LENGTH10: usize = 10; const LENGTH0: usize = 0; + fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { + (0..length).map(|i| offset + i as u8 * increment).collect() + } + + fn generate_generic_array>( + offset: u8, + increment: u8, + ) -> GenericArray { + GenericArray::generate(|i| offset + i as u8 * increment) + } + fn run_split_encrypted_string_test<'a, const N: usize>( successfully_split: bool, plaintext_to_encrypt: &'a str, diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 5e2e42b463f..4198baa4b5a 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -17,7 +17,7 @@ manual_test = [] anyhow = { workspace = true } autotype = { path = "../autotype" } base64 = { workspace = true } -bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" } +chromium_importer = { path = "../chromium_importer" } desktop_core = { path = "../core" } hex = { workspace = true } napi = { workspace = true, features = ["async"] } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 59751cd3246..cd49e5ac27a 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -3,15 +3,6 @@ /* auto-generated by NAPI-RS */ -/** Mechanisms that load data into the importer */ -export interface NativeImporterMetadata { - /** Identifies the importer */ - id: string - /** Describes the strategies used to obtain imported data */ - loaders: Array - /** Identifies the instructions for the importer */ - instructions: string -} export declare namespace passwords { /** The error message returned when a password is not found during retrieval or deletion. */ export const PASSWORD_NOT_FOUND: string @@ -249,6 +240,11 @@ export declare namespace chromium_importer { login?: Login failure?: LoginImportFailure } + export interface NativeImporterMetadata { + id: string + loaders: Array + instructions: string + } /** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */ export function getMetadata(): Record export function getInstalledBrowsers(): Array diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 09f63f7854b..61453994d72 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -1064,11 +1064,13 @@ pub mod logging { #[napi] pub mod chromium_importer { - use bitwarden_chromium_importer::chromium::DefaultInstalledBrowserRetriever; - use bitwarden_chromium_importer::chromium::InstalledBrowserRetriever; - use bitwarden_chromium_importer::chromium::LoginImportResult as _LoginImportResult; - use bitwarden_chromium_importer::chromium::ProfileInfo as _ProfileInfo; - use bitwarden_chromium_importer::metadata::NativeImporterMetadata; + use chromium_importer::{ + chromium::{ + DefaultInstalledBrowserRetriever, InstalledBrowserRetriever, + LoginImportResult as _LoginImportResult, ProfileInfo as _ProfileInfo, + }, + metadata::NativeImporterMetadata as _NativeImporterMetadata, + }; use std::collections::HashMap; #[napi(object)] @@ -1098,6 +1100,13 @@ pub mod chromium_importer { pub failure: Option, } + #[napi(object)] + pub struct NativeImporterMetadata { + pub id: String, + pub loaders: Vec<&'static str>, + pub instructions: &'static str, + } + impl From<_LoginImportResult> for LoginImportResult { fn from(l: _LoginImportResult) -> Self { match l { @@ -1131,23 +1140,34 @@ pub mod chromium_importer { } } + impl From<_NativeImporterMetadata> for NativeImporterMetadata { + fn from(m: _NativeImporterMetadata) -> Self { + NativeImporterMetadata { + id: m.id, + loaders: m.loaders, + instructions: m.instructions, + } + } + } + #[napi] /// Returns OS aware metadata describing supported Chromium based importers as a JSON string. pub fn get_metadata() -> HashMap { - bitwarden_chromium_importer::metadata::get_supported_importers::< - DefaultInstalledBrowserRetriever, - >() + chromium_importer::metadata::get_supported_importers::() + .into_iter() + .map(|(browser, metadata)| (browser, NativeImporterMetadata::from(metadata))) + .collect() } #[napi] pub fn get_installed_browsers() -> napi::Result> { - bitwarden_chromium_importer::chromium::DefaultInstalledBrowserRetriever::get_installed_browsers() + chromium_importer::chromium::DefaultInstalledBrowserRetriever::get_installed_browsers() .map_err(|e| napi::Error::from_reason(e.to_string())) } #[napi] pub fn get_available_profiles(browser: String) -> napi::Result> { - bitwarden_chromium_importer::chromium::get_available_profiles(&browser) + chromium_importer::chromium::get_available_profiles(&browser) .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect()) .map_err(|e| napi::Error::from_reason(e.to_string())) } @@ -1157,7 +1177,7 @@ pub mod chromium_importer { browser: String, profile_id: String, ) -> napi::Result> { - bitwarden_chromium_importer::chromium::import_logins(&browser, &profile_id) + chromium_importer::chromium::import_logins(&browser, &profile_id) .await .map(|logins| logins.into_iter().map(LoginImportResult::from).collect()) .map_err(|e| napi::Error::from_reason(e.to_string())) diff --git a/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts b/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts index fc2c2ff1183..0c29cd9f44a 100644 --- a/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts +++ b/apps/desktop/src/app/tools/import/desktop-import-metadata.service.ts @@ -1,5 +1,5 @@ import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import type { NativeImporterMetadata } from "@bitwarden/desktop-napi"; +import type { chromium_importer } from "@bitwarden/desktop-napi"; import { ImportType, DefaultImportMetadataService, @@ -25,7 +25,9 @@ export class DesktopImportMetadataService await super.init(); } - private async parseNativeMetaData(raw: Record): Promise { + private async parseNativeMetaData( + raw: Record, + ): Promise { const entries = Object.entries(raw).map(([id, meta]) => { const loaders = meta.loaders.map(this.mapLoader); const instructions = this.mapInstructions(meta.instructions); diff --git a/apps/desktop/src/app/tools/preload.ts b/apps/desktop/src/app/tools/preload.ts index 4d629c992ad..b872f108551 100644 --- a/apps/desktop/src/app/tools/preload.ts +++ b/apps/desktop/src/app/tools/preload.ts @@ -1,9 +1,9 @@ import { ipcRenderer } from "electron"; -import type { NativeImporterMetadata } from "@bitwarden/desktop-napi"; +import type { chromium_importer } from "@bitwarden/desktop-napi"; const chromiumImporter = { - getMetadata: (): Promise> => + getMetadata: (): Promise> => ipcRenderer.invoke("chromium_importer.getMetadata"), getInstalledBrowsers: (): Promise => ipcRenderer.invoke("chromium_importer.getInstalledBrowsers"), diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index a4ebba7a760..7c081b38279 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -31,6 +31,7 @@ import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-conso import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; @@ -41,7 +42,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; @@ -654,7 +655,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { orgId = this.selfHosted ? await this.createSelfHosted(key, collectionCt, orgKeys) - : await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1]); + : await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId); this.toastService.showToast({ variant: "success", @@ -808,6 +809,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { collectionCt: string, orgKeys: [string, EncString], orgKey: SymmetricCryptoKey, + activeUserId: UserId, ): Promise { const request = new OrganizationCreateRequest(); request.key = key; @@ -855,7 +857,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.formGroup.controls.clientOwnerEmail.value, request, ); - const providerKey = await this.keyService.getProviderKey(this.providerId); + + const providerKey = await firstValueFrom( + this.keyService + .providerKeys$(activeUserId) + .pipe(map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null)), + ); + assertNonNullish(providerKey, "Provider key not found"); + providerRequest.organizationCreateRequest.key = ( await this.encryptService.wrapSymmetricKey(orgKey, providerKey) ).encryptedString; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-existing-organization-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-existing-organization-dialog.component.ts index e36e4e5f0c6..8ce8153b36e 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-existing-organization-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-existing-organization-dialog.component.ts @@ -1,8 +1,11 @@ import { Component, Inject, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DIALOG_DATA, @@ -46,6 +49,7 @@ export class AddExistingOrganizationDialogComponent implements OnInit { private providerApiService: ProviderApiServiceAbstraction, private toastService: ToastService, private webProviderService: WebProviderService, + private accountService: AccountService, ) {} async ngOnInit() { @@ -57,9 +61,11 @@ export class AddExistingOrganizationDialogComponent implements OnInit { addExistingOrganization = async (): Promise => { if (this.selectedOrganization) { + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); await this.webProviderService.addOrganizationToProvider( this.dialogParams.provider.id, this.selectedOrganization.id, + userId, ); this.toastService.showToast({ diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts index dd54b842062..7ade77ed01b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, Inject } from "@angular/core"; +import { firstValueFrom, map, Observable, switchMap } from "rxjs"; import { OrganizationUserBulkPublicKeyResponse, @@ -12,10 +13,14 @@ import { ProviderUserBulkConfirmRequest } from "@bitwarden/common/admin-console/ import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response"; import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { ProviderId } from "@bitwarden/common/types/guid"; +import { ProviderKey } from "@bitwarden/common/types/key"; import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { BaseBulkConfirmComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/base-bulk-confirm.component"; @@ -35,6 +40,7 @@ type BulkConfirmDialogParams = { }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { providerId: string; + providerKey$: Observable; constructor( private apiService: ApiService, @@ -42,15 +48,21 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { protected encryptService: EncryptService, @Inject(DIALOG_DATA) protected dialogParams: BulkConfirmDialogParams, protected i18nService: I18nService, + private accountService: AccountService, ) { super(keyService, encryptService, i18nService); this.providerId = dialogParams.providerId; + this.providerKey$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.providerKeys$(userId)), + map((providerKeysById) => providerKeysById?.[this.providerId as ProviderId]), + ); this.users = dialogParams.users; } - protected getCryptoKey = (): Promise => - this.keyService.getProviderKey(this.providerId); + protected getCryptoKey = async (): Promise => + await firstValueFrom(this.providerKey$); protected getPublicKeys = async (): Promise< ListResponse diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index b1cd52cf8a6..268a82ac12f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -4,7 +4,7 @@ import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs"; -import { first } from "rxjs/operators"; +import { first, map } from "rxjs/operators"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -16,11 +16,13 @@ import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/mode import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { ProviderId } from "@bitwarden/common/types/guid"; import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { BaseMembersComponent } from "@bitwarden/web-vault/app/admin-console/common/base-members.component"; @@ -204,7 +206,15 @@ export class MembersComponent extends BaseMembersComponent { async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise { try { - const providerKey = await this.keyService.getProviderKey(this.providerId); + const providerKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.providerKeys$(userId)), + map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null), + ), + ); + assertNonNullish(providerKey, "Provider key not found"); + const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey); const request = new ProviderUserConfirmRequest(); request.key = key.encryptedString; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.spec.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.spec.ts index b2da18dd047..2accd760fcb 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.spec.ts @@ -1,4 +1,5 @@ import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; @@ -8,7 +9,6 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { StateProvider } from "@bitwarden/common/platform/state"; import { OrgKey, ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { newGuid } from "@bitwarden/guid"; @@ -24,16 +24,22 @@ describe("WebProviderService", () => { let apiService: MockProxy; let i18nService: MockProxy; let encryptService: MockProxy; - let stateProvider: MockProxy; let providerApiService: MockProxy; + const activeUserId = newGuid() as UserId; + const providerId = "provider-123"; + const mockOrgKey = new SymmetricCryptoKey(new Uint8Array(64)) as OrgKey; + const mockProviderKey = new SymmetricCryptoKey(new Uint8Array(64)) as ProviderKey; + const mockProviderKeysById: Record = { + [providerId]: mockProviderKey, + }; + beforeEach(() => { keyService = mock(); syncService = mock(); apiService = mock(); i18nService = mock(); encryptService = mock(); - stateProvider = mock(); providerApiService = mock(); sut = new WebProviderService( @@ -42,14 +48,69 @@ describe("WebProviderService", () => { apiService, i18nService, encryptService, - stateProvider, providerApiService, ); }); + describe("addOrganizationToProvider", () => { + const organizationId = "org-789"; + const encryptedOrgKey = new EncString("encrypted-org-key"); + const mockOrgKeysById: Record = { + [organizationId]: mockOrgKey, + }; + + beforeEach(() => { + keyService.orgKeys$.mockReturnValue(of(mockOrgKeysById)); + keyService.providerKeys$.mockReturnValue(of(mockProviderKeysById)); + encryptService.wrapSymmetricKey.mockResolvedValue(encryptedOrgKey); + }); + + it("adds an organization to a provider with correct encryption", async () => { + await sut.addOrganizationToProvider(providerId, organizationId, activeUserId); + + expect(keyService.orgKeys$).toHaveBeenCalledWith(activeUserId); + expect(keyService.providerKeys$).toHaveBeenCalledWith(activeUserId); + expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockOrgKey, mockProviderKey); + expect(providerApiService.addOrganizationToProvider).toHaveBeenCalledWith(providerId, { + key: encryptedOrgKey.encryptedString, + organizationId, + }); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("throws an error if organization key is not found", async () => { + const invalidOrgId = "invalid-org"; + + await expect( + sut.addOrganizationToProvider(providerId, invalidOrgId, activeUserId), + ).rejects.toThrow("Organization key not found"); + }); + + it("throws an error if no organization keys are available", async () => { + keyService.orgKeys$.mockReturnValue(of(null)); + + await expect( + sut.addOrganizationToProvider(providerId, organizationId, activeUserId), + ).rejects.toThrow("Organization key not found"); + }); + + it("throws an error if provider key is not found", async () => { + const invalidProviderId = "invalid-provider"; + await expect( + sut.addOrganizationToProvider(invalidProviderId, organizationId, activeUserId), + ).rejects.toThrow("Provider key not found"); + }); + + it("throws an error if no provider keys are available", async () => { + keyService.providerKeys$.mockReturnValue(of(null)); + + await expect( + sut.addOrganizationToProvider(providerId, organizationId, activeUserId), + ).rejects.toThrow("Provider key not found"); + }); + }); + describe("createClientOrganization", () => { - const activeUserId = newGuid() as UserId; - const providerId = "provider-123"; const name = "Test Org"; const ownerEmail = "owner@example.com"; const planType = PlanType.EnterpriseAnnually; @@ -59,15 +120,13 @@ describe("WebProviderService", () => { const encryptedProviderKey = new EncString("encrypted-provider-key"); const encryptedCollectionName = new EncString("encrypted-collection-name"); const defaultCollectionTranslation = "Default Collection"; - const mockOrgKey = new SymmetricCryptoKey(new Uint8Array(64)) as OrgKey; - const mockProviderKey = new SymmetricCryptoKey(new Uint8Array(64)) as ProviderKey; beforeEach(() => { keyService.makeOrgKey.mockResolvedValue([new EncString("mockEncryptedKey"), mockOrgKey]); keyService.makeKeyPair.mockResolvedValue([publicKey, encryptedPrivateKey]); i18nService.t.mockReturnValue(defaultCollectionTranslation); encryptService.encryptString.mockResolvedValue(encryptedCollectionName); - keyService.getProviderKey.mockResolvedValue(mockProviderKey); + keyService.providerKeys$.mockReturnValue(of(mockProviderKeysById)); encryptService.wrapSymmetricKey.mockResolvedValue(encryptedProviderKey); }); @@ -88,7 +147,7 @@ describe("WebProviderService", () => { defaultCollectionTranslation, mockOrgKey, ); - expect(keyService.getProviderKey).toHaveBeenCalledWith(providerId); + expect(keyService.providerKeys$).toHaveBeenCalledWith(activeUserId); expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockOrgKey, mockProviderKey); expect(providerApiService.createProviderOrganization).toHaveBeenCalledWith( @@ -107,5 +166,27 @@ describe("WebProviderService", () => { expect(apiService.refreshIdentityToken).toHaveBeenCalled(); expect(syncService.fullSync).toHaveBeenCalledWith(true); }); + + it("throws an error if provider key is not found", async () => { + const invalidProviderId = "invalid-provider"; + await expect( + sut.createClientOrganization( + invalidProviderId, + name, + ownerEmail, + planType, + seats, + activeUserId, + ), + ).rejects.toThrow("Provider key not found"); + }); + + it("throws an error if no provider keys are available", async () => { + keyService.providerKeys$.mockReturnValue(of(null)); + + await expect( + sut.createClientOrganization(providerId, name, ownerEmail, planType, seats, activeUserId), + ).rejects.toThrow("Provider key not found"); + }); }); }); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts index 78931f9c445..e1eea78d26a 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts @@ -1,18 +1,17 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; -import { switchMap } from "rxjs/operators"; +import { combineLatest, firstValueFrom, map } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { CreateProviderOrganizationRequest } from "@bitwarden/common/admin-console/models/request/create-provider-organization.request"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { PlanType } from "@bitwarden/common/billing/enums"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { StateProvider } from "@bitwarden/common/platform/state"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { KeyService } from "@bitwarden/key-management"; @@ -25,18 +24,26 @@ export class WebProviderService { private apiService: ApiService, private i18nService: I18nService, private encryptService: EncryptService, - private stateProvider: StateProvider, private providerApiService: ProviderApiServiceAbstraction, ) {} - async addOrganizationToProvider(providerId: string, organizationId: string): Promise { - const orgKey = await firstValueFrom( - this.stateProvider.activeUserId$.pipe( - switchMap((userId) => this.keyService.orgKeys$(userId)), - map((organizationKeysById) => organizationKeysById[organizationId as OrganizationId]), - ), + async addOrganizationToProvider( + providerId: string, + organizationId: string, + activeUserId: UserId, + ): Promise { + const [orgKeysById, providerKeys] = await firstValueFrom( + combineLatest([ + this.keyService.orgKeys$(activeUserId), + this.keyService.providerKeys$(activeUserId), + ]), ); - const providerKey = await this.keyService.getProviderKey(providerId); + + const orgKey = orgKeysById?.[organizationId as OrganizationId]; + const providerKey = providerKeys?.[providerId as ProviderId]; + assertNonNullish(orgKey, "Organization key not found"); + assertNonNullish(providerKey, "Provider key not found"); + const encryptedOrgKey = await this.encryptService.wrapSymmetricKey(orgKey, providerKey); await this.providerApiService.addOrganizationToProvider(providerId, { key: encryptedOrgKey.encryptedString, @@ -62,7 +69,12 @@ export class WebProviderService { organizationKey, ); - const providerKey = await this.keyService.getProviderKey(providerId); + const providerKey = await firstValueFrom( + this.keyService + .providerKeys$(activeUserId) + .pipe(map((providerKeys) => providerKeys?.[providerId as ProviderId])), + ); + assertNonNullish(providerKey, "Provider key not found"); const encryptedProviderKey = await this.encryptService.wrapSymmetricKey( organizationKey, diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index c2c92104727..a4af25a2492 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -97,7 +97,7 @@
diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index abd4dcc1563..7891c9952b2 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -10,7 +10,7 @@ import { import { WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey, @@ -248,17 +248,19 @@ export abstract class KeyService { /** * Stores the provider keys for a given user. - * @param orgs The provider orgs for which to save the keys from. + * @param providers The provider orgs for which to save the keys from. * @param userId The user id of the user for which to store the keys for. */ - abstract setProviderKeys(orgs: ProfileProviderResponse[], userId: UserId): Promise; + abstract setProviderKeys(providers: ProfileProviderResponse[], userId: UserId): Promise; + /** - * - * @throws Error when providerId is null or no active user - * @param providerId The desired provider - * @returns The provider's symmetric key + * Gets an observable of provider keys for the given user. + * @param userId The user to get provider keys for. + * @return An observable stream of the users providers keys if they are unlocked, or null if the user is not unlocked. + * @throws If an invalid user id is passed in. */ - abstract getProviderKey(providerId: string): Promise; + abstract providerKeys$(userId: UserId): Observable | null>; + /** * Creates a new organization key and encrypts it with the user's public key. * This method can also return Provider keys for creating new Provider users. diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 0dd9f3603f5..5d5340d4900 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -39,7 +39,7 @@ import { FakeSingleUserState, } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, ProviderId, UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey, @@ -1314,6 +1314,49 @@ describe("keyService", () => { }); }); + describe("providerKeys$", () => { + let mockUserPrivateKey: Uint8Array; + let mockProviderKeys: Record; + + beforeEach(() => { + mockUserPrivateKey = makeStaticByteArray(64, 1); + mockProviderKeys = { + ["provider1" as ProviderId]: makeSymmetricCryptoKey(64), + ["provider2" as ProviderId]: makeSymmetricCryptoKey(64), + }; + }); + + it("returns null when userPrivateKey is null", async () => { + jest.spyOn(keyService, "userPrivateKey$").mockReturnValue(of(null)); + + const result = await firstValueFrom(keyService.providerKeys$(mockUserId)); + + expect(result).toBeNull(); + }); + + it("returns provider keys when userPrivateKey is available", async () => { + jest.spyOn(keyService, "userPrivateKey$").mockReturnValue(of(mockUserPrivateKey as any)); + jest.spyOn(keyService as any, "providerKeysHelper$").mockReturnValue(of(mockProviderKeys)); + + const result = await firstValueFrom(keyService.providerKeys$(mockUserId)); + + expect(result).toEqual(mockProviderKeys); + expect((keyService as any).providerKeysHelper$).toHaveBeenCalledWith( + mockUserId, + mockUserPrivateKey, + ); + }); + + it("returns null when providerKeysHelper$ returns null", async () => { + jest.spyOn(keyService, "userPrivateKey$").mockReturnValue(of(mockUserPrivateKey as any)); + jest.spyOn(keyService as any, "providerKeysHelper$").mockReturnValue(of(null)); + + const result = await firstValueFrom(keyService.providerKeys$(mockUserId)); + + expect(result).toBeNull(); + }); + }); + describe("makeKeyPair", () => { test.each([null as unknown as SymmetricCryptoKey, undefined as unknown as SymmetricCryptoKey])( "throws when the provided key is %s", diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index fc340410124..032faeaf42e 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -426,20 +426,16 @@ export class DefaultKeyService implements KeyServiceAbstraction { }); } - // TODO: Deprecate in favor of observable - async getProviderKey(providerId: ProviderId): Promise { - if (providerId == null) { - return null; - } + providerKeys$(userId: UserId): Observable | null> { + return this.userPrivateKey$(userId).pipe( + switchMap((userPrivateKey) => { + if (userPrivateKey == null) { + return of(null); + } - const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$); - if (activeUserId == null) { - throw new Error("No active user found."); - } - - const providerKeys = await firstValueFrom(this.providerKeys$(activeUserId)); - - return providerKeys?.[providerId] ?? null; + return this.providerKeysHelper$(userId, userPrivateKey); + }), + ); } private async clearProviderKeys(userId: UserId): Promise { @@ -829,18 +825,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { )) as UserPrivateKey; } - providerKeys$(userId: UserId) { - return this.userPrivateKey$(userId).pipe( - switchMap((userPrivateKey) => { - if (userPrivateKey == null) { - return of(null); - } - - return this.providerKeysHelper$(userId, userPrivateKey); - }), - ); - } - /** * A helper for decrypting provider keys that requires a user id and that users decrypted private key * this is helpful for when you may have already grabbed the user private key and don't want to redo