1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-24748][PM-24072] Chromium importer (#16100)

* 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 <Hinton@users.noreply.github.com>

* Fix napi deps

* fix lints

* format

* fix linux lint

* fix windows lints

* format

* fix missing `?`

* fix a different missing `?`

---------

Co-authored-by: Dmitry Yakimenko <detunized@gmail.com>
Co-authored-by: Kyle Spearrin <kyle.spearrin@gmail.com>
Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by:  Audrey  <ajensen@bitwarden.com>
Co-authored-by:  Audrey  <audrey@audreyality.com>
Co-authored-by: adudek-bw <adudek@bitwarden.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
Daniel James Smith
2025-09-04 11:21:57 +02:00
committed by GitHub
parent b957a0c28f
commit 66f5700a75
46 changed files with 2436 additions and 90 deletions

1
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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(

View File

@@ -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(

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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
```

View File

@@ -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<String>,
#[allow(dead_code)]
pub account_email: Option<String>,
}
#[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<Vec<String>> {
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<Vec<ProfileInfo>> {
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<Vec<LoginImportResult>> {
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::<Vec<_>>();
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::<std::collections::HashMap<_, _>>()
});
fn get_browser_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
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<String>;
}
#[derive(serde::Deserialize, Clone)]
struct LocalState {
profile: AllProfiles,
#[allow(dead_code)]
os_crypt: Option<OsCrypt>,
}
#[derive(serde::Deserialize, Clone)]
struct AllProfiles {
info_cache: std::collections::HashMap<String, OneProfile>,
}
#[derive(serde::Deserialize, Clone)]
struct OneProfile {
name: String,
gaia_name: Option<String>,
user_name: Option<String>,
}
#[derive(serde::Deserialize, Clone)]
struct OsCrypt {
#[allow(dead_code)]
encrypted_key: Option<String>,
#[allow(dead_code)]
app_bound_encrypted_key: Option<String>,
}
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<LocalState> {
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<ProfileInfo> {
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<u8>,
encrypted_note: Vec<u8>,
}
fn get_logins(
browser_dir: &Path,
profile_id: &String,
filename: &str,
) -> Result<Vec<EncryptedLogin>> {
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::<u32>()
));
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<u8> {
decode(hex).unwrap_or_default()
}
fn does_table_exist(conn: &Connection, table_name: &str) -> Result<bool, rusqlite::Error> {
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<Vec<EncryptedLogin>, 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<EncryptedLogin>,
crypto_service: &mut Box<dyn CryptoService>,
) -> Vec<LoginImportResult> {
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<dyn CryptoService>,
) -> 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(),
}),
}
}

View File

@@ -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<u8, U32>) -> Result<Vec<u8>> {
let iv = GenericArray::from_slice(iv);
let mut data = data.to_vec();
return cbc::Decryptor::<aes::Aes256>::new(&key, iv)
.decrypt_padded_mut::<Pkcs7>(&mut data)
.map_err(|_| anyhow!("Failed to decrypt data"))?;
Ok(data)
}

View File

@@ -0,0 +1 @@
pub mod chromium;

View File

@@ -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<Box<dyn CryptoService>> {
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<Vec<u8>>,
}
impl LinuxCryptoService {
fn new(config: &'static KeyringConfig) -> Self {
Self {
config,
v11_key: None,
}
}
fn decrypt_v10(&self, encrypted: &[u8]) -> Result<String> {
decrypt(&V10_KEY, encrypted)
}
async fn decrypt_v11(&mut self, encrypted: &[u8]) -> Result<String> {
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<String> {
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<String> {
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<Vec<u8>> {
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")),
}
}

View File

@@ -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<Box<dyn CryptoService>> {
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<Vec<u8>>,
}
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<String> {
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<Vec<u8>> {
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<Vec<u8>> {
let password = get_generic_password(service, account)
.map_err(|e| anyhow!("Failed to get password from keychain: {}", e))?;
Ok(password)
}

View File

@@ -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<Vec<u8>> {
let decryptor = cbc::Decryptor::<aes::Aes128>::new_from_slices(key, iv)?;
let plaintext = decryptor
.decrypt_padded_vec_mut::<Pkcs7>(ciphertext)
.map_err(|e| anyhow!("Failed to decrypt: {}", e))?;
Ok(plaintext)
}
pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result<Vec<u8>> {
let mut key = vec![0u8; 16];
pbkdf2::<Hmac<Sha1>>(password, b"saltysalt", iterations, &mut key)
.map_err(|e| anyhow!("Failed to derive master key: {}", e))?;
Ok(key)
}

View File

@@ -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<Box<dyn CryptoService>> {
Ok(Box::new(WindowsCryptoService::new(local_state)))
}
//
// CryptoService
//
struct WindowsCryptoService {
master_key: Option<Vec<u8>>,
encrypted_key: Option<String>,
}
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<String> {
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::<Aes256Gcm>::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<Vec<u8>> {
match version {
"v10" => self.get_master_key_v10(),
_ => Err(anyhow!("Unsupported version: {}", version)),
}
}
fn get_master_key_v10(&mut self) -> Result<Vec<u8>> {
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<Vec<u8>> {
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())
}

View File

@@ -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";

View File

@@ -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 }

View File

@@ -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<Array<string>>
export function getAvailableProfiles(browser: string): Promise<Array<ProfileInfo>>
export function importLogins(browser: string, profileId: string): Promise<Array<LoginImportResult>>
}
export declare namespace autotype {
export function getForegroundWindowTitle(): string
export function typeInput(input: Array<number>): void

View File

@@ -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<Login>,
pub failure: Option<LoginImportFailure>,
}
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<Vec<String>> {
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<Vec<ProfileInfo>> {
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<Vec<LoginImportResult>> {
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]

View File

@@ -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);
},
);
}
}

View File

@@ -5,6 +5,8 @@
(formLoading)="this.loading = $event"
(formDisabled)="this.disabled = $event"
(onSuccessfulImport)="this.onSuccessfulImport($event)"
[onImportFromBrowser]="this.onImportFromBrowser"
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
></tools-import>
</ng-container>
<ng-container bitDialogFooter>

View File

@@ -28,4 +28,12 @@ export class ImportDesktopComponent {
protected async onSuccessfulImport(organizationId: string): Promise<void> {
this.dialogRef.close();
}
protected onLoadProfilesFromBrowser(browser: string): Promise<any[]> {
return ipc.tools.chromiumImporter.getAvailableProfiles(browser);
}
protected onImportFromBrowser(browser: string, profile: string): Promise<any[]> {
return ipc.tools.chromiumImporter.importLogins(browser, profile);
}
}

View File

@@ -0,0 +1,14 @@
import { ipcRenderer } from "electron";
const chromiumImporter = {
getInstalledBrowsers: (): Promise<string[]> =>
ipcRenderer.invoke("chromium_importer.getInstalledBrowsers"),
getAvailableProfiles: (browser: string): Promise<any[]> =>
ipcRenderer.invoke("chromium_importer.getAvailableProfiles", browser),
importLogins: (browser: string, profileId: string): Promise<any[]> =>
ipcRenderer.invoke("chromium_importer.importLogins", browser, profileId),
};
export default {
chromiumImporter,
};

View File

@@ -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."

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,

View File

@@ -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<PlatformUtilsService>;
let mockConfigService: ConfigService;
beforeEach(() => {
jest.resetAllMocks();
mockEncryptor = mock<LegacyEncryptorProvider>();
mockState = mock<StateProvider>();
mockPolicy = mock<PolicyService>();
mockRegistry = mock<ExtensionRegistry>();
mockLogger = mock<LogService>();
mockEnvironment = mock<PlatformUtilsService>();
mockConfigService = mock<ConfigService>();
});
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);
});
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,8 @@
<div [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "browserProfile" | i18n }}</bit-label>
<bit-select formControlName="profile">
<bit-option *ngFor="let p of profileList" [value]="p.id" [label]="p.name" />
</bit-select>
</bit-form-field>
</div>

View File

@@ -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<any[]>;
@Input()
onImportFromBrowser: (browser: string, profile: string) => Promise<any[]>;
@Output() csvDataLoaded = new EventEmitter<string>();
constructor(
private formBuilder: FormBuilder,
private controlContainer: ControlContainer,
private logService: LogService,
private i18nService: I18nService,
) {}
async ngOnInit(): Promise<void> {
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;
}
}

View File

@@ -0,0 +1 @@
export { ImportChromeComponent } from "./import-chrome.component";

View File

@@ -169,27 +169,41 @@
"Export" to save the JSON file.
</ng-container>
-->
<ng-container
*ngIf="
format === 'chromecsv' ||
format === 'operacsv' ||
format === 'vivaldicsv' ||
format === 'edgecsv'
"
>
<ng-container *ngIf="showChromiumInstructions$ | async">
<span *ngIf="format !== 'chromecsv'">
The process is exactly the same as importing from Google Chrome.
</span>
See detailed instructions on our help site at
<a
bitLink
linkType="primary"
target="_blank"
rel="noreferrer"
href="https://bitwarden.com/help/import-from-chrome/"
>
https://bitwarden.com/help/import-from-chrome/</a
<p>
See detailed instructions on our help site at
<a
bitLink
linkType="primary"
target="_blank"
rel="noreferrer"
href="https://bitwarden.com/help/import-from-chrome/"
>
https://bitwarden.com/help/import-from-chrome/</a
>
</p>
<bit-radio-group
[hidden]="!(browserImporterAvailable$ | async)"
formControlName="chromiumLoader"
>
<bit-radio-button
class="tw-block"
id="import_bit-radio-button_chrome-browser"
value="chromium"
>
<bit-label>{{ "importDirectlyFromBrowser" | i18n }}</bit-label>
</bit-radio-button>
<bit-radio-button
class="tw-block"
id="import_bit-radio-button_chrome-file"
value="file"
>
<bit-label>{{ "importFromCSV" | i18n }}</bit-label>
</bit-radio-button>
</bit-radio-group>
</ng-container>
<ng-container *ngIf="format === 'firefoxcsv'">
See detailed instructions on our help site at
@@ -440,12 +454,20 @@
previously chosen.
</ng-container>
</bit-callout>
<import-lastpass
*ngIf="showLastPassOptions"
[formGroup]="formGroup"
(csvDataLoaded)="this.formGroup.controls.fileContents.setValue($event)"
></import-lastpass>
<div [hidden]="showLastPassOptions">
@if (showLastPassOptions) {
<import-lastpass
[formGroup]="formGroup"
(csvDataLoaded)="this.formGroup.controls.fileContents.setValue($event)"
></import-lastpass>
} @else if (showChromiumOptions$ | async) {
<import-chrome
[formGroup]="formGroup"
[onImportFromBrowser]="this.onImportFromBrowser"
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
[format]="this.format"
(csvDataLoaded)="this.formGroup.controls.fileContents.setValue($event)"
></import-chrome>
} @else {
<bit-form-field>
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>
<div class="file-selector tw-pt-2 tw-pb-1 tw-break-words">
@@ -473,7 +495,7 @@
formControlName="fileContents"
></textarea>
</bit-form-field>
</div>
}
</bit-card>
</bit-section>
</form>

View File

@@ -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<any[]>;
@Input()
onImportFromBrowser: (browser: string, profile: string) => Promise<any[]>;
protected organization: Organization;
protected destroy$ = new Subject<void>();
@@ -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<ImporterMetadata | undefined>(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<string> {
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) {

View File

@@ -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<SystemServiceProvider>("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,
],
}),
];

View File

@@ -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);
});

View File

@@ -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<DataLoader, ClientType[]> = 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]: [],
});

View File

@@ -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",
});

View File

@@ -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<Record<ImportType, ImporterMetadata>> = deepFreeze(
Object.fromEntries(importers.map((i) => [i.id, i])),
);

View File

@@ -0,0 +1,4 @@
export * from "./availability";
export * from "./data";
export * from "./types";
export * from "./importers";

View File

@@ -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[];
};

View File

@@ -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)" },

View File

@@ -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<ImportType>) => Observable<ImporterMetadata>;
import: (
importer: Importer,
fileContents: string,

View File

@@ -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<EncryptService>;
let pinService: MockProxy<PinServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let sdkService: MockSdkService;
let restrictedItemTypesService: MockProxy<RestrictedItemTypesService>;
let systemServiceProvider: MockProxy<SystemServiceProvider>;
beforeEach(() => {
cipherService = mock<CipherService>();
@@ -47,9 +55,20 @@ describe("ImportService", () => {
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
pinService = mock<PinServiceAbstraction>();
sdkService = new MockSdkService();
restrictedItemTypesService = mock<RestrictedItemTypesService>();
const configService = mock<ConfigService>();
configService.getFeatureFlag$.mockReturnValue(new BehaviorSubject(false));
const environment = mock<PlatformUtilsService>();
environment.getClientType.mockReturnValue(ClientType.Desktop);
systemServiceProvider = mock<SystemServiceProvider>({
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<boolean>;
let typeSubject: Subject<ImportType>;
let mockLogger: { debug: jest.Mock };
beforeEach(() => {
featureFlagSubject = new BehaviorSubject(false);
typeSubject = new Subject<ImportType>();
mockLogger = { debug: jest.fn() };
const configService = mock<ConfigService>();
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject);
const environment = mock<PlatformUtilsService>();
environment.getClientType.mockReturnValue(ClientType.Desktop);
systemServiceProvider = mock<SystemServiceProvider>({
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<CipherView> = {}) {

View File

@@ -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<ImportType>): Observable<ImporterMetadata> {
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();

View File

@@ -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]);
});
});
});

19
libs/importer/src/util.ts Normal file
View File

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