diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index a0cd1b3dcbf..c4fcbe86a78 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -589,18 +589,24 @@ dependencies = [ "async-trait", "base64", "cbc", + "chacha20poly1305", + "clap", + "dirs", "hex", - "homedir", "oo7", "pbkdf2", "rand 0.9.1", "rusqlite", + "scopeguard", "security-framework", "serde", "serde_json", "sha1", + "sysinfo", "tokio", - "winapi", + "tracing", + "tracing-subscriber", + "verifysign", "windows 0.61.1", ] @@ -3824,6 +3830,18 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "verifysign" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebfe12e38930c3b851aea35e93fab1a6c29279cad7e8e273f29a21678fee8c0" +dependencies = [ + "core-foundation", + "sha1", + "sha2", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4186,6 +4204,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.42.2" diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 8b13fcc6eb3..9af3f832723 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -45,6 +45,25 @@ function buildProxyBin(target, release = true) { } } +function buildImporterBinaries(target, release = true) { + // These binaries are only built for Windows, so we can skip them on other platforms + if (process.platform !== "win32") { + return; + } + + const bin = "bitwarden_chromium_import_helper"; + const targetArg = target ? `--target ${target}` : ""; + const releaseArg = release ? "--release" : ""; + child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg} --features windows-binary`, {stdio: 'inherit', cwd: path.join(__dirname, "chromium_importer")}); + + if (target) { + // Copy the resulting binary to the dist folder + const targetFolder = release ? "release" : "debug"; + const nodeArch = rustTargetsMap[target].nodeArch; + fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`)); + } +} + function buildProcessIsolation() { if (process.platform !== "linux") { return; @@ -67,6 +86,7 @@ if (!crossPlatform && !target) { console.log(`Building native modules in ${mode} mode for the native architecture`); buildNapiModule(false, mode === "release"); buildProxyBin(false, mode === "release"); + buildImporterBinaries(false, mode === "release"); buildProcessIsolation(); return; } @@ -76,6 +96,7 @@ if (target) { installTarget(target); buildNapiModule(target, mode === "release"); buildProxyBin(target, mode === "release"); + buildImporterBinaries(false, mode === "release"); buildProcessIsolation(); return; } @@ -94,5 +115,6 @@ platformTargets.forEach(([target, _]) => { installTarget(target); buildNapiModule(target); buildProxyBin(target); + buildImporterBinaries(target); buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml index 648a36543c2..739382e2815 100644 --- a/apps/desktop/desktop_native/chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -12,25 +12,52 @@ anyhow = { workspace = true } async-trait = "=0.1.88" base64 = { workspace = true } cbc = { workspace = true, features = ["alloc"] } +dirs = { workspace = true } hex = { workspace = true } -homedir = { workspace = true } pbkdf2 = "=0.12.2" rand = { workspace = true } rusqlite = { version = "=0.37.0", features = ["bundled"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha1 = "=0.10.6" +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } [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"] } +chacha20poly1305 = { workspace = true } +clap = { version = "=4.5.40", features = ["derive"] } +scopeguard = { workspace = true } +sysinfo = { workspace = true, optional = true } +verifysign = "=0.2.4" +windows = { workspace = true, features = [ + "Wdk_System_SystemServices", + "Win32_Security_Cryptography", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_ProcessStatus", + "Win32_System_Services", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } [target.'cfg(target_os = "linux")'.dependencies] oo7 = { workspace = true } [lints] workspace = true + +[features] +windows-binary = ["dep:sysinfo"] + +[[bin]] +name = "bitwarden_chromium_import_helper" +path = "src/bin/bitwarden_chromium_import_helper.rs" +required-features = ["windows-binary"] diff --git a/apps/desktop/desktop_native/chromium_importer/README.md b/apps/desktop/desktop_native/chromium_importer/README.md index dd563697e5b..9bacea4822a 100644 --- a/apps/desktop/desktop_native/chromium_importer/README.md +++ b/apps/desktop/desktop_native/chromium_importer/README.md @@ -9,155 +9,126 @@ get access to the passwords. ### Overview -The Windows Application Bound Encryption (ABE) consists of three main components that work together: +The Windows **Application Bound Encryption (ABE)** subsystem consists of two 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 +- **client library** — a library that is part of the desktop client application +- **bitwarden_chromium_import_helper.exe** — a password decryptor running as **ADMINISTRATOR** and later as **SYSTEM** -_(The names of the binaries will be changed for the released product.)_ +_(The name of the binary will be changed in the released product.)_ -### The goal +See the last section for a concise summary of the entire process. -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. +### Goal -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 goal of this subsystem is to decrypt the master encryption key used to encrypt login information on the local +Windows system. This applies to the most recent versions of Chrome, Brave, and (untested) Edge that use the ABE/v20 +encryption scheme for some local profiles. -The next paragraphs describe what is done at each level to decrypt the key. +The general idea of this encryption scheme is as follows: -### 1. Client library +1. Chrome generates a unique random encryption key. +2. This key is first encrypted at the **user level** with a fixed key. +3. It is then encrypted at the **user level** again using the Windows **Data Protection API (DPAPI)**. +4. Finally, it is sent to a special service that encrypts it with DPAPI at the **system level**. -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 triply encrypted key is stored in the `Local State` file. -This function takes three arguments: +The following sections describe how the key is decrypted at each level. -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 +### 1. Client Library -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. +This is a Rust module that is part of the Chromium importer. It compiles and runs only on Windows (see `abe.rs` and +`abe_config.rs`). Its main task is to launch `bitwarden_chromium_import_helper.exe` with elevated privileges, presenting +the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `windows.rs`. -The name of the service executable and the data to be decrypted are passed via the command line to -`admin.exe` like this: +This function takes two arguments: + +1. Absolute path to `bitwarden_chromium_import_helper.exe` +2. Base64 string of the ABE key extracted from the browser's local state + +First, `bitwarden_chromium_import_helper.exe` is launched by calling a variant of `ShellExecute` with the `runas` verb. +This displays the UAC screen. If the user accepts, `bitwarden_chromium_import_helper.exe` starts with **ADMINISTRATOR** +privileges. + +> **The user must approve the UAC prompt or the process is aborted.** + +Because it is not possible to read the standard output of an application launched in this way, a named pipe server is +created at the user level before `bitwarden_chromium_import_helper.exe` is launched. This pipe is used to send the +decryption result from `bitwarden_chromium_import_helper.exe` back to the client. + +The data to be decrypted are passed via the command line to `bitwarden_chromium_import_helper.exe` like this: ```bat -admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." +bitwarden_chromium_import_helper.exe --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." ``` -**At this point, the user must permit the action to be performed on the UAC screen.** +### 2. Admin Executable -### 2. Admin executable +Although the process starts with **ADMINISTRATOR** privileges, its ultimate goal is to elevate to **SYSTEM**. To achieve +this, it uses a technique to impersonate a system-level process. -This executable receives the full path of `service.exe` and the data to be decrypted. +First, `bitwarden_chromium_import_helper.exe` ensures that the `SE_DEBUG_PRIVILEGE` privilege is enabled by calling +`RtlAdjustPrivilege`. This allows it to enumerate running system-level processes. -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). +Next, it finds an instance of `services.exe` or `winlogon.exe`, which are known to run at the **SYSTEM** level. Once a +system process is found, its token is duplicated by calling `DuplicateToken`. -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). +With the duplicated token, `ImpersonateLoggedOnUser` is called to impersonate a system-level process. -After that, the executable stops and uninstalls the service and then exits. +> **At this point `bitwarden_chromium_import_helper.exe` is running as SYSTEM.** -### 3. System service +The received encryption key can now be decrypted using DPAPI at the system level. -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. +The decrypted result is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to +the pipe and writes the result. -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 response can indicate success or failure: -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. +- On success: a Base64-encoded string. +- On failure: an error message prefixed with `!`. -### 4. Back to client library +In either case, the response is sent to the named pipe server created by the client. The client responds with `ok` +(ignored). -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. +Finally, `bitwarden_chromium_import_helper.exe` exits. -In the next step, the string is decrypted at the user level with the same Windows Data Protection -API. +### 3. Back to the Client Library -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`. +The decrypted Base64-encoded string is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at +the user level. At this point it has been decrypted only once—at the system level. -After all of these steps, we have the master key which can be used to decrypt the password -information stored in the local database. +Next, the string is decrypted at the **user level** with DPAPI. -### Summary +Finally, for Google Chrome (but not Brave), it is decrypted again with a hard-coded key found in `elevation_service.exe` +from the Chrome installation. Based on the version of the encrypted string (encoded within the string itself), this step +uses either **AES-256-GCM** or **ChaCha20-Poly1305**. See `windows.rs` for details. -The Windows ABE decryption process involves a three-tier architecture with named pipe communication: +After these steps, the master key is available and can be used to decrypt the password information stored in the +browser’s local database. -```mermaid -sequenceDiagram - participant Client as Client Library (User) - participant Admin as admin.exe (Administrator) - participant Service as service.exe (System) +### TL;DR Steps - Client->>Client: Create named pipe server - Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user +1. **Client side:** - Client->>Admin: Launch with UAC elevation - Note over Client,Admin: --service-exe c:\path\to\service.exe - Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE... + 1. Extract the encrypted key from Chrome’s settings. + 2. Create a named pipe server. + 3. Launch `bitwarden_chromium_import_helper.exe` with **ADMINISTRATOR** privileges, passing the key to be decrypted + via CLI arguments. + 4. Wait for the response from `bitwarden_chromium_import_helper.exe`. - Client->>Client: Wait for response +2. **Admin side:** - Admin->>Service: Install & start service - Note over Admin,Service: c:\path\to\service.exe + 1. Start. + 2. Ensure `SE_DEBUG_PRIVILEGE` is enabled (not strictly necessary in tests). + 3. Impersonate a system process such as `services.exe` or `winlogon.exe`. + 4. Decrypt the key using DPAPI at the **SYSTEM** level. + 5. Send the result or error back via the named pipe. + 6. Exit. - 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 -``` +3. **Back on the client side:** + 1. Receive the encryption key. + 2. Shutdown the pipe server. + 3. Decrypt it with DPAPI at the **USER** level. + 4. (For Chrome only) Decrypt again with the hard-coded key. + 5. Obtain the fully decrypted master key. + 6. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc. diff --git a/apps/desktop/desktop_native/chromium_importer/src/bin/bitwarden_chromium_import_helper.rs b/apps/desktop/desktop_native/chromium_importer/src/bin/bitwarden_chromium_import_helper.rs new file mode 100644 index 00000000000..5041028ed4e --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/bin/bitwarden_chromium_import_helper.rs @@ -0,0 +1,515 @@ +// Hide everything inside a platform specific module to avoid clippy errors on other platforms +#[cfg(target_os = "windows")] +mod windows_binary { + use anyhow::{anyhow, Result}; + use base64::{engine::general_purpose, Engine as _}; + use clap::Parser; + use scopeguard::defer; + use std::{ + ffi::OsString, + os::windows::{ffi::OsStringExt as _, io::AsRawHandle}, + path::{Path, PathBuf}, + ptr, + time::Duration, + }; + use sysinfo::System; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::windows::named_pipe::{ClientOptions, NamedPipeClient}, + time, + }; + use tracing::{debug, error, level_filters::LevelFilter}; + use tracing_subscriber::{ + fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _, + }; + use verifysign::CodeSignVerifier; + use windows::{ + core::BOOL, + Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE, + Win32::{ + Foundation::{ + CloseHandle, LocalFree, ERROR_PIPE_BUSY, HANDLE, HLOCAL, NTSTATUS, STATUS_SUCCESS, + }, + Security::{ + self, + Cryptography::{CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB}, + DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE, + TOKEN_QUERY, + }, + System::{ + Pipes::GetNamedPipeServerProcessId, + Threading::{ + OpenProcess, OpenProcessToken, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, + PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, + }, + }, + UI::Shell::IsUserAnAdmin, + }, + }; + + use chromium_importer::chromium::ADMIN_TO_USER_PIPE_NAME; + + #[derive(Parser)] + #[command(name = "bitwarden_chromium_import_helper")] + #[command(about = "Admin tool for ABE service management")] + struct Args { + /// Base64 encoded encrypted data to process + #[arg(long, help = "Base64 encoded encrypted data string")] + encrypted: String, + } + + // Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost. + // This is intended for development time only. All the logging is wrapped in `dbg_log!`` macro that compiles to + // no-op when logging is disabled. This is needed to avoid any sensitive data being logged in production. Normally + // all the logging code is present in the release build and could be enabled via RUST_LOG environment variable. + // We don't want that! + const ENABLE_DEVELOPER_LOGGING: bool = false; + const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; // This is an example filename, replace it with you own + + // This should be enabled for production + const ENABLE_SERVER_SIGNATURE_VALIDATION: bool = false; + const EXPECTED_SERVER_SIGNATURE_SHA256_THUMBPRINT: &str = + "9f6680c4720dbf66d1cb8ed6e328f58e42523badc60d138c7a04e63af14ea40d"; + + // List of SYSTEM process names to try to impersonate + const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"]; + + // Macro wrapper around debug! that compiles to no-op when ENABLE_DEVELOPER_LOGGING is false + macro_rules! dbg_log { + ($($arg:tt)*) => { + if ENABLE_DEVELOPER_LOGGING { + debug!($($arg)*); + } + }; + } + + async fn open_pipe_client(pipe_name: &'static str) -> Result { + let max_attempts = 5; + for _ in 0..max_attempts { + match ClientOptions::new().open(pipe_name) { + Ok(client) => { + dbg_log!("Successfully connected to the pipe!"); + return Ok(client); + } + Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => { + dbg_log!("Pipe is busy, retrying in 50ms..."); + } + Err(e) => { + dbg_log!("Failed to connect to pipe: {}", &e); + return Err(e.into()); + } + } + + time::sleep(Duration::from_millis(50)).await; + } + + Err(anyhow!( + "Failed to connect to pipe after {} attempts", + max_attempts + )) + } + + async fn send_message_with_client( + client: &mut NamedPipeClient, + message: &str, + ) -> Result { + client.write_all(message.as_bytes()).await?; + + // Try to receive a response for this message + let mut buffer = vec![0u8; 64 * 1024]; + match client.read(&mut buffer).await { + Ok(0) => Err(anyhow!( + "Server closed the connection (0 bytes read) on message" + )), + Ok(bytes_received) => { + let response = String::from_utf8_lossy(&buffer[..bytes_received]); + Ok(response.to_string()) + } + Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)), + } + } + + fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result { + let handle = HANDLE(client.as_raw_handle() as _); + let mut pid: u32 = 0; + unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?; + Ok(pid) + } + + fn resolve_process_executable_path(pid: u32) -> Result { + dbg_log!("Resolving process executable path for PID {}", pid); + + // Open the process handle + let hprocess = + unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid) }?; + dbg_log!("Opened process handle for PID {}", pid); + + // Close when no longer needed + defer! { + dbg_log!("Closing process handle for PID {}", pid); + unsafe { + _ = CloseHandle(hprocess); + } + }; + + let mut exe_name = vec![0u16; 32 * 1024]; + let mut exe_name_length = exe_name.len() as u32; + unsafe { + QueryFullProcessImageNameW( + hprocess, + PROCESS_NAME_WIN32, + windows::core::PWSTR(exe_name.as_mut_ptr()), + &mut exe_name_length, + ) + }?; + dbg_log!( + "QueryFullProcessImageNameW returned {} bytes", + exe_name_length + ); + + exe_name.truncate(exe_name_length as usize); + Ok(PathBuf::from(OsString::from_wide(&exe_name))) + } + + async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) { + _ = send_to_user(client, &format!("!{}", error_message)).await + } + + async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> { + let _ = send_message_with_client(client, message).await?; + Ok(()) + } + + fn is_admin() -> bool { + unsafe { IsUserAnAdmin().as_bool() } + } + + fn decrypt_data_base64(data_base64: &str, expect_appb: bool) -> Result { + dbg_log!("Decrypting data base64: {}", data_base64); + + let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| { + dbg_log!("Failed to decode base64: {} APPB: {}", e, expect_appb); + e + })?; + + let decrypted = decrypt_data(&data, expect_appb)?; + let decrypted_base64 = general_purpose::STANDARD.encode(decrypted); + + Ok(decrypted_base64) + } + + fn decrypt_data(data: &[u8], expect_appb: bool) -> Result> { + if expect_appb && !data.starts_with(b"APPB") { + dbg_log!("Decoded data does not start with 'APPB'"); + return Err(anyhow!("Decoded data does not start with 'APPB'")); + } + + let data = if expect_appb { &data[4..] } else { data }; + + let in_blob = CRYPT_INTEGER_BLOB { + cbData: data.len() as u32, + pbData: data.as_ptr() as *mut u8, + }; + + let mut out_blob = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: ptr::null_mut(), + }; + + let result = unsafe { + CryptUnprotectData( + &in_blob, + None, + None, + None, + None, + CRYPTPROTECT_UI_FORBIDDEN, + &mut out_blob, + ) + }; + + if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 { + let decrypted = unsafe { + std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec() + }; + + // Free the memory allocated by CryptUnprotectData + unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) }; + + Ok(decrypted) + } else { + dbg_log!("CryptUnprotectData failed"); + Err(anyhow!("CryptUnprotectData failed")) + } + } + + // + // Impersonate a SYSTEM process + // + + fn start_impersonating() -> Result { + // Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes + enable_debug_privilege()?; + + // Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several. + let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?; + + // Impersonate the SYSTEM process + unsafe { + ImpersonateLoggedOnUser(token)?; + }; + dbg_log!("Impersonating system process '{}' (PID: {})", name, pid); + + Ok(token) + } + + fn stop_impersonating(token: HANDLE) -> Result<()> { + unsafe { + RevertToSelf()?; + CloseHandle(token)?; + }; + Ok(()) + } + + fn find_system_process_with_token( + pids: Vec<(u32, &'static str)>, + ) -> Result<(HANDLE, u32, &'static str)> { + for (pid, name) in pids { + match get_system_token_from_pid(pid) { + Err(_) => { + dbg_log!( + "Failed to open process handle '{}' (PID: {}), skipping", + name, + pid + ); + continue; + } + Ok(system_handle) => { + return Ok((system_handle, pid, name)); + } + } + } + Err(anyhow!("Failed to get system token from any process")) + } + + fn get_system_token_from_pid(pid: u32) -> Result { + let handle = get_process_handle(pid)?; + let token = get_system_token(handle)?; + unsafe { + CloseHandle(handle)?; + }; + Ok(token) + } + + fn get_system_token(handle: HANDLE) -> Result { + let token_handle = unsafe { + let mut token_handle = HANDLE::default(); + OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?; + token_handle + }; + + let duplicate_token = unsafe { + let mut duplicate_token = HANDLE::default(); + DuplicateToken( + token_handle, + Security::SECURITY_IMPERSONATION_LEVEL(2), + &mut duplicate_token, + )?; + CloseHandle(token_handle)?; + duplicate_token + }; + + Ok(duplicate_token) + } + + fn get_system_pid_list() -> Vec<(u32, &'static str)> { + let sys = System::new_all(); + SYSTEM_PROCESS_NAMES + .iter() + .flat_map(|&name| { + sys.processes_by_exact_name(name.as_ref()) + .map(move |process| (process.pid().as_u32(), name)) + }) + .collect() + } + + fn get_process_handle(pid: u32) -> Result { + let hprocess = + unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid) }?; + Ok(hprocess) + } + + #[link(name = "ntdll")] + unsafe extern "system" { + unsafe fn RtlAdjustPrivilege( + privilege: i32, + enable: BOOL, + current_thread: BOOL, + previous_value: *mut BOOL, + ) -> NTSTATUS; + } + + fn enable_debug_privilege() -> Result<()> { + let mut previous_value = BOOL(0); + let status = unsafe { + dbg_log!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege"); + RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value) + }; + + match status { + STATUS_SUCCESS => { + dbg_log!( + "SE_DEBUG_PRIVILEGE set to 1, was {} before", + previous_value.as_bool() + ); + Ok(()) + } + _ => { + dbg_log!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0); + Err(anyhow!("Failed to adjust privilege")) + } + } + } + + // + // Pipe + // + + async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result { + let client = open_pipe_client(pipe_name).await?; + + if ENABLE_SERVER_SIGNATURE_VALIDATION { + let server_pid = get_named_pipe_server_pid(&client)?; + dbg_log!("Connected to pipe server PID {}", server_pid); + + // Validate the server end process signature + let exe_path = resolve_process_executable_path(server_pid)?; + + dbg_log!("Pipe server executable path: {}", exe_path.display()); + + let verifier = CodeSignVerifier::for_file(exe_path.as_path()).map_err(|e| { + anyhow!("verifysign init failed for {}: {:?}", exe_path.display(), e) + })?; + + let signature = verifier.verify().map_err(|e| { + anyhow!( + "verifysign verify failed for {}: {:?}", + exe_path.display(), + e + ) + })?; + + dbg_log!("Pipe server executable path: {}", exe_path.display()); + + // Dump signature fields for debugging/inspection + dbg_log!("Signature fields:"); + dbg_log!(" Subject Name: {:?}", signature.subject_name()); + dbg_log!(" Issuer Name: {:?}", signature.issuer_name()); + dbg_log!(" SHA1 Thumbprint: {:?}", signature.sha1_thumbprint()); + dbg_log!(" SHA256 Thumbprint: {:?}", signature.sha256_thumbprint()); + dbg_log!(" Serial Number: {:?}", signature.serial()); + + if signature.sha256_thumbprint() != EXPECTED_SERVER_SIGNATURE_SHA256_THUMBPRINT { + return Err(anyhow!("Pipe server signature is not valid")); + } + + dbg_log!("Pipe server signature verified for PID {}", server_pid); + } + + Ok(client) + } + + fn run() -> Result { + dbg_log!("Starting bitwarden_chromium_import_helper.exe"); + + let args = Args::try_parse()?; + + if !is_admin() { + return Err(anyhow!("Expected to run with admin privileges")); + } + + dbg_log!("Running as ADMINISTRATOR"); + + // Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine + let system_decrypted_base64 = { + let system_token = start_impersonating()?; + defer! { + dbg_log!("Stopping impersonation"); + _ = stop_impersonating(system_token); + } + let system_decrypted_base64 = decrypt_data_base64(&args.encrypted, true)?; + dbg_log!("Decrypted data with system"); + system_decrypted_base64 + }; + + // This is just to check that we're decrypting Chrome keys and not something else sent to us by a malicious actor. + // Now that we're back from SYSTEM, we need to decrypt one more time just to verify. + // Chrome keys are double encrypted: once at SYSTEM level and once at USER level. + // When the decryption fails, it means that we're decrypting something unexpected. + // We don't send this result back since the library will decrypt again at USER level. + + _ = decrypt_data_base64(&system_decrypted_base64, false).map_err(|e| { + dbg_log!("User level decryption check failed: {}", e); + e + })?; + + dbg_log!("User level decryption check passed"); + + Ok(system_decrypted_base64) + } + + fn init_logging(log_path: &Path, file_level: LevelFilter) { + // We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW. + match std::fs::File::create(log_path) { + Ok(file) => { + let file_filter = EnvFilter::builder() + .with_default_directive(file_level.into()) + .from_env_lossy(); + + let file_layer = fmt::layer() + .with_writer(file) + .with_ansi(false) + .with_filter(file_filter); + + tracing_subscriber::registry().with(file_layer).init(); + } + Err(error) => { + error!(%error, ?log_path, "Could not create log file."); + } + } + } + + pub async fn main() { + if ENABLE_DEVELOPER_LOGGING { + init_logging(LOG_FILENAME.as_ref(), LevelFilter::DEBUG); + } + + let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await { + Ok(client) => client, + Err(e) => { + error!( + "Failed to open pipe {} to send result/error: {}", + ADMIN_TO_USER_PIPE_NAME, e + ); + return; + } + }; + + match run() { + Ok(system_decrypted_base64) => { + dbg_log!("Sending response back to user"); + let _ = send_to_user(&mut client, &system_decrypted_base64).await; + } + Err(e) => { + dbg_log!("Error: {}", e); + send_error_to_user(&mut client, &format!("{}", e)).await; + } + } + } +} + +#[tokio::main] +async fn main() { + #[cfg(target_os = "windows")] + windows_binary::main().await; +} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs index 55728460436..0873c66d053 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs @@ -3,12 +3,15 @@ use std::sync::LazyLock; use anyhow::{anyhow, Result}; use async_trait::async_trait; +use dirs; use hex::decode; -use homedir::my_home; use rusqlite::{params, Connection}; mod platform; +#[cfg(target_os = "windows")] +pub use platform::ADMIN_TO_USER_PIPE_NAME; + pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; // @@ -52,7 +55,6 @@ pub trait InstalledBrowserRetriever { pub struct DefaultInstalledBrowserRetriever {} impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever { - // TODO: Make thus async fn get_installed_browsers() -> Result> { let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len()); @@ -67,7 +69,6 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever { } } -// TODO: Make thus async pub fn get_available_profiles(browser_name: &String) -> Result> { let (_, local_state) = load_local_state_for_browser(browser_name)?; Ok(get_profile_info(&local_state)) @@ -123,8 +124,7 @@ pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock< }); fn get_browser_data_dir(config: &BrowserConfig) -> Result { - let dir = my_home() - .map_err(|_| anyhow!("Home directory not found"))? + let dir = dirs::home_dir() .ok_or_else(|| anyhow!("Home directory not found"))? .join(config.data_dir); Ok(dir) diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs index 2a21ef23d82..fe497de0773 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs @@ -1,7 +1,9 @@ // Platform-specific code #[cfg_attr(target_os = "linux", path = "linux.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "windows", path = "windows/mod.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] mod native; -pub(crate) use native::*; +// Windows exposes public const +#[allow(unused_imports)] +pub use native::*; diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs deleted file mode 100644 index 79c462c29a1..00000000000 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs +++ /dev/null @@ -1,204 +0,0 @@ -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}; - -use crate::util; - -// -// Public API -// - -pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ - BrowserConfig { - name: "Brave", - data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", - }, - 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: "Opera", - data_dir: "AppData/Roaming/Opera Software/Opera Stable", - }, - BrowserConfig { - name: "Vivaldi", - data_dir: "AppData/Local/Vivaldi/User Data", - }, -]; - -pub(crate) fn get_crypto_service( - _browser_name: &str, - local_state: &LocalState, -) -> Result> { - Ok(Box::new(WindowsCryptoService::new(local_state))) -} - -// -// CryptoService -// -struct WindowsCryptoService { - master_key: Option>, - encrypted_key: Option, -} - -impl WindowsCryptoService { - pub(crate) fn new(local_state: &LocalState) -> Self { - Self { - master_key: None, - encrypted_key: local_state - .os_crypt - .as_ref() - .and_then(|c| c.encrypted_key.clone()), - } - } -} - -#[async_trait] -impl CryptoService for WindowsCryptoService { - async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { - if encrypted.is_empty() { - return Ok(String::new()); - } - - // On Windows only v10 and v20 are supported at the moment - let (version, no_prefix) = - util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; - - // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag] - const IV_SIZE: usize = 12; - const TAG_SIZE: usize = 16; - const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; - - if no_prefix.len() < MIN_LENGTH { - return Err(anyhow!( - "Corrupted entry: expected at least {} bytes, got {} bytes", - MIN_LENGTH, - no_prefix.len() - )); - } - - // Allow empty passwords - if no_prefix.len() == MIN_LENGTH { - return Ok(String::new()); - } - - if self.master_key.is_none() { - self.master_key = Some(self.get_master_key(version)?); - } - - let key = self - .master_key - .as_ref() - .ok_or_else(|| anyhow!("Failed to retrieve key"))?; - let key = Key::::from_slice(key); - let cipher = Aes256Gcm::new(key); - let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); - - let decrypted_bytes = cipher - .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) - .map_err(|e| anyhow!("Decryption failed: {}", e))?; - - let plaintext = String::from_utf8(decrypted_bytes) - .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; - - Ok(plaintext) - } -} - -impl WindowsCryptoService { - fn get_master_key(&mut self, version: &str) -> Result> { - match version { - "v10" => self.get_master_key_v10(), - _ => Err(anyhow!("Unsupported version: {}", version)), - } - } - - fn get_master_key_v10(&mut self) -> Result> { - if self.encrypted_key.is_none() { - return Err(anyhow!( - "Encrypted master key is not found in the local browser state" - )); - } - - let key = self - .encrypted_key - .as_ref() - .ok_or_else(|| anyhow!("Failed to retrieve key"))?; - let key_bytes = BASE64_STANDARD - .decode(key) - .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; - - if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { - return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); - } - - let key = unprotect_data_win(&key_bytes[5..]) - .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; - - Ok(key) - } -} - -fn unprotect_data_win(data: &[u8]) -> Result> { - if data.is_empty() { - return Ok(Vec::new()); - } - - let mut data_in = DATA_BLOB { - cbData: data.len() as DWORD, - pbData: data.as_ptr() as *mut BYTE, - }; - - let mut data_out = DATA_BLOB { - cbData: 0, - pbData: std::ptr::null_mut(), - }; - - let result: BOOL = unsafe { - // BOOL from winapi (i32) - CryptUnprotectData( - &mut data_in, - std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16) - std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB - std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void) - std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT - 0, // dwFlags: DWORD - &mut data_out, - ) - }; - - if result == 0 { - return Err(anyhow!("CryptUnprotectData failed")); - } - - if data_out.pbData.is_null() || data_out.cbData == 0 { - return Ok(Vec::new()); - } - - let output_slice = - unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) }; - - unsafe { - if !data_out.pbData.is_null() { - LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void))); - } - } - - Ok(output_slice.to_vec()) -} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs new file mode 100644 index 00000000000..943727690f2 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs @@ -0,0 +1,178 @@ +use super::abe_config; +use anyhow::{anyhow, Result}; +use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; +use tokio::{ + io::{self, AsyncReadExt, AsyncWriteExt}, + net::windows::named_pipe::{NamedPipeServer, ServerOptions}, + sync::mpsc::channel, + task::JoinHandle, + time::{timeout, Duration}, +}; +use tracing::debug; +use windows::{ + core::PCWSTR, + Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_HIDE}, +}; + +const WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS: u64 = 30; + +fn start_tokio_named_pipe_server( + pipe_name: &'static str, + process_message: F, +) -> Result>> +where + F: Fn(&str) -> String + Send + Sync + Clone + 'static, +{ + debug!("Starting Tokio named pipe server on: {}", pipe_name); + + // The first server needs to be constructed early so that clients can be correctly + // connected. Otherwise calling .wait will cause the client to error. + // Here we also make use of `first_pipe_instance`, which will ensure that + // there are no other servers up and running already. + let mut server = ServerOptions::new() + .first_pipe_instance(true) + .create(pipe_name)?; + + debug!("Named pipe server created and listening..."); + + // Spawn the server loop. + let server_task = tokio::spawn(async move { + loop { + // Wait for a client to connect. + match server.connect().await { + Ok(_) => { + debug!("Client connected to named pipe"); + let connected_client = server; + + // Construct the next server to be connected before sending the one + // we already have off to a task. This ensures that the server + // isn't closed (after it's done in the task) before a new one is + // available. Otherwise the client might error with + // `io::ErrorKind::NotFound`. + server = ServerOptions::new().create(pipe_name)?; + + // Handle the connected client in a separate task + let process_message_clone = process_message.clone(); + let _client_task = tokio::spawn(async move { + if let Err(e) = handle_client(connected_client, process_message_clone).await + { + debug!("Error handling client: {}", e); + } + }); + } + Err(e) => { + debug!("Failed to connect to client: {}", e); + continue; + } + } + } + }); + + Ok(server_task) +} + +async fn handle_client(mut client: NamedPipeServer, process_message: F) -> Result<()> +where + F: Fn(&str) -> String, +{ + debug!("Handling new client connection"); + + loop { + // Read a message from the client + let mut buffer = vec![0u8; 64 * 1024]; + match client.read(&mut buffer).await { + Ok(0) => { + debug!("Client disconnected (0 bytes read)"); + return Ok(()); + } + Ok(bytes_read) => { + let message = String::from_utf8_lossy(&buffer[..bytes_read]); + let preview = message.chars().take(16).collect::(); + debug!( + "Received from client: '{}...' ({} bytes)", + preview, bytes_read, + ); + + let response = process_message(&message); + match client.write_all(response.as_bytes()).await { + Ok(_) => { + debug!("Sent response to client ({} bytes)", response.len()); + } + Err(e) => { + return Err(anyhow!("Failed to send response to client: {}", e)); + } + } + } + Err(e) => { + return Err(anyhow!("Failed to read from client: {}", e)); + } + } + } +} + +pub(crate) async fn decrypt_with_admin_exe(admin_exe: &str, encrypted: &str) -> Result { + let (tx, mut rx) = channel::(1); + + debug!( + "Starting named pipe server at '{}'...", + abe_config::ADMIN_TO_USER_PIPE_NAME + ); + + let server = match start_tokio_named_pipe_server( + abe_config::ADMIN_TO_USER_PIPE_NAME, + move |message: &str| { + let _ = tx.try_send(message.to_string()); + "ok".to_string() + }, + ) { + Ok(server) => server, + Err(e) => return Err(anyhow!("Failed to start named pipe server: {}", e)), + }; + + debug!("Launching '{}' as ADMINISTRATOR...", admin_exe); + decrypt_with_admin_exe_internal(admin_exe, encrypted); + + debug!("Waiting for message from {}...", admin_exe); + let message = match timeout( + Duration::from_secs(WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS), + rx.recv(), + ) + .await + { + Ok(Some(msg)) => msg, + Ok(None) => return Err(anyhow!("Channel closed without message from {}", admin_exe)), + Err(_) => return Err(anyhow!("Timeout waiting for message from {}", admin_exe)), + }; + + debug!("Shutting down the pipe server..."); + server.abort(); + + Ok(message) +} + +fn decrypt_with_admin_exe_internal(admin_exe: &str, encrypted: &str) { + // Convert strings to wide strings for Windows API + let exe_wide = OsStr::new(admin_exe) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let runas_wide = OsStr::new("runas") + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let parameters = OsStr::new(&format!(r#"--encrypted "{}""#, encrypted)) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + unsafe { + ShellExecuteW( + None, + PCWSTR(runas_wide.as_ptr()), + PCWSTR(exe_wide.as_ptr()), + PCWSTR(parameters.as_ptr()), + None, + SW_HIDE, + ); + } +} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs new file mode 100644 index 00000000000..66b1d3b8435 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs @@ -0,0 +1,2 @@ +pub const ADMIN_TO_USER_PIPE_NAME: &str = + r"\\.\pipe\bitwarden-to-bitwarden-chromium-importer-helper"; diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs new file mode 100644 index 00000000000..e47e9bf08d2 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs @@ -0,0 +1,424 @@ +use aes_gcm::{aead::Aead, 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 chacha20poly1305::ChaCha20Poly1305; +use std::path::{Path, PathBuf}; +use windows::Win32::{ + Foundation::{LocalFree, HLOCAL}, + Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB}, +}; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; +use crate::util; +mod abe; +mod abe_config; + +pub use abe_config::ADMIN_TO_USER_PIPE_NAME; + +// +// Public API +// + +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ + BrowserConfig { + name: "Brave", + data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", + }, + 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: "Opera", + data_dir: "AppData/Roaming/Opera Software/Opera Stable", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "AppData/Local/Vivaldi/User Data", + }, +]; + +pub(crate) fn get_crypto_service( + _browser_name: &str, + local_state: &LocalState, +) -> Result> { + Ok(Box::new(WindowsCryptoService::new(local_state))) +} + +// +// Private +// + +const ADMIN_EXE_FILENAME: &str = "bitwarden_chromium_import_helper.exe"; + +// +// CryptoService +// +struct WindowsCryptoService { + master_key: Option>, + encrypted_key: Option, + app_bound_encrypted_key: Option, +} + +impl WindowsCryptoService { + pub(crate) fn new(local_state: &LocalState) -> Self { + Self { + master_key: None, + encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.encrypted_key.clone()), + app_bound_encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.app_bound_encrypted_key.clone()), + } + } +} + +#[async_trait] +impl CryptoService for WindowsCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On Windows only v10 and v20 are supported at the moment + let (version, no_prefix) = + util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; + + // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag] + const IV_SIZE: usize = 12; + const TAG_SIZE: usize = 16; + const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; + + if no_prefix.len() < MIN_LENGTH { + return Err(anyhow!( + "Corrupted entry: expected at least {} bytes, got {} bytes", + MIN_LENGTH, + no_prefix.len() + )); + } + + // Allow empty passwords + if no_prefix.len() == MIN_LENGTH { + return Ok(String::new()); + } + + if self.master_key.is_none() { + self.master_key = Some(self.get_master_key(version).await?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); + + let decrypted_bytes = cipher + .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) + .map_err(|e| anyhow!("Decryption failed: {}", e))?; + + let plaintext = String::from_utf8(decrypted_bytes) + .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +impl WindowsCryptoService { + async fn get_master_key(&mut self, version: &str) -> Result> { + match version { + "v10" => self.get_master_key_v10(), + "v20" => self.get_master_key_v20().await, + _ => Err(anyhow!("Unsupported version: {}", version)), + } + } + + fn get_master_key_v10(&mut self) -> Result> { + if self.encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let key = self + .encrypted_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key_bytes = BASE64_STANDARD + .decode(key) + .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; + + if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { + return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); + } + + let key = unprotect_data_win(&key_bytes[5..]) + .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; + + Ok(key) + } + + async fn get_master_key_v20(&mut self) -> Result> { + if self.app_bound_encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let admin_exe_path = get_admin_exe_path()?; + let admin_exe_str = admin_exe_path + .to_str() + .ok_or_else(|| anyhow!("Failed to convert {} path to string", ADMIN_EXE_FILENAME))?; + + let key_base64 = abe::decrypt_with_admin_exe( + admin_exe_str, + self.app_bound_encrypted_key + .as_ref() + .expect("app_bound_encrypted_key should not be None"), + ) + .await?; + + if let Some(error_message) = key_base64.strip_prefix('!') { + return Err(anyhow!( + "Failed to decrypt the master key: {}", + error_message + )); + } + + let key_bytes = BASE64_STANDARD.decode(&key_base64)?; + let key = unprotect_data_win(&key_bytes)?; + + Self::decode_abe_key_blob(key.as_slice()) + } + + fn decode_abe_key_blob(blob_data: &[u8]) -> Result> { + let header_len = u32::from_le_bytes(blob_data[0..4].try_into()?) as usize; + // Ignore the header + + let content_len_offset = 4 + header_len; + let content_len = + u32::from_le_bytes(blob_data[content_len_offset..content_len_offset + 4].try_into()?) + as usize; + + if content_len < 1 { + return Err(anyhow!( + "Corrupted ABE key blob: content length is less than 1" + )); + } + + let content_offset = content_len_offset + 4; + let content = &blob_data[content_offset..content_offset + content_len]; + + // When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge + if content_len == 32 { + return Ok(content.to_vec()); + } + + let version = content[0]; + let key_blob = &content[1..]; + match version { + // Google Chrome v1 key encrypted with a hardcoded AES key + 1_u8 => Self::decrypt_abe_key_blob_chrome_aes(key_blob), + // Google Chrome v2 key encrypted with a hardcoded ChaCha20 key + 2_u8 => Self::decrypt_abe_key_blob_chrome_chacha20(key_blob), + // Google Chrome v3 key encrypted with CNG APIs + 3_u8 => Self::decrypt_abe_key_blob_chrome_cng(key_blob), + v => Err(anyhow!("Unsupported ABE key blob version: {}", v)), + } + } + + // TODO: DRY up with decrypt_abe_key_blob_chrome_chacha20 + fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result> { + if blob.len() < 60 { + return Err(anyhow!( + "Corrupted ABE key blob: expected at least 60 bytes, got {} bytes", + blob.len() + )); + } + + let iv: [u8; 12] = blob[0..12].try_into()?; + let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?; + + const GOOGLE_AES_KEY: &[u8] = &[ + 0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93, + 0x66, 0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D, + 0xA0, 0x28, 0x47, 0x87, + ]; + let aes_key = Key::::from_slice(GOOGLE_AES_KEY); + let cipher = Aes256Gcm::new(aes_key); + + let decrypted = cipher + .decrypt((&iv).into(), ciphertext.as_ref()) + .map_err(|e| anyhow!("Failed to decrypt v20 key with Google AES key: {}", e))?; + + Ok(decrypted) + } + + fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result> { + if blob.len() < 60 { + return Err(anyhow!( + "Corrupted ABE key blob: expected at least 60 bytes, got {} bytes", + blob.len() + )); + } + + let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY); + let cipher = ChaCha20Poly1305::new(chacha20_key); + + const GOOGLE_CHACHA20_KEY: &[u8] = &[ + 0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25, + 0x80, 0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D, + 0x08, 0x72, 0x96, 0x60, + ]; + + let iv: [u8; 12] = blob[0..12].try_into()?; + let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?; + + let decrypted = cipher + .decrypt((&iv).into(), ciphertext.as_ref()) + .map_err(|e| anyhow!("Failed to decrypt v20 key with Google ChaCha20 key: {}", e))?; + + Ok(decrypted) + } + + fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result> { + if blob.len() < 92 { + return Err(anyhow!( + "Corrupted ABE key blob: expected at least 92 bytes, got {} bytes", + blob.len() + )); + } + + let _encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?; + let _iv: [u8; 12] = blob[32..32 + 12].try_into()?; + let _ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?; + + // TODO: Decrypt the AES key using CNG APIs + // TODO: Implement this in the future once we run into a browser that uses this scheme + + // There's no way to test this at the moment. This encryption scheme is not used in any of the browsers I've tested. + Err(anyhow!("Google ABE CNG flavor is not supported yet")) + } +} + +fn unprotect_data_win(data: &[u8]) -> Result> { + if data.is_empty() { + return Ok(Vec::new()); + } + + let data_in = CRYPT_INTEGER_BLOB { + cbData: data.len() as u32, + pbData: data.as_ptr() as *mut u8, + }; + + let mut data_out = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + + let result = unsafe { + CryptUnprotectData( + &data_in, + None, // ppszDataDescr: Option<*mut PWSTR> + None, // pOptionalEntropy: Option<*const CRYPT_INTEGER_BLOB> + None, // pvReserved: Option<*const std::ffi::c_void> + None, // pPromptStruct: Option<*const CRYPTPROTECT_PROMPTSTRUCT> + 0, // dwFlags: u32 + &mut data_out, + ) + }; + + if result.is_err() { + 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()) +} + +fn get_admin_exe_path() -> Result { + let current_exe_full_path = std::env::current_exe() + .map_err(|e| anyhow!("Failed to get current executable path: {}", e))?; + + let exe_name = current_exe_full_path + .file_name() + .ok_or_else(|| anyhow!("Failed to get file name from current executable path"))?; + + let admin_exe_full_path = if exe_name.eq_ignore_ascii_case("electron.exe") { + get_debug_admin_exe_path()? + } else { + get_dist_admin_exe_path(¤t_exe_full_path)? + }; + + // check if bitwarden_chromium_import_helper.exe exists + if !admin_exe_full_path.exists() { + return Err(anyhow!( + "{} not found at path: {:?}", + ADMIN_EXE_FILENAME, + admin_exe_full_path + )); + } + + Ok(admin_exe_full_path) +} + +fn get_dist_admin_exe_path(current_exe_full_path: &Path) -> Result { + let admin_exe = current_exe_full_path + .parent() + .map(|p| p.join(ADMIN_EXE_FILENAME)) + .ok_or_else(|| anyhow!("Failed to get parent directory of current executable"))?; + + Ok(admin_exe) +} + +// Try to find bitwarden_chromium_import_helper.exe in debug build folders. This might not cover all the cases. +// Tested on `npm run electron` from apps/desktop and apps/desktop/desktop_native. +fn get_debug_admin_exe_path() -> Result { + let current_dir = std::env::current_dir()?; + let folder_name = current_dir + .file_name() + .ok_or_else(|| anyhow!("Failed to get folder name from current directory"))?; + match folder_name.to_str() { + Some("desktop") => Ok(get_target_admin_exe_path( + current_dir.join("desktop_native"), + )), + Some("desktop_native") => Ok(get_target_admin_exe_path(current_dir)), + _ => Err(anyhow!( + "Cannot determine {} path from current directory: {}", + ADMIN_EXE_FILENAME, + current_dir.display() + )), + } +} + +fn get_target_admin_exe_path(desktop_native_dir: PathBuf) -> PathBuf { + desktop_native_dir + .join("target") + .join("debug") + .join(ADMIN_EXE_FILENAME) +} diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 5b792097623..630a956560d 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -36,6 +36,10 @@ { "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", "to": "desktop_proxy.exe" + }, + { + "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "to": "bitwarden_chromium_import_helper.exe" } ] }, diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index f7dcfb65044..0b18786863d 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -96,6 +96,10 @@ { "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", "to": "desktop_proxy.exe" + }, + { + "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "to": "bitwarden_chromium_import_helper.exe" } ] },