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:
36
apps/desktop/desktop_native/chromium_importer/Cargo.toml
Normal file
36
apps/desktop/desktop_native/chromium_importer/Cargo.toml
Normal 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
|
||||
163
apps/desktop/desktop_native/chromium_importer/README.md
Normal file
163
apps/desktop/desktop_native/chromium_importer/README.md
Normal 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
|
||||
```
|
||||
@@ -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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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())
|
||||
}
|
||||
5
apps/desktop/desktop_native/chromium_importer/src/lib.rs
Normal file
5
apps/desktop/desktop_native/chromium_importer/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod chromium;
|
||||
pub mod metadata;
|
||||
mod util;
|
||||
209
apps/desktop/desktop_native/chromium_importer/src/metadata.rs
Normal file
209
apps/desktop/desktop_native/chromium_importer/src/metadata.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
162
apps/desktop/desktop_native/chromium_importer/src/util.rs
Normal file
162
apps/desktop/desktop_native/chromium_importer/src/util.rs
Normal 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!");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user