1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 19:23:52 +00:00

[PM-27341] Chrome importer refactors (#16720)

Various refactors to the chrome importer
This commit is contained in:
Oscar Hinton
2025-10-27 17:24:50 +01:00
committed by GitHub
parent bd89c0ce6d
commit 42377a1533
20 changed files with 161 additions and 185 deletions

View File

@@ -0,0 +1,36 @@
[package]
name = "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 }
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"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
tokio = { workspace = true, features = ["full"] }
winapi = { version = "=0.3.9", features = ["dpapi", "memoryapi"] }
windows = { workspace = true, features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Services", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(target_os = "linux")'.dependencies]
oo7 = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,163 @@
# Chromium Direct Importer
A rust library that allows you to directly import credentials from Chromium-based browsers.
## Windows ABE Architecture
On Windows chrome has additional protection measurements which needs to be circumvented in order to
get access to the passwords.
### Overview
The Windows Application Bound Encryption (ABE) consists of three main components that work together:
- **client library** -- Library that is part of the desktop client application
- **admin.exe** -- Service launcher running as ADMINISTRATOR
- **service.exe** -- Background Windows service running as SYSTEM
_(The names of the binaries will be changed for the released product.)_
### The goal
The goal of this subsystem is to decrypt the master encryption key with which the login information
is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and
Edge (untested yet) that are using the ABE/v20 encryption scheme for some of the local profiles.
The general idea of this encryption scheme is that Chrome generates a unique random encryption key,
then encrypts it at the user level with a fixed key. It then sends it to the Windows Data Protection
API at the user level, and then, using an installed service, encrypts it with the Windows Data
Protection API at the system level on top of that. This triply encrypted key is later stored in the
`Local State` file.
The next paragraphs describe what is done at each level to decrypt the key.
### 1. Client library
This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows
(see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges
by presenting the user with the UAC screen. See the `abe::decrypt_with_admin_and_service` invocation
in `windows.rs`.
This function takes three arguments:
1. Absolute path to `admin.exe`
2. Absolute path to `service.exe`
3. Base64 string of the ABE key extracted from the browser's local state
It's not possible to install the service from the user-level executable. So first, we have to
elevate the privileges and run `admin.exe` as ADMINISTRATOR. This is done by calling `ShellExecute`
with the `runas` verb. Since it's not trivial to read the standard output from an application
launched in this way, a named pipe server is created at the user level, which waits for the response
from `admin.exe` after it has been launched.
The name of the service executable and the data to be decrypted are passed via the command line to
`admin.exe` like this:
```bat
admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..."
```
**At this point, the user must permit the action to be performed on the UAC screen.**
### 2. Admin executable
This executable receives the full path of `service.exe` and the data to be decrypted.
First, it installs the service to run as SYSTEM and waits for it to start running. The service
creates a named pipe server that the admin-level executable communicates with (see the `service.exe`
description further down).
It sends the base64 string to the pipe server in a raw message and waits for the answer. The answer
could be a success or a failure. In case of success, it's a base64 string decrypted at the system
level. In case of failure, it's an error message prefixed with an `!`. In either case, the response
is sent to the named pipe server created by the user. The user responds with `ok` (ignored).
After that, the executable stops and uninstalls the service and then exits.
### 3. System service
The service starts and creates a named pipe server for communication between `admin.exe` and the
system service. Please note that it is not possible to communicate between the user and the system
service directly via a named pipe. Thus, this three-layered approach is necessary.
Once the service is started, it waits for the incoming message via the named pipe. The expected
message is a base64 string to be decrypted. The data is decrypted via the Windows Data Protection
API `CryptUnprotectData` and sent back in response to this incoming message in base64 encoding. In
case of an error, the error message is sent back prefixed with an `!`.
The service keeps running and servicing more requests if there are any, until it's stopped and
removed from the system. Even though we send only one request, the service is designed to handle as
many clients with as many messages as needed and could be installed on the system permanently if
necessary.
### 4. Back to client library
The decrypted base64-encoded string comes back from the admin executable to the named pipe server at
the user level. At this point, it has been decrypted only once at the system level.
In the next step, the string is decrypted at the user level with the same Windows Data Protection
API.
And as the third step, it's decrypted with a hard-coded key found in the `elevation_service.exe`
from the Chrome installation. Based on the version of the encrypted string (encoded in the string
itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The details can be found in
`windows.rs`.
After all of these steps, we have the master key which can be used to decrypt the password
information stored in the local database.
### Summary
The Windows ABE decryption process involves a three-tier architecture with named pipe communication:
```mermaid
sequenceDiagram
participant Client as Client Library (User)
participant Admin as admin.exe (Administrator)
participant Service as service.exe (System)
Client->>Client: Create named pipe server
Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user
Client->>Admin: Launch with UAC elevation
Note over Client,Admin: --service-exe c:\path\to\service.exe
Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE...
Client->>Client: Wait for response
Admin->>Service: Install & start service
Note over Admin,Service: c:\path\to\service.exe
Service->>Service: Create named pipe server
Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin
Service->>Service: Wait for message
Admin->>Service: Send encrypted data via admin-service pipe
Note over Admin,Service: QVBQQgEAAADQjJ3fARXRE...
Admin->>Admin: Wait for response
Service->>Service: Decrypt with system-level DPAPI
Service->>Admin: Return decrypted data via admin-service pipe
Note over Service,Admin: EjRWeXN0ZW0gU2VydmljZQ...
Admin->>Client: Send result via named user-admin pipe
Note over Client,Admin: EjRWeXN0ZW0gU2VydmljZQ...
Client->>Admin: Send ACK to admin
Note over Client,Admin: ok
Admin->>Service: Stop & uninstall service
Service-->>Admin: Exit
Admin-->>Client: Exit
Client->>Client: Decrypt with user-level DPAPI
Client->>Client: Decrypt with hardcoded key
Note over Client: AES-256-GCM or ChaCha20Poly1305
Client->>Client: Done
```

View File

@@ -0,0 +1,350 @@
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use hex::decode;
use homedir::my_home;
use rusqlite::{params, Connection};
mod platform;
pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS;
//
// Public API
//
#[derive(Debug)]
pub struct ProfileInfo {
pub name: String,
pub folder: String,
pub account_name: Option<String>,
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),
}
pub trait InstalledBrowserRetriever {
fn get_installed_browsers() -> Result<Vec<String>>;
}
pub struct DefaultInstalledBrowserRetriever {}
impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
// TODO: Make thus async
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, Clone, Copy)]
pub(crate) struct BrowserConfig {
pub name: &'static str,
pub data_dir: &'static str,
}
pub(crate) 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]
pub(crate) trait CryptoService: Send {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String>;
}
#[derive(serde::Deserialize, Clone)]
pub(crate) 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> {
local_state
.profile
.info_cache
.iter()
.map(|(name, info)| ProfileInfo {
name: info.name.clone(),
folder: name.clone(),
account_name: info.gaia_name.clone(),
account_email: info.user_name.clone(),
})
.collect()
}
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 table_exist(conn: &Connection, table_name: &str) -> Result<bool, rusqlite::Error> {
conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?
.exists(params![table_name])
}
fn query_logins(db_path: &str) -> Result<Vec<EncryptedLogin>, rusqlite::Error> {
let conn = Connection::open(db_path)?;
let have_logins = table_exist(&conn, "logins")?;
let have_password_notes = 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 logins = logins_iter.collect::<Result<Vec<_>, _>>()?;
Ok(logins)
}
async fn decrypt_logins(
encrypted_logins: Vec<EncryptedLogin>,
crypto_service: &mut Box<dyn CryptoService>,
) -> Vec<LoginImportResult> {
let mut results = Vec::with_capacity(encrypted_logins.len());
for encrypted_login in encrypted_logins {
let result = decrypt_login(encrypted_login, crypto_service).await;
results.push(result);
}
results
}
async fn decrypt_login(
encrypted_login: EncryptedLogin,
crypto_service: &mut Box<dyn CryptoService>,
) -> LoginImportResult {
let maybe_password = crypto_service
.decrypt_to_string(&encrypted_login.encrypted_password)
.await;
match maybe_password {
Ok(password) => {
let note = crypto_service
.decrypt_to_string(&encrypted_login.encrypted_note)
.await
.unwrap_or_default();
LoginImportResult::Success(Login {
url: encrypted_login.url,
username: encrypted_login.username,
password,
note,
})
}
Err(e) => LoginImportResult::Failure(LoginImportFailure {
url: encrypted_login.url,
username: encrypted_login.username,
error: e.to_string(),
}),
}
}

