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;
+}