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:
committed by
GitHub
parent
b957a0c28f
commit
66f5700a75
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
106
apps/desktop/desktop_native/Cargo.lock
generated
106
apps/desktop/desktop_native/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod chromium;
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }
|
||||
|
||||
24
apps/desktop/desktop_native/napi/index.d.ts
vendored
24
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/desktop/src/app/tools/preload.ts
Normal file
14
apps/desktop/src/app/tools/preload.ts
Normal 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,
|
||||
};
|
||||
@@ -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."
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
178
libs/common/src/tools/providers.spec.ts
Normal file
178
libs/common/src/tools/providers.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
167
libs/importer/src/components/chrome/import-chrome.component.ts
Normal file
167
libs/importer/src/components/chrome/import-chrome.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
libs/importer/src/components/chrome/index.ts
Normal file
1
libs/importer/src/components/chrome/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImportChromeComponent } from "./import-chrome.component";
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
91
libs/importer/src/components/importer-providers.ts
Normal file
91
libs/importer/src/components/importer-providers.ts
Normal 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,
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
15
libs/importer/src/metadata/availability.ts
Normal file
15
libs/importer/src/metadata/availability.ts
Normal 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]: [],
|
||||
});
|
||||
27
libs/importer/src/metadata/data.ts
Normal file
27
libs/importer/src/metadata/data.ts
Normal 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",
|
||||
});
|
||||
27
libs/importer/src/metadata/importers.ts
Normal file
27
libs/importer/src/metadata/importers.ts
Normal 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])),
|
||||
);
|
||||
4
libs/importer/src/metadata/index.ts
Normal file
4
libs/importer/src/metadata/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./availability";
|
||||
export * from "./data";
|
||||
export * from "./types";
|
||||
export * from "./importers";
|
||||
20
libs/importer/src/metadata/types.ts
Normal file
20
libs/importer/src/metadata/types.ts
Normal 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[];
|
||||
};
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {}) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
60
libs/importer/src/util.spec.ts
Normal file
60
libs/importer/src/util.spec.ts
Normal 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
19
libs/importer/src/util.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user