View File

@@ -0,0 +1,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};
use crate::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(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
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(crate) fn get_crypto_service(
browser_name: &String,
_local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {
let config = KEYRING_CONFIG
.iter()
.find(|b| b.browser == browser_name)
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
let service = LinuxCryptoService::new(config);
Ok(Box::new(service))
}
//
// Private
//
#[derive(Debug)]
struct KeyringConfig {
browser: &'static str,
application_id: &'static str,
}
const KEYRING_CONFIG: [KeyringConfig; SUPPORTED_BROWSERS.len()] = [
KeyringConfig {
browser: "Chrome",
application_id: "chrome",
},
KeyringConfig {
browser: "Chromium",
application_id: "chromium",
},
KeyringConfig {
browser: "Brave",
application_id: "brave",
},
KeyringConfig {
browser: "Opera",
application_id: "opera",
},
];
const IV: [u8; 16] = [0x20; 16];
const V10_KEY: [u8; 16] = [
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78,
];
struct LinuxCryptoService {
config: &'static KeyringConfig,
v11_key: Option<Vec<u8>>,
}
impl LinuxCryptoService {
fn new(config: &'static KeyringConfig) -> Self {
Self {
config,
v11_key: None,
}
}
fn decrypt_v10(&self, encrypted: &[u8]) -> Result<String> {
decrypt(&V10_KEY, encrypted)
}
async fn decrypt_v11(&mut self, encrypted: &[u8]) -> Result<String> {
if self.v11_key.is_none() {
let master_password = get_master_password(self.config.application_id).await?;
self.v11_key = Some(util::derive_saltysalt(&master_password, 1)?);
}
let key = self
.v11_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
decrypt(key, encrypted)
}
}
#[async_trait]
impl CryptoService for LinuxCryptoService {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
let (version, password) =
util::split_encrypted_string_and_validate(encrypted, &["v10", "v11"])?;
let result = match version {
"v10" => self.decrypt_v10(password),
"v11" => self.decrypt_v11(password).await,
_ => Err(anyhow!("Logic error: unreachable code")),
}?;
Ok(result)
}
}
fn decrypt(key: &[u8], encrypted: &[u8]) -> Result<String> {
let plaintext = util::decrypt_aes_128_cbc(key, &IV, encrypted)?;
String::from_utf8(plaintext).map_err(|e| anyhow!("UTF-8 error: {:?}", e))
}
async fn get_master_password(application_tag: &str) -> Result<Vec<u8>> {
let keyring = oo7::Keyring::new().await?;
keyring.unlock().await?;
let attributes = HashMap::from([
(
XDG_SCHEMA_ATTRIBUTE,
"chrome_libsecret_os_crypt_password_v2",
),
("application", application_tag),
]);
let results = keyring.search_items(&attributes).await?;
match results.first() {
Some(r) => {
let secret = r.secret().await?;
Ok(secret.to_vec())
}
None => Err(anyhow!("The master password not found in the keyring")),
}
}

View File

@@ -0,0 +1,164 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use security_framework::passwords::get_generic_password;
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
use crate::util;
//
// Public API
//
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
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(crate) fn get_crypto_service(
browser_name: &String,
_local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {
let config = KEYCHAIN_CONFIG
.iter()
.find(|b| b.browser == browser_name)
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
Ok(Box::new(MacCryptoService::new(config)))
}
//
// Private
//
#[derive(Debug)]
struct KeychainConfig {
browser: &'static str,
service: &'static str,
account: &'static str,
}
const KEYCHAIN_CONFIG: [KeychainConfig; SUPPORTED_BROWSERS.len()] = [
KeychainConfig {
browser: "Chrome",
service: "Chrome Safe Storage",
account: "Chrome",
},
KeychainConfig {
browser: "Chromium",
service: "Chromium Safe Storage",
account: "Chromium",
},
KeychainConfig {
browser: "Microsoft Edge",
service: "Microsoft Edge Safe Storage",
account: "Microsoft Edge",
},
KeychainConfig {
browser: "Brave",
service: "Brave Safe Storage",
account: "Brave",
},
KeychainConfig {
browser: "Arc",
service: "Arc Safe Storage",
account: "Arc",
},
KeychainConfig {
browser: "Opera",
service: "Opera Safe Storage",
account: "Opera",
},
KeychainConfig {
browser: "Vivaldi",
service: "Vivaldi Safe Storage",
account: "Vivaldi",
},
];
const IV: [u8; 16] = [0x20; 16]; // 16 bytes of 0x20 (space character)
//
// CryptoService
//
struct MacCryptoService {
config: &'static KeychainConfig,
master_key: Option<Vec<u8>>,
}
impl MacCryptoService {
fn new(config: &'static KeychainConfig) -> Self {
Self {
config,
master_key: None,
}
}
}
#[async_trait]
impl CryptoService for MacCryptoService {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
if encrypted.is_empty() {
return Ok(String::new());
}
// On macOS only v10 is supported
let (_, no_prefix) = util::split_encrypted_string_and_validate(encrypted, &["v10"])?;
// This might bring up the admin password prompt
if self.master_key.is_none() {
self.master_key = Some(get_master_key(self.config.service, self.config.account)?);
}
let key = self
.master_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
let plaintext = util::decrypt_aes_128_cbc(key, &IV, no_prefix)
.map_err(|e| anyhow!("Failed to decrypt: {}", e))?;
let plaintext =
String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8: {}", e))?;
Ok(plaintext)
}
}
fn get_master_key(service: &str, account: &str) -> Result<Vec<u8>> {
let master_password = get_master_password(service, account)?;
let key = util::derive_saltysalt(&master_password, 1003)?;
Ok(key)
}
fn get_master_password(service: &str, account: &str) -> Result<Vec<u8>> {
let password = get_generic_password(service, account)
.map_err(|e| anyhow!("Failed to get password from keychain: {}", e))?;
Ok(password)
}

View File

@@ -0,0 +1,7 @@
// 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 native;
pub(crate) use native::*;

View File

@@ -0,0 +1,204 @@
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<Box<dyn CryptoService>> {
Ok(Box::new(WindowsCryptoService::new(local_state)))
}
//
// CryptoService
//
struct WindowsCryptoService {
master_key: Option<Vec<u8>>,
encrypted_key: Option<String>,
}
impl WindowsCryptoService {
pub(crate) fn new(local_state: &LocalState) -> Self {
Self {
master_key: None,
encrypted_key: local_state
.os_crypt
.as_ref()
.and_then(|c| c.encrypted_key.clone()),
}
}
}
#[async_trait]
impl CryptoService for WindowsCryptoService {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
if encrypted.is_empty() {
return Ok(String::new());
}
// On Windows only v10 and v20 are supported at the moment
let (version, no_prefix) =
util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?;
// v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag]
const IV_SIZE: usize = 12;
const TAG_SIZE: usize = 16;
const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE;
if no_prefix.len() < MIN_LENGTH {
return Err(anyhow!(
"Corrupted entry: expected at least {} bytes, got {} bytes",
MIN_LENGTH,
no_prefix.len()
));
}
// Allow empty passwords
if no_prefix.len() == MIN_LENGTH {
return Ok(String::new());
}
if self.master_key.is_none() {
self.master_key = Some(self.get_master_key(version)?);
}
let key = self
.master_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
let key = Key::<Aes256Gcm>::from_slice(key);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]);
let decrypted_bytes = cipher
.decrypt(nonce, no_prefix[IV_SIZE..].as_ref())
.map_err(|e| anyhow!("Decryption failed: {}", e))?;
let plaintext = String::from_utf8(decrypted_bytes)
.map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?;
Ok(plaintext)
}
}
impl WindowsCryptoService {
fn get_master_key(&mut self, version: &str) -> Result<Vec<u8>> {
match version {
"v10" => self.get_master_key_v10(),
_ => Err(anyhow!("Unsupported version: {}", version)),
}
}
fn get_master_key_v10(&mut self) -> Result<Vec<u8>> {
if self.encrypted_key.is_none() {
return Err(anyhow!(
"Encrypted master key is not found in the local browser state"
));
}
let key = self
.encrypted_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
let key_bytes = BASE64_STANDARD
.decode(key)
.map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?;
if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" {
return Err(anyhow!("Encrypted master key is not encrypted with DPAPI"));
}
let key = unprotect_data_win(&key_bytes[5..])
.map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?;
Ok(key)
}
}
fn unprotect_data_win(data: &[u8]) -> Result<Vec<u8>> {
if data.is_empty() {
return Ok(Vec::new());
}
let mut data_in = DATA_BLOB {
cbData: data.len() as DWORD,
pbData: data.as_ptr() as *mut BYTE,
};
let mut data_out = DATA_BLOB {
cbData: 0,
pbData: std::ptr::null_mut(),
};
let result: BOOL = unsafe {
// BOOL from winapi (i32)
CryptUnprotectData(
&mut data_in,
std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16)
std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB
std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void)
std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT
0, // dwFlags: DWORD
&mut data_out,
)
};
if result == 0 {
return Err(anyhow!("CryptUnprotectData failed"));
}
if data_out.pbData.is_null() || data_out.cbData == 0 {
return Ok(Vec::new());
}
let output_slice =
unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) };
unsafe {
if !data_out.pbData.is_null() {
LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void)));
}
}
Ok(output_slice.to_vec())
}

