From 66f5700a75ffc114dac0abf1fcb9f9ab64e9d525 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:21:57 +0200 Subject: [PATCH] [PM-24748][PM-24072] Chromium importer (#16100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add importer dummy lib, add cargo deps for win/mac * Add Chromium importer source from bitwarden/password-access * Mod crypto is no more * Expose some Chromium importer functions via NAPI, replace home with home_dir crate * Add Chromium importer to the main <-> renderer IPC, export all functions from Rust * Add password and notes fields to the imported logins * Fix windows to use homedir instead of home * Return success/failure results * Import from account logins and join * Linux v10 support * Use mod util on Windows * Use mod util on macOS * Refactor to move shared code into chromium.rs * Fix windows * Fix Linux as well * Linux v11 support for Chrome/Gnome, everything is async now * Support multiple browsers on Linux v11 * Move oo7 to Linux * Fix Windows * Fix macOS * Add support for Brave browser in Linux configuration * Add support for Opera browser in Linux configuration * Fix Edge and add Arc on macOS * Add Opera on macOS * Add support for Vivaldi browser in macOS configuration * Add support for Chromium browser in macOS configuration * Fix Edge on Windows * Add Opera on Windows * Add Vivaldi on windows * Add Chromium to supported browsers on Windows * stub out UI options for chromium direct import * call IPC funcs from import-desktop * add notes to chrome csv importer * remove (csv) from import tool names and format item names as hostnames * Add ABE/v20 encryption support * ABE/v20 architecture description * Add a build step to produce admin.exe and service.exe * Add Windows v20/ABE configuration functionality to specify the full path to the admin.exe and service.exe. Use ipc.platform.chromiumImporter.configureWindowsCryptoService to configure the Chromium importer on Windows. * rename ARCHITECTURE.md to README.md * aligns with guidance from architecture re: in-repository documentation. * also fixes a failing lint. * cargo fmt * cargo clippy fix * Declare feature flag for using chromium importer * Linter fix after executing npm run prettier * Use feature flag to guard the use of the chromium importer * Added temporary logging to further debug, why the Angular change detection isn't working as expected * introduce importer metadata; host metadata from service; includes tests * fix cli build * Register autotype module in lib.rs introduce by a bad merge * Fix web build * Fix issue with loaders being undefined and the feature flag turned off * Add missing Chromium support when selecting chromecsv * debugging * remove chromium support from chromecsv metadata * fix default loader selection * [PM-24753] cargo lib file (#16090) * Add new modules * Fix chromium importer * Fix compile bugs for toolchain * remove importer folder * remove IPC code * undo setting change * clippy fixes * cargo fmt * clippy fixes * clippy fixes * clippy fixes * clippy fixes * lint fix * fix release build * Add files in CODEOWNERS * Create tools owned preload.ts * Move chromium-importer.service under tools-ownership * Fix typeError When accessing the Chromium direct import options the file button is hidden, so trying to access it's values will fail * Fix tools owned preload * Remove dead code and redundant truncation * Remove configureWindowsCryptoService function/methods * Clean up cargo files * Fix unused async * Update apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml Co-authored-by: Oscar Hinton * Fix napi deps * fix lints * format * fix linux lint * fix windows lints * format * fix missing `?` * fix a different missing `?` --------- Co-authored-by: Dmitry Yakimenko Co-authored-by: Kyle Spearrin Co-authored-by: Daniel James Smith Co-authored-by: ✨ Audrey ✨ Co-authored-by: ✨ Audrey ✨ Co-authored-by: adudek-bw Co-authored-by: Oscar Hinton --- .github/CODEOWNERS | 1 + .../browser/src/background/main.background.ts | 13 +- .../service-container/service-container.ts | 13 +- apps/desktop/desktop_native/Cargo.lock | 106 +++++- apps/desktop/desktop_native/Cargo.toml | 2 +- .../bitwarden_chromium_importer/Cargo.toml | 38 ++ .../bitwarden_chromium_importer/README.md | 156 ++++++++ .../src/chromium.rs | 350 ++++++++++++++++++ .../bitwarden_chromium_importer/src/crypto.rs | 17 + .../bitwarden_chromium_importer/src/lib.rs | 1 + .../bitwarden_chromium_importer/src/linux.rs | 153 ++++++++ .../bitwarden_chromium_importer/src/macos.rs | 164 ++++++++ .../bitwarden_chromium_importer/src/util.rs | 43 +++ .../src/windows.rs | 205 ++++++++++ apps/desktop/desktop_native/build.js | 2 +- apps/desktop/desktop_native/napi/Cargo.toml | 1 + apps/desktop/desktop_native/napi/index.d.ts | 24 ++ apps/desktop/desktop_native/napi/src/lib.rs | 90 +++++ .../tools/import/chromium-importer.service.ts | 22 ++ .../import/import-desktop.component.html | 2 + .../tools/import/import-desktop.component.ts | 8 + apps/desktop/src/app/tools/preload.ts | 14 + apps/desktop/src/locales/en/messages.json | 6 + apps/desktop/src/main.ts | 3 + apps/desktop/src/preload.ts | 2 + libs/common/src/enums/feature-flag.enum.ts | 2 + libs/common/src/tools/providers.spec.ts | 178 +++++++++ libs/common/src/tools/providers.ts | 45 ++- .../chrome/import-chrome.component.html | 8 + .../chrome/import-chrome.component.ts | 167 +++++++++ libs/importer/src/components/chrome/index.ts | 1 + .../src/components/import.component.html | 70 ++-- .../src/components/import.component.ts | 105 +++--- .../src/components/importer-providers.ts | 91 +++++ .../src/importers/chrome-csv-importer.ts | 1 + libs/importer/src/metadata/availability.ts | 15 + libs/importer/src/metadata/data.ts | 27 ++ libs/importer/src/metadata/importers.ts | 27 ++ libs/importer/src/metadata/index.ts | 4 + libs/importer/src/metadata/types.ts | 20 + libs/importer/src/models/import-options.ts | 9 +- .../services/import.service.abstraction.ts | 7 + .../src/services/import.service.spec.ts | 191 +++++++++- libs/importer/src/services/import.service.ts | 43 ++- libs/importer/src/util.spec.ts | 60 +++ libs/importer/src/util.ts | 19 + 46 files changed, 2436 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/README.md create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs create mode 100644 apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs create mode 100644 apps/desktop/src/app/tools/import/chromium-importer.service.ts create mode 100644 apps/desktop/src/app/tools/preload.ts create mode 100644 libs/common/src/tools/providers.spec.ts create mode 100644 libs/importer/src/components/chrome/import-chrome.component.html create mode 100644 libs/importer/src/components/chrome/import-chrome.component.ts create mode 100644 libs/importer/src/components/chrome/index.ts create mode 100644 libs/importer/src/components/importer-providers.ts create mode 100644 libs/importer/src/metadata/availability.ts create mode 100644 libs/importer/src/metadata/data.ts create mode 100644 libs/importer/src/metadata/importers.ts create mode 100644 libs/importer/src/metadata/index.ts create mode 100644 libs/importer/src/metadata/types.ts create mode 100644 libs/importer/src/util.spec.ts create mode 100644 libs/importer/src/util.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d6028d106db..c190a77068d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -29,6 +29,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/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/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a14d43fd218..4d227330184 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -158,10 +158,13 @@ import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; +import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; +import { createSystemServiceProvider } from "@bitwarden/common/tools/providers"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; @@ -1056,8 +1059,16 @@ export default class MainBackground { this.encryptService, this.pinService, this.accountService, - this.sdkService, this.restrictedItemTypesService, + createSystemServiceProvider( + new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), + this.stateProvider, + this.policyService, + buildExtensionRegistry(), + this.logService, + this.platformUtilsService, + this.configService, + ), ); this.individualVaultExportService = new IndividualVaultExportService( diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index e3359c17b91..76eeb340550 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -113,10 +113,13 @@ import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; +import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; +import { createSystemServiceProvider } from "@bitwarden/common/tools/providers"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; @@ -816,8 +819,16 @@ export class ServiceContainer { this.encryptService, this.pinService, this.accountService, - this.sdkService, this.restrictedItemTypesService, + createSystemServiceProvider( + new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), + this.stateProvider, + this.policyService, + buildExtensionRegistry(), + this.logService, + this.platformUtilsService, + this.configService, + ), ); this.individualExportService = new IndividualVaultExportService( diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 70814c74106..bb7f7d9995b 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -447,6 +447,32 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "bitwarden_chromium_importer" +version = "0.0.0" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "cbc", + "hex", + "homedir", + "log", + "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" @@ -586,9 +612,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.38" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" dependencies = [ "clap_builder", "clap_derive", @@ -596,9 +622,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.38" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" dependencies = [ "anstream", "anstyle", @@ -608,9 +634,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" dependencies = [ "heck", "proc-macro2", @@ -922,6 +948,7 @@ dependencies = [ "anyhow", "autotype", "base64", + "bitwarden_chromium_importer", "desktop_core", "hex", "log", @@ -1165,6 +1192,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1424,6 +1463,18 @@ name = "hashbrown" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.3", +] [[package]] name = "heck" @@ -1689,6 +1740,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "link-cplusplus" version = "1.0.10" @@ -2642,6 +2704,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "russh-cryptovec" version = "0.7.3" @@ -2846,6 +2922,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -3178,6 +3265,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3496,6 +3584,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index b5819d3688e..84b835de35f 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "autotype", + "bitwarden_chromium_importer", "core", "macos_provider", "napi", @@ -21,7 +22,6 @@ anyhow = "=1.0.94" arboard = { version = "=3.6.0", default-features = false } ashpd = "=0.11.0" base64 = "=0.22.1" -bindgen = "=0.72.0" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" bytes = "=1.10.1" diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml b/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml new file mode 100644 index 00000000000..8512ed1b319 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "bitwarden_chromium_importer" +edition = { workspace = true } +license = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[dependencies] +aes = { workspace = true } +aes-gcm = "=0.10.3" +anyhow = { workspace = true } +async-trait = "=0.1.88" +base64 = { workspace = true } +cbc = { workspace = true, features = ["alloc"] } +hex = { workspace = true } +homedir = { workspace = true } +log = { workspace = true } +pbkdf2 = "=0.12.2" +rand = { workspace = true } +rusqlite = { version = "=0.35.0", features = ["bundled"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = "=0.10.6" + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +tokio = { workspace = true, features = ["full"] } +winapi = { version = "=0.3.9", features = ["dpapi", "memoryapi"] } +windows = { workspace = true, features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Services", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(target_os = "linux")'.dependencies] +oo7 = { workspace = true } + +[lints] +workspace = true + diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md b/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md new file mode 100644 index 00000000000..498dd3ac67d --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md @@ -0,0 +1,156 @@ +# Windows ABE Architecture + +## Overview + +The Windows Application Bound Encryption (ABE) consists of three main components that work together: + +- **client library** -- Library that is part of the desktop client application +- **admin.exe** -- Service launcher running as ADMINISTRATOR +- **service.exe** -- Background Windows service running as SYSTEM + +_(The names of the binaries will be changed for the released product.)_ + +## 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 +Edge (untested yet) that are using the ABE/v20 encryption scheme for some of the local profiles. + +The general idea of this encryption scheme is that Chrome generates a unique random encryption key, +then encrypts it at the user level with a fixed key. It then sends it to the Windows Data Protection +API at the user level, and then, using an installed service, encrypts it with the Windows Data +Protection API at the system level on top of that. This triply encrypted key is later stored in the +`Local State` file. + +The next paragraphs describe what is done at each level to decrypt the key. + +## 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 +by presenting the user with the UAC screen. See the `abe::decrypt_with_admin_and_service` invocation +in `windows.rs`. + +This function takes three arguments: + +1. Absolute path to `admin.exe` +2. Absolute path to `service.exe` +3. Base64 string of the ABE key extracted from the browser's local state + +It's not possible to install the service from the user-level executable. So first, we have to +elevate the privileges and run `admin.exe` as ADMINISTRATOR. This is done by calling `ShellExecute` +with the `runas` verb. Since it's not trivial to read the standard output from an application +launched in this way, a named pipe server is created at the user level, which waits for the response +from `admin.exe` after it has been launched. + +The name of the service executable and the data to be decrypted are passed via the command line to +`admin.exe` like this: + +```bat +admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." +``` + +**At this point, the user must permit the action to be performed on the UAC screen.** + +## 2. Admin executable + +This executable receives the full path of `service.exe` and the data to be decrypted. + +First, it installs the service to run as SYSTEM and waits for it to start running. The service +creates a named pipe server that the admin-level executable communicates with (see the `service.exe` +description further down). + +It sends the base64 string to the pipe server in a raw message and waits for the answer. The answer +could be a success or a failure. In case of success, it's a base64 string decrypted at the system +level. In case of failure, it's an error message prefixed with an `!`. In either case, the response +is sent to the named pipe server created by the user. The user responds with `ok` (ignored). + +After that, the executable stops and uninstalls the service and then exits. + +## 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 +service directly via a named pipe. Thus, this three-layered approach is necessary. + +Once the service is started, it waits for the incoming message via the named pipe. The expected +message is a base64 string to be decrypted. The data is decrypted via the Windows Data Protection +API `CryptUnprotectData` and sent back in response to this incoming message in base64 encoding. In +case of an error, the error message is sent back prefixed with an `!`. + +The service keeps running and servicing more requests if there are any, until it's stopped and +removed from the system. Even though we send only one request, the service is designed to handle as +many clients with as many messages as needed and could be installed on the system permanently if +necessary. + +## 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. + +In the next step, the string is decrypted at the user level with the same Windows Data Protection +API. + +And as the third step, it's decrypted with a hard-coded key found in the `elevation_service.exe` +from the Chrome installation. Based on the version of the encrypted string (encoded in the string +itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The details can be found in +`windows.rs`. + +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 + +The Windows ABE decryption process involves a three-tier architecture with named pipe communication: + +```mermaid +sequenceDiagram + participant Client as Client Library (User) + participant Admin as admin.exe (Administrator) + participant Service as service.exe (System) + + Client->>Client: Create named pipe server + Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user + + Client->>Admin: Launch with UAC elevation + Note over Client,Admin: --service-exe c:\path\to\service.exe + Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE... + + Client->>Client: Wait for response + + Admin->>Service: Install & start service + Note over Admin,Service: c:\path\to\service.exe + + Service->>Service: Create named pipe server + Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin + + Service->>Service: Wait for message + + Admin->>Service: Send encrypted data via admin-service pipe + Note over Admin,Service: QVBQQgEAAADQjJ3fARXRE... + + Admin->>Admin: Wait for response + + Service->>Service: Decrypt with system-level DPAPI + + Service->>Admin: Return decrypted data via admin-service pipe + Note over Service,Admin: EjRWeXN0ZW0gU2VydmljZQ... + + Admin->>Client: Send result via named user-admin pipe + Note over Client,Admin: EjRWeXN0ZW0gU2VydmljZQ... + + Client->>Admin: Send ACK to admin + Note over Client,Admin: ok + + Admin->>Service: Stop & uninstall service + Service-->>Admin: Exit + + Admin-->>Client: Exit + + Client->>Client: Decrypt with user-level DPAPI + + Client->>Client: Decrypt with hardcoded key + Note over Client: AES-256-GCM or ChaCha20Poly1305 + + Client->>Client: Done +``` diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs new file mode 100644 index 00000000000..8179a10213d --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs @@ -0,0 +1,350 @@ +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +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")] +mod platform; + +// +// Public API +// + +#[derive(Debug)] +pub struct ProfileInfo { + pub name: String, + pub folder: String, + + #[allow(dead_code)] + pub account_name: Option, + + #[allow(dead_code)] + pub account_email: Option, +} + +#[derive(Debug)] +pub struct Login { + pub url: String, + pub username: String, + pub password: String, + pub note: String, +} + +#[derive(Debug)] +pub struct LoginImportFailure { + pub url: String, + pub username: String, + pub error: String, +} + +#[derive(Debug)] +pub enum LoginImportResult { + Success(Login), + Failure(LoginImportFailure), +} + +// TODO: Make thus async +pub fn get_installed_browsers() -> Result> { + let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len()); + + for (browser, config) in SUPPORTED_BROWSER_MAP.iter() { + let data_dir = get_browser_data_dir(config)?; + if data_dir.exists() { + browsers.push((*browser).to_string()); + } + } + + Ok(browsers) +} + +// TODO: Make thus async +pub fn get_available_profiles(browser_name: &String) -> Result> { + let (_, local_state) = load_local_state_for_browser(browser_name)?; + Ok(get_profile_info(&local_state)) +} + +pub async fn import_logins( + browser_name: &String, + profile_id: &String, +) -> Result> { + let (data_dir, local_state) = load_local_state_for_browser(browser_name)?; + + let mut crypto_service = platform::get_crypto_service(browser_name, &local_state) + .map_err(|e| anyhow!("Failed to get crypto service: {}", e))?; + + let local_logins = get_logins(&data_dir, profile_id, "Login Data") + .map_err(|e| anyhow!("Failed to query logins: {}", e))?; + + // This is not available in all browsers, but there's no harm in trying. If the file doesn't exist we just get an empty vector. + let account_logins = get_logins(&data_dir, profile_id, "Login Data For Account") + .map_err(|e| anyhow!("Failed to query logins: {}", e))?; + + // TODO: Do we need a better merge strategy? Maybe ignore duplicates at least? + // TODO: Should we also ignore an error from one of the two imports? If one is successful and the other fails, + // should we still return the successful ones? At the moment it doesn't fail for a missing file, only when + // something goes really wrong. + let all_logins = local_logins + .into_iter() + .chain(account_logins.into_iter()) + .collect::>(); + + let results = decrypt_logins(all_logins, &mut crypto_service).await; + + Ok(results) +} + +// +// Private +// + +#[derive(Debug)] +struct BrowserConfig { + name: &'static str, + data_dir: &'static str, +} + +static SUPPORTED_BROWSER_MAP: LazyLock< + std::collections::HashMap<&'static str, &'static BrowserConfig>, +> = LazyLock::new(|| { + platform::SUPPORTED_BROWSERS + .iter() + .map(|b| (b.name, b)) + .collect::>() +}); + +fn get_browser_data_dir(config: &BrowserConfig) -> Result { + let dir = my_home() + .map_err(|_| anyhow!("Home directory not found"))? + .ok_or_else(|| anyhow!("Home directory not found"))? + .join(config.data_dir); + Ok(dir) +} + +// +// CryptoService +// + +#[async_trait] +trait CryptoService: Send { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result; +} + +#[derive(serde::Deserialize, Clone)] +struct LocalState { + profile: AllProfiles, + #[allow(dead_code)] + os_crypt: Option, +} + +#[derive(serde::Deserialize, Clone)] +struct AllProfiles { + info_cache: std::collections::HashMap, +} + +#[derive(serde::Deserialize, Clone)] +struct OneProfile { + name: String, + gaia_name: Option, + user_name: Option, +} + +#[derive(serde::Deserialize, Clone)] +struct OsCrypt { + #[allow(dead_code)] + encrypted_key: Option, + #[allow(dead_code)] + app_bound_encrypted_key: Option, +} + +fn load_local_state_for_browser(browser_name: &String) -> Result<(PathBuf, LocalState)> { + let config = SUPPORTED_BROWSER_MAP + .get(browser_name.as_str()) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + + let data_dir = get_browser_data_dir(config)?; + if !data_dir.exists() { + return Err(anyhow!( + "Browser user data directory '{}' not found", + data_dir.display() + )); + } + + let local_state = load_local_state(&data_dir)?; + + Ok((data_dir, local_state)) +} + +fn load_local_state(browser_dir: &Path) -> Result { + let local_state = std::fs::read_to_string(browser_dir.join("Local State")) + .map_err(|e| anyhow!("Failed to read local state file: {}", e))?; + + serde_json::from_str(&local_state) + .map_err(|e| anyhow!("Failed to parse local state JSON: {}", e)) +} + +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 { + name: info.name.clone(), + folder: name.clone(), + account_name: info.gaia_name.clone(), + account_email: info.user_name.clone(), + }); + } + profile_infos +} + +struct EncryptedLogin { + url: String, + username: String, + encrypted_password: Vec, + encrypted_note: Vec, +} + +fn get_logins( + browser_dir: &Path, + profile_id: &String, + filename: &str, +) -> Result> { + let login_data_path = browser_dir.join(profile_id).join(filename); + + // Sometimes database files are not present, so nothing to import + if !login_data_path.exists() { + return Ok(vec![]); + } + + // When the browser with the current profile is open the database file is locked. + // To access it we need to copy it to a temporary location. + let tmp_db_path = std::env::temp_dir().join(format!( + "tmp-logins-{}-{}.db", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| anyhow!("Failed to retrieve system time: {}", e))? + .as_millis(), + rand::random::() + )); + + std::fs::copy(&login_data_path, &tmp_db_path).map_err(|e| { + anyhow!( + "Failed to copy the password database file at {:?}: {}", + login_data_path, + e + ) + })?; + + let tmp_db_path = tmp_db_path + .to_str() + .ok_or_else(|| anyhow!("Failed to locate database."))?; + let maybe_logins = + query_logins(tmp_db_path).map_err(|e| anyhow!("Failed to query logins: {}", e))?; + + // Clean up temp file + let _ = std::fs::remove_file(tmp_db_path); + + Ok(maybe_logins) +} + +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 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")?; + if !have_logins || !have_password_notes { + return Ok(vec![]); + } + + let mut stmt = conn.prepare( + r#" + SELECT + l.origin_url AS url, + l.username_value AS username, + hex(l.password_value) AS encryptedPasswordHex, + hex(pn.value) AS encryptedNoteHex + FROM + logins l + LEFT JOIN + password_notes pn ON l.id = pn.parent_id + WHERE + l.blacklisted_by_user = 0 + "#, + )?; + + let logins_iter = stmt.query_map((), |row| { + let url: String = row.get("url")?; + let username: String = row.get("username")?; + let encrypted_password_hex: String = row.get("encryptedPasswordHex")?; + let encrypted_note_hex: String = row.get("encryptedNoteHex")?; + Ok(EncryptedLogin { + url, + username, + encrypted_password: hex_to_bytes(&encrypted_password_hex), + encrypted_note: hex_to_bytes(&encrypted_note_hex), + }) + })?; + + let mut logins = Vec::new(); + for login in logins_iter { + logins.push(login?); + } + + Ok(logins) +} + +async fn decrypt_logins( + encrypted_logins: Vec, + crypto_service: &mut Box, +) -> Vec { + let mut results = Vec::with_capacity(encrypted_logins.len()); + for encrypted_login in encrypted_logins { + let result = decrypt_login(encrypted_login, crypto_service).await; + results.push(result); + } + results +} + +async fn decrypt_login( + encrypted_login: EncryptedLogin, + crypto_service: &mut Box, +) -> LoginImportResult { + let maybe_password = crypto_service + .decrypt_to_string(&encrypted_login.encrypted_password) + .await; + match maybe_password { + Ok(password) => { + let note = crypto_service + .decrypt_to_string(&encrypted_login.encrypted_note) + .await + .unwrap_or_default(); + + LoginImportResult::Success(Login { + url: encrypted_login.url, + username: encrypted_login.username, + password, + note, + }) + } + Err(e) => LoginImportResult::Failure(LoginImportFailure { + url: encrypted_login.url, + username: encrypted_login.username, + error: e.to_string(), + }), + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs new file mode 100644 index 00000000000..a2b87d758a4 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs @@ -0,0 +1,17 @@ +//! Cryptographic primitives used in the SDK + +use anyhow::{Result, anyhow}; + +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(); + return cbc::Decryptor::::new(&key, iv) + .decrypt_padded_mut::(&mut data) + .map_err(|_| anyhow!("Failed to decrypt data"))?; + + Ok(data) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs new file mode 100644 index 00000000000..b0a399d6321 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs @@ -0,0 +1 @@ +pub mod chromium; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs new file mode 100644 index 00000000000..0ead034a4b2 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use oo7::XDG_SCHEMA_ATTRIBUTE; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +mod util; + +// +// Public API +// + +// 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] = [ + BrowserConfig { + name: "Chrome", + data_dir: ".config/google-chrome", + }, + BrowserConfig { + name: "Chromium", + data_dir: "snap/chromium/common/chromium", + }, + BrowserConfig { + name: "Brave", + data_dir: "snap/brave/current/.config/BraveSoftware/Brave-Browser", + }, + BrowserConfig { + name: "Opera", + data_dir: "snap/opera/current/.config/opera", + }, +]; + +pub fn get_crypto_service( + browser_name: &String, + _local_state: &LocalState, +) -> Result> { + let config = KEYRING_CONFIG + .iter() + .find(|b| b.browser == browser_name) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + let service = LinuxCryptoService::new(config); + Ok(Box::new(service)) +} + +// +// Private +// + +#[derive(Debug)] +struct KeyringConfig { + browser: &'static str, + application_id: &'static str, +} + +const KEYRING_CONFIG: [KeyringConfig; SUPPORTED_BROWSERS.len()] = [ + KeyringConfig { + browser: "Chrome", + application_id: "chrome", + }, + KeyringConfig { + browser: "Chromium", + application_id: "chromium", + }, + KeyringConfig { + browser: "Brave", + application_id: "brave", + }, + KeyringConfig { + browser: "Opera", + application_id: "opera", + }, +]; + +const IV: [u8; 16] = [0x20; 16]; +const V10_KEY: [u8; 16] = [ + 0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78, +]; + +struct LinuxCryptoService { + config: &'static KeyringConfig, + v11_key: Option>, +} + +impl LinuxCryptoService { + fn new(config: &'static KeyringConfig) -> Self { + Self { + config, + v11_key: None, + } + } + + fn decrypt_v10(&self, encrypted: &[u8]) -> Result { + decrypt(&V10_KEY, encrypted) + } + + async fn decrypt_v11(&mut self, encrypted: &[u8]) -> Result { + if self.v11_key.is_none() { + let master_password = get_master_password(self.config.application_id).await?; + self.v11_key = Some(util::derive_saltysalt(&master_password, 1)?); + } + + let key = self + .v11_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + decrypt(key, encrypted) + } +} + +#[async_trait] +impl CryptoService for LinuxCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + let (version, password) = + util::split_encrypted_string_and_validate(encrypted, &["v10", "v11"])?; + + let result = match version { + "v10" => self.decrypt_v10(password), + "v11" => self.decrypt_v11(password).await, + _ => Err(anyhow!("Logic error: unreachable code")), + }?; + + Ok(result) + } +} + +fn decrypt(key: &[u8], encrypted: &[u8]) -> Result { + let plaintext = util::decrypt_aes_128_cbc(key, &IV, encrypted)?; + String::from_utf8(plaintext).map_err(|e| anyhow!("UTF-8 error: {:?}", e)) +} + +async fn get_master_password(application_tag: &str) -> Result> { + let keyring = oo7::Keyring::new().await?; + keyring.unlock().await?; + + let attributes = HashMap::from([ + ( + XDG_SCHEMA_ATTRIBUTE, + "chrome_libsecret_os_crypt_password_v2", + ), + ("application", application_tag), + ]); + + let results = keyring.search_items(&attributes).await?; + match results.first() { + Some(r) => { + let secret = r.secret().await?; + Ok(secret.to_vec()) + } + None => Err(anyhow!("The master password not found in the keyring")), + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs new file mode 100644 index 00000000000..d9aeff68f2b --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs @@ -0,0 +1,164 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use security_framework::passwords::get_generic_password; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +mod util; + +// +// Public API +// + +pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ + BrowserConfig { + name: "Chrome", + data_dir: "Library/Application Support/Google/Chrome", + }, + BrowserConfig { + name: "Chromium", + data_dir: "Library/Application Support/Chromium", + }, + BrowserConfig { + name: "Microsoft Edge", + data_dir: "Library/Application Support/Microsoft Edge", + }, + BrowserConfig { + name: "Brave", + data_dir: "Library/Application Support/BraveSoftware/Brave-Browser", + }, + BrowserConfig { + name: "Arc", + data_dir: "Library/Application Support/Arc/User Data", + }, + BrowserConfig { + name: "Opera", + data_dir: "Library/Application Support/com.operasoftware.Opera", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "Library/Application Support/Vivaldi", + }, +]; + +pub fn get_crypto_service( + browser_name: &String, + _local_state: &LocalState, +) -> Result> { + let config = KEYCHAIN_CONFIG + .iter() + .find(|b| b.browser == browser_name) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + + Ok(Box::new(MacCryptoService::new(config))) +} + +// +// Private +// + +#[derive(Debug)] +struct KeychainConfig { + browser: &'static str, + service: &'static str, + account: &'static str, +} + +const KEYCHAIN_CONFIG: [KeychainConfig; SUPPORTED_BROWSERS.len()] = [ + KeychainConfig { + browser: "Chrome", + service: "Chrome Safe Storage", + account: "Chrome", + }, + KeychainConfig { + browser: "Chromium", + service: "Chromium Safe Storage", + account: "Chromium", + }, + KeychainConfig { + browser: "Microsoft Edge", + service: "Microsoft Edge Safe Storage", + account: "Microsoft Edge", + }, + KeychainConfig { + browser: "Brave", + service: "Brave Safe Storage", + account: "Brave", + }, + KeychainConfig { + browser: "Arc", + service: "Arc Safe Storage", + account: "Arc", + }, + KeychainConfig { + browser: "Opera", + service: "Opera Safe Storage", + account: "Opera", + }, + KeychainConfig { + browser: "Vivaldi", + service: "Vivaldi Safe Storage", + account: "Vivaldi", + }, +]; + +const IV: [u8; 16] = [0x20; 16]; // 16 bytes of 0x20 (space character) + +// +// CryptoService +// + +struct MacCryptoService { + config: &'static KeychainConfig, + master_key: Option>, +} + +impl MacCryptoService { + fn new(config: &'static KeychainConfig) -> Self { + Self { + config, + master_key: None, + } + } +} + +#[async_trait] +impl CryptoService for MacCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On macOS only v10 is supported + let (_, no_prefix) = util::split_encrypted_string_and_validate(encrypted, &["v10"])?; + + // This might bring up the admin password prompt + if self.master_key.is_none() { + self.master_key = Some(get_master_key(self.config.service, self.config.account)?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let plaintext = util::decrypt_aes_128_cbc(key, &IV, no_prefix) + .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; + let plaintext = + String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +fn get_master_key(service: &str, account: &str) -> Result> { + let master_password = get_master_password(service, account)?; + let key = util::derive_saltysalt(&master_password, 1003)?; + Ok(key) +} + +fn get_master_password(service: &str, account: &str) -> Result> { + let password = get_generic_password(service, account) + .map_err(|e| anyhow!("Failed to get password from keychain: {}", e))?; + + Ok(password) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs new file mode 100644 index 00000000000..5edd4a2610f --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs @@ -0,0 +1,43 @@ +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])> { + if encrypted.len() < 3 { + return Err(anyhow!( + "Corrupted entry: invalid encrypted string length, expected at least 3 bytes, got {}", + encrypted.len() + )); + } + + let (version, password) = encrypted.split_at(3); + Ok((std::str::from_utf8(version)?, password)) +} + +pub fn split_encrypted_string_and_validate<'a>( + encrypted: &'a [u8], + supported_versions: &[&str], +) -> Result<(&'a str, &'a [u8])> { + let (version, password) = split_encrypted_string(encrypted)?; + if !supported_versions.contains(&version) { + return Err(anyhow!("Unsupported encryption version: {}", version)); + } + + 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 = decryptor + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; + Ok(plaintext) +} + +pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { + let mut key = vec![0u8; 16]; + pbkdf2::>(password, b"saltysalt", iterations, &mut key) + .map_err(|e| anyhow!("Failed to derive master key: {}", e))?; + Ok(key) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs new file mode 100644 index 00000000000..e7dffe93dba --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs @@ -0,0 +1,205 @@ +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use winapi::shared::minwindef::{BOOL, BYTE, DWORD}; +use winapi::um::{dpapi::CryptUnprotectData, wincrypt::DATA_BLOB}; +use windows::Win32::Foundation::{LocalFree, HLOCAL}; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +#[allow(dead_code)] +mod util; + +// +// Public API +// + +pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ + BrowserConfig { + name: "Chrome", + data_dir: "AppData/Local/Google/Chrome/User Data", + }, + BrowserConfig { + name: "Chromium", + data_dir: "AppData/Local/Chromium/User Data", + }, + BrowserConfig { + name: "Microsoft Edge", + data_dir: "AppData/Local/Microsoft/Edge/User Data", + }, + BrowserConfig { + name: "Brave", + data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", + }, + BrowserConfig { + name: "Opera", + data_dir: "AppData/Roaming/Opera Software/Opera Stable", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "AppData/Local/Vivaldi/User Data", + }, +]; + +pub fn get_crypto_service( + _browser_name: &str, + local_state: &LocalState, +) -> Result> { + Ok(Box::new(WindowsCryptoService::new(local_state))) +} + +// +// CryptoService +// +struct WindowsCryptoService { + master_key: Option>, + encrypted_key: Option, +} + +impl WindowsCryptoService { + pub(crate) fn new(local_state: &LocalState) -> Self { + Self { + master_key: None, + encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.encrypted_key.clone()), + } + } +} + +#[async_trait] +impl CryptoService for WindowsCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On Windows only v10 and v20 are supported at the moment + let (version, no_prefix) = + util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; + + // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag] + const IV_SIZE: usize = 12; + const TAG_SIZE: usize = 16; + const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; + + if no_prefix.len() < MIN_LENGTH { + return Err(anyhow!( + "Corrupted entry: expected at least {} bytes, got {} bytes", + MIN_LENGTH, + no_prefix.len() + )); + } + + // Allow empty passwords + if no_prefix.len() == MIN_LENGTH { + return Ok(String::new()); + } + + if self.master_key.is_none() { + self.master_key = Some(self.get_master_key(version)?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); + + let decrypted_bytes = cipher + .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) + .map_err(|e| anyhow!("Decryption failed: {}", e))?; + + let plaintext = String::from_utf8(decrypted_bytes) + .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +impl WindowsCryptoService { + fn get_master_key(&mut self, version: &str) -> Result> { + match version { + "v10" => self.get_master_key_v10(), + _ => Err(anyhow!("Unsupported version: {}", version)), + } + } + + fn get_master_key_v10(&mut self) -> Result> { + if self.encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let key = self + .encrypted_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key_bytes = BASE64_STANDARD + .decode(key) + .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; + + if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { + return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); + } + + let key = unprotect_data_win(&key_bytes[5..]) + .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; + + Ok(key) + } +} + +fn unprotect_data_win(data: &[u8]) -> Result> { + if data.is_empty() { + return Ok(Vec::new()); + } + + let mut data_in = DATA_BLOB { + cbData: data.len() as DWORD, + pbData: data.as_ptr() as *mut BYTE, + }; + + let mut data_out = DATA_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + + let result: BOOL = unsafe { + // BOOL from winapi (i32) + CryptUnprotectData( + &mut data_in, + std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16) + std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB + std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void) + std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT + 0, // dwFlags: DWORD + &mut data_out, + ) + }; + + if result == 0 { + return Err(anyhow!("CryptUnprotectData failed")); + } + + if data_out.pbData.is_null() || data_out.cbData == 0 { + return Ok(Vec::new()); + } + + let output_slice = + unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) }; + + unsafe { + if !data_out.pbData.is_null() { + LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void))); + } + } + + Ok(output_slice.to_vec()) +} diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 2edd0e89616..125cb1bb567 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -35,7 +35,7 @@ function buildProxyBin(target, release = true) { const targetArg = target ? `--target ${target}` : ""; const releaseArg = release ? "--release" : ""; child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")}); - + if (target) { // Copy the resulting binary to the dist folder const targetFolder = release ? "release" : "debug"; diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 8f2a5cb78a9..9e8404ea8dc 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -17,6 +17,7 @@ manual_test = [] anyhow = { workspace = true } autotype = { path = "../autotype" } base64 = { workspace = true } +bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" } desktop_core = { path = "../core" } hex = { workspace = true } log = { workspace = true } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 5ea75bd6120..a920f0c00aa 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -208,6 +208,30 @@ export declare namespace logging { } export function initNapiLog(jsLogFn: (err: Error | null, arg0: LogLevel, arg1: string) => any): void } +export declare namespace chromium_importer { + export interface ProfileInfo { + id: string + name: string + } + export interface Login { + url: string + username: string + password: string + note: string + } + export interface LoginImportFailure { + url: string + username: string + error: string + } + export interface LoginImportResult { + login?: Login + failure?: LoginImportFailure + } + export function getInstalledBrowsers(): Promise> + export function getAvailableProfiles(browser: string): Promise> + export function importLogins(browser: string, profileId: string): Promise> +} export declare namespace autotype { export function getForegroundWindowTitle(): string export function typeInput(input: Array): void diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 24d41bc3831..1f99c1c3ed2 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -879,6 +879,96 @@ pub mod logging { } } +#[napi] +pub mod chromium_importer { + use bitwarden_chromium_importer::chromium::LoginImportResult as _LoginImportResult; + use bitwarden_chromium_importer::chromium::ProfileInfo as _ProfileInfo; + + #[napi(object)] + pub struct ProfileInfo { + pub id: String, + pub name: String, + } + + #[napi(object)] + pub struct Login { + pub url: String, + pub username: String, + pub password: String, + pub note: String, + } + + #[napi(object)] + pub struct LoginImportFailure { + pub url: String, + pub username: String, + pub error: String, + } + + #[napi(object)] + pub struct LoginImportResult { + pub login: Option, + pub failure: Option, + } + + impl From<_LoginImportResult> for LoginImportResult { + fn from(l: _LoginImportResult) -> Self { + match l { + _LoginImportResult::Success(l) => LoginImportResult { + login: Some(Login { + url: l.url, + username: l.username, + password: l.password, + note: l.note, + }), + failure: None, + }, + _LoginImportResult::Failure(l) => LoginImportResult { + login: None, + failure: Some(LoginImportFailure { + url: l.url, + username: l.username, + error: l.error, + }), + }, + } + } + } + + impl From<_ProfileInfo> for ProfileInfo { + fn from(p: _ProfileInfo) -> Self { + ProfileInfo { + id: p.folder, + name: p.name, + } + } + } + + #[napi] + pub fn get_installed_browsers() -> napi::Result> { + bitwarden_chromium_importer::chromium::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) + .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn import_logins( + browser: String, + profile_id: String, + ) -> napi::Result> { + bitwarden_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())) + } +} + #[napi] pub mod autotype { #[napi] diff --git a/apps/desktop/src/app/tools/import/chromium-importer.service.ts b/apps/desktop/src/app/tools/import/chromium-importer.service.ts new file mode 100644 index 00000000000..56f31c359db --- /dev/null +++ b/apps/desktop/src/app/tools/import/chromium-importer.service.ts @@ -0,0 +1,22 @@ +import { ipcMain } from "electron"; + +import { chromium_importer } from "@bitwarden/desktop-napi"; + +export class ChromiumImporterService { + constructor() { + ipcMain.handle("chromium_importer.getInstalledBrowsers", async (event) => { + return await chromium_importer.getInstalledBrowsers(); + }); + + ipcMain.handle("chromium_importer.getAvailableProfiles", async (event, browser: string) => { + return await chromium_importer.getAvailableProfiles(browser); + }); + + ipcMain.handle( + "chromium_importer.importLogins", + async (event, browser: string, profileId: string) => { + return await chromium_importer.importLogins(browser, profileId); + }, + ); + } +} diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.html b/apps/desktop/src/app/tools/import/import-desktop.component.html index 2bb715b5a46..796d61e1b69 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.html +++ b/apps/desktop/src/app/tools/import/import-desktop.component.html @@ -5,6 +5,8 @@ (formLoading)="this.loading = $event" (formDisabled)="this.disabled = $event" (onSuccessfulImport)="this.onSuccessfulImport($event)" + [onImportFromBrowser]="this.onImportFromBrowser" + [onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser" > diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.ts b/apps/desktop/src/app/tools/import/import-desktop.component.ts index c1639c6d3ec..f096471f770 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.ts +++ b/apps/desktop/src/app/tools/import/import-desktop.component.ts @@ -28,4 +28,12 @@ export class ImportDesktopComponent { protected async onSuccessfulImport(organizationId: string): Promise { this.dialogRef.close(); } + + protected onLoadProfilesFromBrowser(browser: string): Promise { + return ipc.tools.chromiumImporter.getAvailableProfiles(browser); + } + + protected onImportFromBrowser(browser: string, profile: string): Promise { + return ipc.tools.chromiumImporter.importLogins(browser, profile); + } } diff --git a/apps/desktop/src/app/tools/preload.ts b/apps/desktop/src/app/tools/preload.ts new file mode 100644 index 00000000000..574c27ac9fd --- /dev/null +++ b/apps/desktop/src/app/tools/preload.ts @@ -0,0 +1,14 @@ +import { ipcRenderer } from "electron"; + +const chromiumImporter = { + getInstalledBrowsers: (): Promise => + ipcRenderer.invoke("chromium_importer.getInstalledBrowsers"), + getAvailableProfiles: (browser: string): Promise => + ipcRenderer.invoke("chromium_importer.getAvailableProfiles", browser), + importLogins: (browser: string, profileId: string): Promise => + ipcRenderer.invoke("chromium_importer.importLogins", browser, profileId), +}; + +export default { + chromiumImporter, +}; diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index b2005a37472..62da68c8124 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3588,6 +3588,12 @@ "awaitingSSODesc": { "message": "Please continue to log in using your company credentials." }, + "importDirectlyFromBrowser": { + "message": "Import directly from browser" + }, + "browserProfile": { + "message": "Browser Profile" + }, "seeDetailedInstructions": { "message": "See detailed instructions on our help site at", "description": "This is followed a by a hyperlink to the help website." diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6d5f536fadb..d5484213a90 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -33,6 +33,7 @@ import { } from "@bitwarden/state-internal"; import { SerializedMemoryStorageService, StorageServiceProvider } from "@bitwarden/storage-core"; +import { ChromiumImporterService } from "./app/tools/import/chromium-importer.service"; import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service"; import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service"; import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service"; @@ -300,6 +301,8 @@ export class Main { this.ssoUrlService, ); + new ChromiumImporterService(); + this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain); void this.nativeAutofillMain.init(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0fb2db37518..90e8e64138f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import { contextBridge } from "electron"; +import tools from "./app/tools/preload"; import auth from "./auth/preload"; import autofill from "./autofill/preload"; import keyManagement from "./key-management/preload"; @@ -21,6 +22,7 @@ export const ipc = { autofill, platform, keyManagement, + tools, }; contextBridge.exposeInMainWorld("ipc", ipc); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index d5f1f5f4fd7..30644b95627 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -37,6 +37,7 @@ export enum FeatureFlag { /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", + UseChromiumImporter = "pm-23982-chromium-importer", /* DIRT */ EventBasedOrganizationIntegrations = "event-based-organization-integrations", @@ -79,6 +80,7 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, + [FeatureFlag.UseChromiumImporter]: FALSE, /* DIRT */ [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, diff --git a/libs/common/src/tools/providers.spec.ts b/libs/common/src/tools/providers.spec.ts new file mode 100644 index 00000000000..5953e5ebab2 --- /dev/null +++ b/libs/common/src/tools/providers.spec.ts @@ -0,0 +1,178 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; +import { ConfigService } from "../platform/abstractions/config/config.service"; +import { LogService } from "../platform/abstractions/log.service"; +import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { StateProvider } from "../platform/state"; + +import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "./extension/extension-registry.abstraction"; +import { ExtensionService } from "./extension/extension.service"; +import { disabledSemanticLoggerProvider } from "./log"; +import { createSystemServiceProvider } from "./providers"; + +describe("SystemServiceProvider", () => { + let mockEncryptor: LegacyEncryptorProvider; + let mockState: StateProvider; + let mockPolicy: PolicyService; + let mockRegistry: ExtensionRegistry; + let mockLogger: LogService; + let mockEnvironment: MockProxy; + let mockConfigService: ConfigService; + + beforeEach(() => { + jest.resetAllMocks(); + + mockEncryptor = mock(); + mockState = mock(); + mockPolicy = mock(); + mockRegistry = mock(); + mockLogger = mock(); + mockEnvironment = mock(); + mockConfigService = mock(); + }); + + describe("createSystemServiceProvider", () => { + it("returns object with all required services when called with valid parameters", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result).toHaveProperty("policy", mockPolicy); + expect(result).toHaveProperty("extension"); + expect(result).toHaveProperty("log"); + expect(result).toHaveProperty("configService", mockConfigService); + expect(result).toHaveProperty("environment", mockEnvironment); + expect(result.extension).toBeInstanceOf(ExtensionService); + }); + + it("creates ExtensionService with correct dependencies when called", () => { + mockEnvironment.isDev.mockReturnValue(true); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.extension).toBeInstanceOf(ExtensionService); + }); + + describe("given development environment", () => { + it("uses enableLogForTypes when environment.isDev() returns true", () => { + mockEnvironment.isDev.mockReturnValue(true); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1); + expect(result.log).not.toBe(disabledSemanticLoggerProvider); + }); + }); + + describe("given production environment", () => { + it("uses disabledSemanticLoggerProvider when environment.isDev() returns false", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1); + expect(result.log).toBe(disabledSemanticLoggerProvider); + }); + }); + + it("configures ExtensionService with encryptor, state, log provider, and now function when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + const dateSpy = jest.spyOn(Date, "now"); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.extension).toBeInstanceOf(ExtensionService); + expect(dateSpy).not.toHaveBeenCalled(); + }); + + it("passes through policy service correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.policy).toBe(mockPolicy); + }); + + it("passes through configService correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.configService).toBe(mockConfigService); + }); + + it("passes through environment service correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.environment).toBe(mockEnvironment); + }); + }); +}); diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts index 181df94be83..ac42c556042 100644 --- a/libs/common/src/tools/providers.ts +++ b/libs/common/src/tools/providers.ts @@ -1,10 +1,15 @@ +import { LogService } from "@bitwarden/logging"; import { BitwardenClient } from "@bitwarden/sdk-internal"; +import { StateProvider } from "@bitwarden/state"; import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; import { ConfigService } from "../platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "./extension/extension-registry.abstraction"; import { ExtensionService } from "./extension/extension.service"; -import { LogProvider } from "./log"; +import { disabledSemanticLoggerProvider, enableLogForTypes, LogProvider } from "./log"; /** Provides access to commonly-used cross-cutting services. */ export type SystemServiceProvider = { @@ -20,6 +25,42 @@ export type SystemServiceProvider = { /** Config Service to determine flag features */ readonly configService: ConfigService; + /** Platform Service to inspect runtime environment */ + readonly environment: PlatformUtilsService; + /** SDK Service */ - readonly sdk: BitwardenClient; + readonly sdk?: BitwardenClient; }; + +/** Constructs a system service provider. */ +export function createSystemServiceProvider( + encryptor: LegacyEncryptorProvider, + state: StateProvider, + policy: PolicyService, + registry: ExtensionRegistry, + logger: LogService, + environment: PlatformUtilsService, + configService: ConfigService, +): SystemServiceProvider { + let log: LogProvider; + if (environment.isDev()) { + log = enableLogForTypes(logger, []); + } else { + log = disabledSemanticLoggerProvider; + } + + const extension = new ExtensionService(registry, { + encryptor, + state, + log, + now: Date.now, + }); + + return { + policy, + extension, + log, + configService, + environment, + }; +} diff --git a/libs/importer/src/components/chrome/import-chrome.component.html b/libs/importer/src/components/chrome/import-chrome.component.html new file mode 100644 index 00000000000..284f8cec857 --- /dev/null +++ b/libs/importer/src/components/chrome/import-chrome.component.html @@ -0,0 +1,8 @@ +
+ + {{ "browserProfile" | i18n }} + + + + +
diff --git a/libs/importer/src/components/chrome/import-chrome.component.ts b/libs/importer/src/components/chrome/import-chrome.component.ts new file mode 100644 index 00000000000..035487fea6f --- /dev/null +++ b/libs/importer/src/components/chrome/import-chrome.component.ts @@ -0,0 +1,167 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { + AsyncValidatorFn, + ControlContainer, + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import * as papa from "papaparse"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + CalloutModule, + CheckboxModule, + FormFieldModule, + IconButtonModule, + SelectModule, + TypographyModule, +} from "@bitwarden/components"; + +import { ImportType } from "../../models"; + +@Component({ + selector: "import-chrome", + templateUrl: "import-chrome.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + CalloutModule, + TypographyModule, + FormFieldModule, + ReactiveFormsModule, + IconButtonModule, + CheckboxModule, + SelectModule, + ], +}) +export class ImportChromeComponent implements OnInit, OnDestroy { + private _parentFormGroup: FormGroup; + protected formGroup = this.formBuilder.group({ + profile: [ + "", + { + nonNullable: true, + validators: [Validators.required], + asyncValidators: [this.validateAndEmitData()], + updateOn: "submit", + }, + ], + }); + + profileList: { id: string; name: string }[] = []; + + @Input() + format: ImportType; + + @Input() + onLoadProfilesFromBrowser: (browser: string) => Promise; + + @Input() + onImportFromBrowser: (browser: string, profile: string) => Promise; + + @Output() csvDataLoaded = new EventEmitter(); + + constructor( + private formBuilder: FormBuilder, + private controlContainer: ControlContainer, + private logService: LogService, + private i18nService: I18nService, + ) {} + + async ngOnInit(): Promise { + this._parentFormGroup = this.controlContainer.control as FormGroup; + this._parentFormGroup.addControl("chromeOptions", this.formGroup); + this.profileList = await this.onLoadProfilesFromBrowser(this.getBrowserName()); + } + + ngOnDestroy(): void { + this._parentFormGroup.removeControl("chromeOptions"); + } + + /** + * Attempts to login to the provided Chrome email and retrieve account contents. + * Will return a validation error if unable to login or fetch. + * Emits account contents to `csvDataLoaded` + */ + validateAndEmitData(): AsyncValidatorFn { + return async () => { + try { + const logins = await this.onImportFromBrowser( + this.getBrowserName(), + this.formGroup.controls.profile.value, + ); + if (logins.length === 0) { + throw "nothing to import"; + } + const chromeLogins: ChromeLogin[] = []; + for (const l of logins) { + if (l.login != null) { + chromeLogins.push(new ChromeLogin(l.login)); + } + } + const csvData = papa.unparse(chromeLogins); + this.csvDataLoaded.emit(csvData); + return null; + } catch (error) { + this.logService.error(`Chromium importer error: ${error}`); + return { + errors: { + message: this.i18nService.t(this.getValidationErrorI18nKey(error)), + }, + }; + } + }; + } + + private getValidationErrorI18nKey(error: any): string { + const message = typeof error === "string" ? error : error?.message; + switch (message) { + default: + return "errorOccurred"; + } + } + + private getBrowserName(): string { + if (this.format === "edgecsv") { + return "Microsoft Edge"; + } else if (this.format === "operacsv") { + return "Opera"; + } else if (this.format === "bravecsv") { + return "Brave"; + } else if (this.format === "vivaldicsv") { + return "Vivaldi"; + } + return "Chrome"; + } +} + +class ChromeLogin { + name: string; + url: string; + username: string; + password: string; + note: string; + + constructor(login: any) { + const url = Utils.getUrl(login?.url); + if (url != null) { + this.name = new URL(url).hostname; + } + if (this.name == null) { + this.name = login.url; + } + this.url = login.url; + this.username = login.username; + this.password = login.password; + this.note = login.note; + } +} diff --git a/libs/importer/src/components/chrome/index.ts b/libs/importer/src/components/chrome/index.ts new file mode 100644 index 00000000000..1365c155038 --- /dev/null +++ b/libs/importer/src/components/chrome/index.ts @@ -0,0 +1 @@ +export { ImportChromeComponent } from "./import-chrome.component"; diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html index d0107bb5808..9f1247b52da 100644 --- a/libs/importer/src/components/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -169,27 +169,41 @@ "Export" to save the JSON file.
--> - + The process is exactly the same as importing from Google Chrome. - See detailed instructions on our help site at - - https://bitwarden.com/help/import-from-chrome/ + See detailed instructions on our help site at + + https://bitwarden.com/help/import-from-chrome/ +

+ + + {{ "importDirectlyFromBrowser" | i18n }} + + + {{ "importFromCSV" | i18n }} + +
See detailed instructions on our help site at @@ -440,12 +454,20 @@ previously chosen. - -
+ @if (showLastPassOptions) { + + } @else if (showChromiumOptions$ | async) { + + } @else { {{ "selectImportFile" | i18n }}
@@ -473,7 +495,7 @@ formControlName="fileContents" > -
+ } diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 646db8d643e..774392be879 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, + DestroyRef, EventEmitter, Inject, Input, @@ -13,17 +14,23 @@ import { Output, ViewChild, } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import * as JSZip from "jszip"; -import { Observable, Subject, lastValueFrom, combineLatest, firstValueFrom } from "rxjs"; +import { + Observable, + Subject, + lastValueFrom, + combineLatest, + firstValueFrom, + BehaviorSubject, +} from "rxjs"; import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/operators"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { getOrganizationById, OrganizationService, @@ -34,14 +41,10 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; 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"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -62,49 +65,20 @@ import { ToastService, LinkModule, } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; +import { ImporterMetadata, DataLoader, Loader, Instructions } from "../metadata"; import { ImportOption, ImportResult, ImportType } from "../models"; -import { - ImportApiService, - ImportApiServiceAbstraction, - ImportCollectionServiceAbstraction, - ImportService, - ImportServiceAbstraction, -} from "../services"; +import { ImportCollectionServiceAbstraction, ImportServiceAbstraction } from "../services"; +import { ImportChromeComponent } from "./chrome"; import { FilePasswordPromptComponent, ImportErrorDialogComponent, ImportSuccessDialogComponent, } from "./dialog"; +import { ImporterProviders } from "./importer-providers"; import { ImportLastPassComponent } from "./lastpass"; -const safeProviders: SafeProvider[] = [ - safeProvider({ - provide: ImportApiServiceAbstraction, - useClass: ImportApiService, - deps: [ApiService], - }), - safeProvider({ - provide: ImportServiceAbstraction, - useClass: ImportService, - deps: [ - CipherService, - FolderService, - ImportApiServiceAbstraction, - I18nService, - CollectionService, - KeyService, - EncryptService, - PinServiceAbstraction, - AccountService, - SdkService, - RestrictedItemTypesService, - ], - }), -]; - @Component({ selector: "tools-import", templateUrl: "import.component.html", @@ -118,6 +92,7 @@ const safeProviders: SafeProvider[] = [ SelectModule, CalloutModule, ReactiveFormsModule, + ImportChromeComponent, ImportLastPassComponent, RadioButtonModule, CardComponent, @@ -125,7 +100,7 @@ const safeProviders: SafeProvider[] = [ SectionComponent, LinkModule, ], - providers: safeProviders, + providers: ImporterProviders, }) export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { featuredImportOptions: ImportOption[]; @@ -160,6 +135,12 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { }); } + @Input() + onLoadProfilesFromBrowser: (browser: string) => Promise; + + @Input() + onImportFromBrowser: (browser: string, profile: string) => Promise; + protected organization: Organization; protected destroy$ = new Subject(); @@ -184,6 +165,8 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { fileContents: [], file: [], lastPassType: ["direct" as "csv" | "direct"], + // FIXME: once the flag is disabled this should initialize to `Strategy.browser` + chromiumLoader: [Loader.file as DataLoader], }); @ViewChild(BitSubmitDirective) @@ -208,6 +191,26 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { }); } + private importer$ = new BehaviorSubject(undefined); + + /** emits `true` when the chromium instruction block should be visible. */ + protected readonly showChromiumInstructions$ = this.importer$.pipe( + map((importer) => importer?.instructions === Instructions.chromium), + ); + + /** emits `true` when direct browser import is available. */ + // FIXME: use the capabilities list to populate `chromiumLoader` and replace the explicit + // strategy check with a check for multiple loaders + protected readonly browserImporterAvailable$ = this.importer$.pipe( + map((importer) => (importer?.loaders ?? []).includes(Loader.chromium)), + ); + + /** emits `true` when the chromium loader is selected. */ + protected readonly showChromiumOptions$ = + this.formGroup.controls.chromiumLoader.valueChanges.pipe( + map((chromiumLoader) => chromiumLoader === Loader.chromium), + ); + constructor( protected i18nService: I18nService, protected importService: ImportServiceAbstraction, @@ -226,6 +229,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { protected toastService: ToastService, protected accountService: AccountService, private restrictedItemTypesService: RestrictedItemTypesService, + private destroyRef: DestroyRef, ) {} protected get importBlockedByPolicy(): boolean { @@ -246,6 +250,23 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { async ngOnInit() { this.setImportOptions(); + this.importService + .metadata$(this.formGroup.controls.format.valueChanges) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (importer) => { + this.importer$.next(importer); + + // when an importer is defined, the loader needs to be set to a value from + // its list. + const loader = importer.loaders.includes(Loader.chromium) + ? Loader.chromium + : importer.loaders?.[0]; + this.formGroup.controls.chromiumLoader.setValue(loader ?? Loader.file); + }, + error: (err: unknown) => this.logService.error("an error occurred", err), + }); + if (this.organizationId) { await this.handleOrganizationImportInit(); } else { @@ -578,7 +599,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { private async setImportContents(): Promise { const fileEl = document.getElementById("import_input_file") as HTMLInputElement; - const files = fileEl.files; + const files = fileEl?.files; let fileContents = this.formGroup.controls.fileContents.value; if (files != null && files.length > 0) { diff --git a/libs/importer/src/components/importer-providers.ts b/libs/importer/src/components/importer-providers.ts new file mode 100644 index 00000000000..b00bd65211e --- /dev/null +++ b/libs/importer/src/components/importer-providers.ts @@ -0,0 +1,91 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; +import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +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 { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "@bitwarden/common/tools/extension/extension-registry.abstraction"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; +import { + createSystemServiceProvider, + SystemServiceProvider, +} from "@bitwarden/common/tools/providers"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { KeyService } from "@bitwarden/key-management"; +import { StateProvider } from "@bitwarden/state"; +import { SafeInjectionToken } from "@bitwarden/ui-common"; + +import { + ImportApiService, + ImportApiServiceAbstraction, + ImportService, + ImportServiceAbstraction, +} from "../services"; + +// FIXME: unify with `SYSTEM_SERVICE_PROVIDER` when migrating it from the generator component module +// to a general module. +const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("SystemServices"); + +/** Import service factories */ +export const ImporterProviders: SafeProvider[] = [ + safeProvider({ + provide: ImportApiServiceAbstraction, + useClass: ImportApiService, + deps: [ApiService], + }), + safeProvider({ + provide: LegacyEncryptorProvider, + useClass: KeyServiceLegacyEncryptorProvider, + deps: [EncryptService, KeyService], + }), + safeProvider({ + provide: ExtensionRegistry, + useFactory: () => { + return buildExtensionRegistry(); + }, + deps: [], + }), + safeProvider({ + provide: SYSTEM_SERVICE_PROVIDER, + useFactory: createSystemServiceProvider, + deps: [ + LegacyEncryptorProvider, + StateProvider, + PolicyService, + ExtensionRegistry, + LogService, + PlatformUtilsService, + ConfigService, + ], + }), + safeProvider({ + provide: ImportServiceAbstraction, + useClass: ImportService, + deps: [ + CipherService, + FolderService, + ImportApiServiceAbstraction, + I18nService, + CollectionService, + KeyService, + EncryptService, + PinServiceAbstraction, + AccountService, + RestrictedItemTypesService, + SYSTEM_SERVICE_PROVIDER, + ], + }), +]; diff --git a/libs/importer/src/importers/chrome-csv-importer.ts b/libs/importer/src/importers/chrome-csv-importer.ts index 445f0ad57ae..c7a72c126b0 100644 --- a/libs/importer/src/importers/chrome-csv-importer.ts +++ b/libs/importer/src/importers/chrome-csv-importer.ts @@ -24,6 +24,7 @@ export class ChromeCsvImporter extends BaseImporter implements Importer { cipher.login.username = this.getValueOrDefault(value.username); cipher.login.password = this.getValueOrDefault(value.password); cipher.login.uris = this.makeUriArray(value.url); + cipher.notes = this.getValueOrDefault(value.note); this.cleanupCipher(cipher); result.ciphers.push(cipher); }); diff --git a/libs/importer/src/metadata/availability.ts b/libs/importer/src/metadata/availability.ts new file mode 100644 index 00000000000..0ac7269496a --- /dev/null +++ b/libs/importer/src/metadata/availability.ts @@ -0,0 +1,15 @@ +import { ClientType } from "@bitwarden/client-type"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { Loader } from "./data"; +import { DataLoader } from "./types"; + +/** Describes which loaders are supported on each client */ +export const LoaderAvailability: Record = deepFreeze({ + [Loader.chromium]: [ClientType.Desktop], + [Loader.download]: [ClientType.Browser], + [Loader.file]: [ClientType.Browser, ClientType.Desktop, ClientType.Web, ClientType.Cli], + + // FIXME: enable IPC importer on `ClientType.Desktop` once it's ready + [Loader.ipc]: [], +}); diff --git a/libs/importer/src/metadata/data.ts b/libs/importer/src/metadata/data.ts new file mode 100644 index 00000000000..82edd5cdc2d --- /dev/null +++ b/libs/importer/src/metadata/data.ts @@ -0,0 +1,27 @@ +/** Mechanisms that load data into the importer. */ +export const Loader = Object.freeze({ + /** Data loaded from a file provided by the user/ */ + file: "file", + + /** Data loaded directly from the chromium browser's data store */ + chromium: "chromium", + + /** Data provided through an importer ipc channel (e.g. Bitwarden bridge) */ + ipc: "ipc", + + /** Data provided through direct file download (e.g. a LastPass export) */ + download: "download", +}); + +/** Re-branded products often leave their exporters unaltered; when that occurs, + * `Instructions` lets us group them together. + * + * @remarks Instructions values must be mutually exclusive from Loader's values. + */ +export const Instructions = Object.freeze({ + /** the instructions are unique to the import type */ + unique: "unique", + + /** shared chromium instructions */ + chromium: "chromium", +}); diff --git a/libs/importer/src/metadata/importers.ts b/libs/importer/src/metadata/importers.ts new file mode 100644 index 00000000000..efd5eafe7d5 --- /dev/null +++ b/libs/importer/src/metadata/importers.ts @@ -0,0 +1,27 @@ +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { ImportType } from "../models"; + +import { Loader, Instructions } from "./data"; +import { ImporterMetadata } from "./types"; + +// FIXME: load this data from rust code +const importers = [ + // chromecsv import depends upon operating system, so ironically it doesn't support chromium + { id: "chromecsv", loaders: [Loader.file], instructions: Instructions.chromium }, + { id: "operacsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + { + id: "vivaldicsv", + loaders: [Loader.file, Loader.chromium], + instructions: Instructions.chromium, + }, + { id: "bravecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + { id: "edgecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + + // FIXME: add other formats and remove `Partial` from export +] as const; + +/** Describes which loaders are available for each import type */ +export const Importers: Partial> = deepFreeze( + Object.fromEntries(importers.map((i) => [i.id, i])), +); diff --git a/libs/importer/src/metadata/index.ts b/libs/importer/src/metadata/index.ts new file mode 100644 index 00000000000..17c009ae68e --- /dev/null +++ b/libs/importer/src/metadata/index.ts @@ -0,0 +1,4 @@ +export * from "./availability"; +export * from "./data"; +export * from "./types"; +export * from "./importers"; diff --git a/libs/importer/src/metadata/types.ts b/libs/importer/src/metadata/types.ts new file mode 100644 index 00000000000..09b3fe97fd5 --- /dev/null +++ b/libs/importer/src/metadata/types.ts @@ -0,0 +1,20 @@ +import { ImportType } from "../models"; + +import { Instructions, Loader } from "./data"; + +/** Mechanisms that load data into the importer. */ +export type DataLoader = (typeof Loader)[keyof typeof Loader]; + +export type InstructionLink = (typeof Instructions)[keyof typeof Instructions]; + +/** Mechanisms that load data into the importer. */ +export type ImporterMetadata = { + /** Identifies the importer */ + type: ImportType; + + /** Identifies the instructions for the importer; this defaults to `unique`. */ + instructions?: InstructionLink; + + /** Describes the strategies used to obtain imported data */ + loaders: DataLoader[]; +}; diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index 205dbaf0198..22a4f63b248 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -6,7 +6,7 @@ export interface ImportOption { export const featuredImportOptions = [ { id: "bitwardenjson", name: "Bitwarden (json)" }, { id: "bitwardencsv", name: "Bitwarden (csv)" }, - { id: "chromecsv", name: "Chrome (csv)" }, + { id: "chromecsv", name: "Chrome" }, { id: "dashlanecsv", name: "Dashlane (csv)" }, { id: "firefoxcsv", name: "Firefox (csv)" }, { id: "keepass2xml", name: "KeePass 2 (xml)" }, @@ -46,9 +46,10 @@ export const regularImportOptions = [ { id: "ascendocsv", name: "Ascendo DataVault (csv)" }, { id: "meldiumcsv", name: "Meldium (csv)" }, { id: "passkeepcsv", name: "PassKeep (csv)" }, - { id: "edgecsv", name: "Edge (csv)" }, - { id: "operacsv", name: "Opera (csv)" }, - { id: "vivaldicsv", name: "Vivaldi (csv)" }, + { id: "edgecsv", name: "Edge" }, + { id: "operacsv", name: "Opera" }, + { id: "vivaldicsv", name: "Vivaldi" }, + { id: "bravecsv", name: "Brave" }, { id: "gnomejson", name: "GNOME Passwords and Keys/Seahorse (json)" }, { id: "blurcsv", name: "Blur (csv)" }, { id: "passwordagentcsv", name: "Password Agent (csv)" }, diff --git a/libs/importer/src/services/import.service.abstraction.ts b/libs/importer/src/services/import.service.abstraction.ts index d869dc71cc7..ee0d1ed33ab 100644 --- a/libs/importer/src/services/import.service.abstraction.ts +++ b/libs/importer/src/services/import.service.abstraction.ts @@ -1,11 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { Observable } from "rxjs"; + // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionView } from "@bitwarden/admin-console/common"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { Importer } from "../importers/importer"; +import { ImporterMetadata } from "../metadata"; import { ImportOption, ImportType } from "../models/import-options"; import { ImportResult } from "../models/import-result"; @@ -13,6 +16,10 @@ export abstract class ImportServiceAbstraction { featuredImportOptions: readonly ImportOption[]; regularImportOptions: readonly ImportOption[]; getImportOptions: () => ImportOption[]; + + /** describes the features supported by a format */ + metadata$: (type$: Observable) => Observable; + import: ( importer: Importer, fileContents: string, diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index ad6e6ebf016..c3d555af936 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -1,14 +1,20 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, Subject, firstValueFrom } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { ClientType } from "@bitwarden/client-type"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -19,6 +25,8 @@ import { KeyService } from "@bitwarden/key-management"; import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer"; import { Importer } from "../importers/importer"; +import { ImporterMetadata, Instructions, Loader } from "../metadata"; +import { ImportType } from "../models"; import { ImportResult } from "../models/import-result"; import { ImportApiServiceAbstraction } from "./import-api.service.abstraction"; @@ -35,8 +43,8 @@ describe("ImportService", () => { let encryptService: MockProxy; let pinService: MockProxy; let accountService: MockProxy; - let sdkService: MockSdkService; let restrictedItemTypesService: MockProxy; + let systemServiceProvider: MockProxy; beforeEach(() => { cipherService = mock(); @@ -47,9 +55,20 @@ describe("ImportService", () => { keyService = mock(); encryptService = mock(); pinService = mock(); - sdkService = new MockSdkService(); restrictedItemTypesService = mock(); + const configService = mock(); + configService.getFeatureFlag$.mockReturnValue(new BehaviorSubject(false)); + + const environment = mock(); + environment.getClientType.mockReturnValue(ClientType.Desktop); + + systemServiceProvider = mock({ + configService, + environment, + log: jest.fn().mockReturnValue({ debug: jest.fn() }), + }); + importService = new ImportService( cipherService, folderService, @@ -60,8 +79,8 @@ describe("ImportService", () => { encryptService, pinService, accountService, - sdkService, restrictedItemTypesService, + systemServiceProvider, ); }); @@ -249,6 +268,170 @@ describe("ImportService", () => { expect(importResult.folderRelationships[1]).toEqual([0, 1]); }); }); + + describe("metadata$", () => { + let featureFlagSubject: BehaviorSubject; + let typeSubject: Subject; + let mockLogger: { debug: jest.Mock }; + + beforeEach(() => { + featureFlagSubject = new BehaviorSubject(false); + typeSubject = new Subject(); + mockLogger = { debug: jest.fn() }; + + const configService = mock(); + configService.getFeatureFlag$.mockReturnValue(featureFlagSubject); + + const environment = mock(); + environment.getClientType.mockReturnValue(ClientType.Desktop); + + systemServiceProvider = mock({ + configService, + environment, + log: jest.fn().mockReturnValue(mockLogger), + }); + + // Recreate the service with the updated mocks for logging tests + importService = new ImportService( + cipherService, + folderService, + importApiService, + i18nService, + collectionService, + keyService, + encryptService, + pinService, + accountService, + restrictedItemTypesService, + systemServiceProvider, + ); + }); + + afterEach(() => { + featureFlagSubject.complete(); + typeSubject.complete(); + }); + + it("should emit metadata when type$ emits", async () => { + const testType: ImportType = "chromecsv"; + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result).toEqual({ + type: testType, + loaders: expect.any(Array), + instructions: Instructions.chromium, + }); + expect(result.type).toBe(testType); + }); + + it("should include all loaders when chromium feature flag is enabled", async () => { + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(true); + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should exclude chromium loader when feature flag is disabled", async () => { + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(false); + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).not.toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should update when type$ changes", async () => { + const emissions: ImporterMetadata[] = []; + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + typeSubject.next("chromecsv"); + typeSubject.next("bravecsv"); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions).toHaveLength(2); + expect(emissions[0].type).toBe("chromecsv"); + expect(emissions[1].type).toBe("bravecsv"); + + subscription.unsubscribe(); + }); + + it("should update when feature flag changes", async () => { + const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader + const emissions: ImporterMetadata[] = []; + + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + typeSubject.next(testType); + featureFlagSubject.next(true); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions).toHaveLength(2); + expect(emissions[0].loaders).not.toContain(Loader.chromium); + expect(emissions[1].loaders).toContain(Loader.chromium); + + subscription.unsubscribe(); + }); + + it("should update when both type$ and feature flag change", async () => { + const emissions: ImporterMetadata[] = []; + + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + // Initial emission + typeSubject.next("chromecsv"); + + // Change both at the same time + featureFlagSubject.next(true); + typeSubject.next("bravecsv"); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions.length).toBeGreaterThanOrEqual(2); + const lastEmission = emissions[emissions.length - 1]; + expect(lastEmission.type).toBe("bravecsv"); + + subscription.unsubscribe(); + }); + + it("should log debug information with correct data", async () => { + const testType: ImportType = "chromecsv"; + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + await metadataPromise; + + expect(mockLogger.debug).toHaveBeenCalledWith( + { importType: testType, capabilities: expect.any(Object) }, + "capabilities updated", + ); + }); + }); }); function createCipher(options: Partial = {}) { diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 133607251c3..e868a5ac516 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, map } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -10,6 +10,7 @@ import { CollectionView, } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request"; @@ -17,8 +18,9 @@ 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 { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -95,6 +97,7 @@ import { PasswordDepot17XmlImporter, } from "../importers"; import { Importer } from "../importers/importer"; +import { ImporterMetadata, Importers, Loader } from "../metadata"; import { featuredImportOptions, ImportOption, @@ -104,12 +107,15 @@ import { import { ImportResult } from "../models/import-result"; import { ImportApiServiceAbstraction } from "../services/import-api.service.abstraction"; import { ImportServiceAbstraction } from "../services/import.service.abstraction"; +import { availableLoaders as availableLoaders } from "../util"; export class ImportService implements ImportServiceAbstraction { featuredImportOptions = featuredImportOptions as readonly ImportOption[]; regularImportOptions = regularImportOptions as readonly ImportOption[]; + private logger: SemanticLogger; + constructor( private cipherService: CipherService, private folderService: FolderService, @@ -120,14 +126,42 @@ export class ImportService implements ImportServiceAbstraction { private encryptService: EncryptService, private pinService: PinServiceAbstraction, private accountService: AccountService, - private sdkService: SdkService, private restrictedItemTypesService: RestrictedItemTypesService, - ) {} + private system: SystemServiceProvider, + ) { + this.logger = system.log({ type: "ImportService" }); + } getImportOptions(): ImportOption[] { return this.featuredImportOptions.concat(this.regularImportOptions); } + metadata$(type$: Observable): Observable { + const browserEnabled$ = this.system.configService.getFeatureFlag$( + FeatureFlag.UseChromiumImporter, + ); + const client = this.system.environment.getClientType(); + const capabilities$ = combineLatest([type$, browserEnabled$]).pipe( + map(([type, enabled]) => { + let loaders = availableLoaders(type, client); + if (!enabled) { + loaders = loaders?.filter((loader) => loader !== Loader.chromium); + } + + const capabilities: ImporterMetadata = { type, loaders }; + if (type in Importers) { + capabilities.instructions = Importers[type].instructions; + } + + this.logger.debug({ importType: type, capabilities }, "capabilities updated"); + + return capabilities; + }), + ); + + return capabilities$; + } + async import( importer: Importer, fileContents: string, @@ -260,6 +294,7 @@ export class ImportService implements ImportServiceAbstraction { case "chromecsv": case "operacsv": case "vivaldicsv": + case "bravecsv": return new ChromeCsvImporter(); case "firefoxcsv": return new FirefoxCsvImporter(); diff --git a/libs/importer/src/util.spec.ts b/libs/importer/src/util.spec.ts new file mode 100644 index 00000000000..5a68e3cea12 --- /dev/null +++ b/libs/importer/src/util.spec.ts @@ -0,0 +1,60 @@ +import { ClientType } from "@bitwarden/client-type"; + +import { Loader } from "./metadata"; +import { availableLoaders } from "./util"; + +describe("availableLoaders", () => { + describe("given valid import types", () => { + it("returns available loaders when client supports all loaders", () => { + const result = availableLoaders("operacsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file, Loader.chromium]); + }); + + it("returns filtered loaders when client supports some loaders", () => { + const result = availableLoaders("operacsv", ClientType.Browser); + + expect(result).toEqual([Loader.file]); + }); + + it("returns single loader for import types with one loader", () => { + const result = availableLoaders("chromecsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file]); + }); + + it("returns all supported loaders for multi-loader import types", () => { + const result = availableLoaders("bravecsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file, Loader.chromium]); + }); + }); + + describe("given unknown import types", () => { + it("returns undefined when import type is not found in metadata", () => { + const result = availableLoaders("nonexistent" as any, ClientType.Desktop); + + expect(result).toBeUndefined(); + }); + }); + + describe("given different client types", () => { + it("returns appropriate loaders for Browser client", () => { + const result = availableLoaders("operacsv", ClientType.Browser); + + expect(result).toEqual([Loader.file]); + }); + + it("returns appropriate loaders for Web client", () => { + const result = availableLoaders("chromecsv", ClientType.Web); + + expect(result).toEqual([Loader.file]); + }); + + it("returns appropriate loaders for CLI client", () => { + const result = availableLoaders("vivaldicsv", ClientType.Cli); + + expect(result).toEqual([Loader.file]); + }); + }); +}); diff --git a/libs/importer/src/util.ts b/libs/importer/src/util.ts new file mode 100644 index 00000000000..0a76b7e753b --- /dev/null +++ b/libs/importer/src/util.ts @@ -0,0 +1,19 @@ +import { ClientType } from "@bitwarden/client-type"; + +import { LoaderAvailability, Importers } from "./metadata"; +import { ImportType } from "./models"; + +/** Lookup the loaders supported by a specific client. + * WARNING: this method does not supply metadata for every import type. + * @returns `undefined` when metadata is not defined for the type, or + * an array identifying the supported clients. + */ +export function availableLoaders(type: ImportType, client: ClientType) { + if (!(type in Importers)) { + return undefined; + } + + const capabilities = Importers[type]?.loaders ?? []; + const available = capabilities.filter((loader) => LoaderAvailability[loader].includes(client)); + return available; +}