View File

@@ -0,0 +1,5 @@
#![doc = include_str!("../README.md")]
pub mod chromium;
pub mod metadata;
mod util;

View File

@@ -0,0 +1,209 @@
use std::collections::{HashMap, HashSet};
use crate::chromium::{InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS};
/// Mechanisms that load data into the importer
pub struct NativeImporterMetadata {
/// Identifies the importer
pub id: String,
/// Describes the strategies used to obtain imported data
pub loaders: Vec<&'static str>,
/// Identifies the instructions for the importer
pub instructions: &'static str,
}
/// Returns a map of supported importers based on the current platform.
///
/// Only browsers listed in PLATFORM_SUPPORTED_BROWSERS will have the "chromium" loader.
/// All importers will have the "file" loader.
pub fn get_supported_importers<T: InstalledBrowserRetriever>(
) -> HashMap<String, NativeImporterMetadata> {
let mut map = HashMap::new();
// Check for installed browsers
let installed_browsers = T::get_installed_browsers().unwrap_or_default();
const IMPORTERS: &[(&str, &str)] = &[
("chromecsv", "Chrome"),
("chromiumcsv", "Chromium"),
("bravecsv", "Brave"),
("operacsv", "Opera"),
("vivaldicsv", "Vivaldi"),
("edgecsv", "Microsoft Edge"),
];
let supported: HashSet<&'static str> =
PLATFORM_SUPPORTED_BROWSERS.iter().map(|b| b.name).collect();
for (id, browser_name) in IMPORTERS {
let mut loaders: Vec<&'static str> = vec!["file"];
if supported.contains(browser_name) {
loaders.push("chromium");
}
if installed_browsers.contains(&browser_name.to_string()) {
map.insert(
id.to_string(),
NativeImporterMetadata {
id: id.to_string(),
loaders,
instructions: "chromium",
},
);
}
}
map
}
// Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use crate::chromium::{InstalledBrowserRetriever, SUPPORTED_BROWSER_MAP};
pub struct MockInstalledBrowserRetriever {}
impl InstalledBrowserRetriever for MockInstalledBrowserRetriever {
fn get_installed_browsers() -> Result<Vec<String>, anyhow::Error> {
Ok(SUPPORTED_BROWSER_MAP
.keys()
.map(|browser| browser.to_string())
.collect())
}
}
fn map_keys(map: &HashMap<String, NativeImporterMetadata>) -> HashSet<String> {
map.keys().cloned().collect()
}
fn get_loaders(
map: &HashMap<String, NativeImporterMetadata>,
id: &str,
) -> HashSet<&'static str> {
map.get(id)
.map(|m| m.loaders.iter().copied().collect::<HashSet<_>>())
.unwrap_or_default()
}
#[cfg(target_os = "macos")]
#[test]
fn macos_returns_all_known_importers() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let expected: HashSet<String> = HashSet::from([
"chromecsv".to_string(),
"chromiumcsv".to_string(),
"bravecsv".to_string(),
"operacsv".to_string(),
"vivaldicsv".to_string(),
"edgecsv".to_string(),
]);
assert_eq!(map.len(), expected.len());
assert_eq!(map_keys(&map), expected);
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_specific_loaders_match_const_array() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let ids = [
"chromecsv",
"chromiumcsv",
"bravecsv",
"operacsv",
"vivaldicsv",
"edgecsv",
];
for id in ids {
let loaders = get_loaders(&map, id);
assert!(loaders.contains("file"));
assert!(loaders.contains("chromium"), "missing chromium for {id}");
}
}
#[cfg(target_os = "linux")]
#[test]
fn returns_all_known_importers() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let expected: HashSet<String> = HashSet::from([
"chromecsv".to_string(),
"chromiumcsv".to_string(),
"bravecsv".to_string(),
"operacsv".to_string(),
]);
assert_eq!(map.len(), expected.len());
assert_eq!(map_keys(&map), expected);
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
}
}
#[cfg(target_os = "linux")]
#[test]
fn linux_specific_loaders_match_const_array() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let ids = ["chromecsv", "chromiumcsv", "bravecsv", "operacsv"];
for id in ids {
let loaders = get_loaders(&map, id);
assert!(loaders.contains("file"));
assert!(loaders.contains("chromium"), "missing chromium for {id}");
}
}
#[cfg(target_os = "windows")]
#[test]
fn returns_all_known_importers() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let expected: HashSet<String> = HashSet::from([
"bravecsv".to_string(),
"chromecsv".to_string(),
"chromiumcsv".to_string(),
"edgecsv".to_string(),
"operacsv".to_string(),
"vivaldicsv".to_string(),
]);
assert_eq!(map.len(), expected.len());
assert_eq!(map_keys(&map), expected);
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
}
}
#[cfg(target_os = "windows")]
#[test]
fn windows_specific_loaders_match_const_array() {
let map = get_supported_importers::<MockInstalledBrowserRetriever>();
let ids = [
"bravecsv",
"chromecsv",
"chromiumcsv",
"edgecsv",
"operacsv",
"vivaldicsv",
];
for id in ids {
let loaders = get_loaders(&map, id);
assert!(loaders.contains("file"));
assert!(loaders.contains("chromium"), "missing chromium for {id}");
}
}
}

View File

@@ -0,0 +1,162 @@
use anyhow::{anyhow, Result};
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))
}
/// A Chromium password consists of three parts:
/// - Version (3 bytes): "v10", "v11", etc.
/// - Cipher text (chunks of 16 bytes)
/// - Padding (1-15 bytes)
///
/// This function splits the encrypted byte slice into version and cipher text.
/// Padding is included and handled by the underlying cryptographic library.
pub(crate) 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))
}
/// Decrypt using AES-128 in CBC mode.
#[cfg(any(target_os = "linux", target_os = "macos", test))]
pub(crate) fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
cbc::Decryptor::<aes::Aes128>::new_from_slices(key, iv)?
.decrypt_padded_vec_mut::<Pkcs7>(ciphertext)
.map_err(|e| anyhow!("Failed to decrypt: {}", e))
}
/// Derives a PBKDF2 key from the static "saltysalt" salt with the given password and iteration count.
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn derive_saltysalt(password: &[u8], iterations: u32) -> Result<Vec<u8>> {
use pbkdf2::{hmac::Hmac, pbkdf2};
use sha1::Sha1;
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)
}
#[cfg(test)]
mod tests {
use aes::cipher::{
block_padding::Pkcs7,
generic_array::{sequence::GenericSequence, GenericArray},
ArrayLength, BlockEncryptMut, KeyIvInit,
};
const LENGTH16: usize = 16;
const LENGTH10: usize = 10;
const LENGTH0: usize = 0;
fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec<u8> {
(0..length).map(|i| offset + i as u8 * increment).collect()
}
fn generate_generic_array<N: ArrayLength<u8>>(
offset: u8,
increment: u8,
) -> GenericArray<u8, N> {
GenericArray::generate(|i| offset + i as u8 * increment)
}
fn run_split_encrypted_string_test<'a, const N: usize>(
successfully_split: bool,
plaintext_to_encrypt: &'a str,
version: &'a str,
password: Vec<u8>,
) {
let res = super::split_encrypted_string(plaintext_to_encrypt.as_bytes());
assert_eq!(res.is_ok(), successfully_split);
if let Ok((version_found, password_found)) = res {
assert_eq!(version_found, version);
assert_eq!(password_found.len(), password.len());
assert_eq!(password_found, &password);
}
}
#[test]
fn test_split_encrypted_string_success_v10() {
run_split_encrypted_string_test::<LENGTH0>(
true,
"v10EncryptMe!",
"v10",
vec![69, 110, 99, 114, 121, 112, 116, 77, 101, 33],
);
}
#[test]
fn test_split_encrypted_string_fail_no_password() {
run_split_encrypted_string_test::<LENGTH10>(true, "v09", "v09", Vec::<u8>::new());
}
#[test]
fn test_split_encrypted_string_fail_too_small() {
run_split_encrypted_string_test::<LENGTH10>(false, "v0", "v0", vec![0]);
}
fn run_split_encrypted_string_and_validate_test(
valid_version: bool,
plaintext_to_encrypt: &str,
supported_versions: &[&str],
) {
let result = super::split_encrypted_string_and_validate(
plaintext_to_encrypt.as_bytes(),
supported_versions,
);
assert_eq!(result.is_ok(), valid_version);
}
#[test]
fn test_split_encrypted_string_and_validate_version_found_from_single_version() {
run_split_encrypted_string_and_validate_test(true, "v10EncryptMe!", &["v10"]);
}
#[test]
fn test_split_encrypted_string_and_validate_version_found_from_multiple_versions() {
run_split_encrypted_string_and_validate_test(true, "v10EncryptMe!", &["v11", "v10"]);
}
#[test]
fn test_split_encrypted_string_and_validate_version_not_found() {
run_split_encrypted_string_and_validate_test(false, "v10EncryptMe!", &["v11", "v12"]);
}
#[test]
fn test_split_encrypted_string_and_validate_version_not_found_empty_list() {
run_split_encrypted_string_and_validate_test(false, "v10EncryptMe!", &[]);
}
#[test]
fn test_decrypt_aes_128_cbc() {
let offset = 0;
let increment = 1;
let iv = generate_vec(LENGTH16, offset, increment);
let iv: &[u8; LENGTH16] = iv.as_slice().try_into().unwrap();
let key: GenericArray<u8, _> = generate_generic_array(0, 1);
let data = cbc::Encryptor::<aes::Aes128>::new(&key, iv.into())
.encrypt_padded_vec_mut::<Pkcs7>("EncryptMe!".as_bytes());
let decrypted = super::decrypt_aes_128_cbc(&key, iv, &data).unwrap();
assert_eq!(String::from_utf8(decrypted).unwrap(), "EncryptMe!");
}
}