use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use rusqlite::types::Value as SqlValue;
use rusqlite::{params, Connection};
use super::chromium_crypto::encrypt_chromium_value;
use super::fs_utils::path_exists;
use super::os_crypt_keys::SourceKeyResolver;
use super::sqlite_snapshot::with_database_snapshot;
use super::{ClassOutcome, MigrationEntry};
use crate::browser::browser_cookie_crypto::decrypt_chromium_cookie;
pub(crate) struct PasswordKeys<'a> {
pub platform: &'a str,
pub resolve_source_key: &'a SourceKeyResolver,
pub target_key: &'a [u8],
pub target_prefix: Option<&'a str>,
}
pub(super) fn to_bytes(value: SqlValue) -> Vec<u8> {
match value {
SqlValue::Blob(bytes) => bytes,
SqlValue::Text(text) => text.into_bytes(),
SqlValue::Integer(number) => number.to_string().into_bytes(),
SqlValue::Real(number) => number.to_string().into_bytes(),
SqlValue::Null => Vec::new(),
}
}
fn host_from_origin(origin: Option<&str>) -> String {
let origin = origin.unwrap_or_default();
url::Url::parse(origin)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| origin.to_string())
}
struct LoginRow {
rowid: i64,
origin_url: Option<String>,
password_value: Vec<u8>,
}
pub(super) enum SecretRewrite {
Encrypted(Vec<u8>),
Skipped(MigrationEntry),
}
pub(super) fn reencrypt_secret(
value: &[u8],
origin: Option<&str>,
keys: &PasswordKeys<'_>,
) -> Result<SecretRewrite> {
if value.is_empty() {
return Ok(SecretRewrite::Encrypted(Vec::new()));
}
let item = origin.unwrap_or("(unknown)");
let prefix = String::from_utf8_lossy(&value[..value.len().min(3)]).into_owned();
let reason = if prefix == "v20" {
Some("app-bound-v20")
} else if prefix != "v10" && prefix != "v11" {
Some("unsupported-encryption")
} else {
None
};
if let Some(reason) = reason {
return Ok(SecretRewrite::Skipped(MigrationEntry::new(
"passwords",
item,
reason,
)));
}
let decrypted = (keys.resolve_source_key)(&prefix).and_then(|key| {
decrypt_chromium_cookie(value, &host_from_origin(origin), 0, keys.platform, &key)
});
let plaintext = match decrypted {
Ok(plaintext) => plaintext,
Err(error) => {
return Ok(SecretRewrite::Skipped(
MigrationEntry::new("passwords", item, "decrypt-failed")
.with_detail(format!("{error:#}")),
))
}
};
let target_prefix = keys.target_prefix.unwrap_or(if keys.platform == "win32" {
"v10"
} else {
"v11"
});
Ok(SecretRewrite::Encrypted(encrypt_chromium_value(
plaintext.as_bytes(),
keys.target_key,
keys.platform,
Some(target_prefix),
)?))
}
fn reencrypt_logins(
database: &Connection,
keys: &PasswordKeys<'_>,
domains: &[String],
) -> Result<ClassOutcome> {
let rows = {
let mut statement =
database.prepare("SELECT rowid, origin_url, password_value FROM logins")?;
let mapped = statement.query_map([], |row| {
Ok(LoginRow {
rowid: row.get(0)?,
origin_url: row.get(1)?,
password_value: to_bytes(row.get(2)?),
})
})?;
mapped.collect::<rusqlite::Result<Vec<_>>>()?
};
let mut outcome = ClassOutcome::default();
for row in rows {
if !super::domains::matches_domains(row.origin_url.as_deref().unwrap_or_default(), domains)
{
database.execute("DELETE FROM logins WHERE rowid=?", [row.rowid])?;
continue;
}
if row.password_value.is_empty() {
continue;
}
let reencrypted =
match reencrypt_secret(&row.password_value, row.origin_url.as_deref(), keys)? {
SecretRewrite::Encrypted(value) => value,
SecretRewrite::Skipped(entry) => {
database.execute("DELETE FROM logins WHERE rowid=?", [row.rowid])?;
outcome.skipped.push(entry);
continue;
}
};
database.execute(
"UPDATE logins SET password_value = ?1 WHERE rowid = ?2",
params![reencrypted, row.rowid],
)?;
outcome.migrated += 1;
}
Ok(outcome)
}
#[cfg(test)]
pub(crate) fn migrate_passwords(
source_profile_dir: &Path,
target_profile_dir: &Path,
keys: &PasswordKeys<'_>,
) -> Result<ClassOutcome> {
migrate_passwords_filtered(source_profile_dir, target_profile_dir, keys, &[])
}
pub(crate) fn migrate_passwords_filtered(
source_profile_dir: &Path,
target_profile_dir: &Path,
keys: &PasswordKeys<'_>,
domains: &[String],
) -> Result<ClassOutcome> {
let source_path = source_profile_dir.join("Login Data");
if !path_exists(&source_path) {
return Ok(ClassOutcome::skipped(MigrationEntry::new(
"passwords",
"Login Data",
"source-missing",
)));
}
if keys.target_key.is_empty() {
return Err(anyhow::anyhow!(
"migrate_passwords requires a target encryption key"
));
}
fs::create_dir_all(target_profile_dir)
.with_context(|| format!("Could not create {}", target_profile_dir.display()))?;
let target_path = target_profile_dir.join("Login Data");
with_database_snapshot(&source_path, |snapshot_path| {
fs::copy(snapshot_path, &target_path)
.with_context(|| format!("Could not write {}", target_path.display()))?;
let database = Connection::open(&target_path)
.with_context(|| format!("Could not open {}", target_path.display()))?;
let mut outcome = reencrypt_logins(&database, keys, domains)?;
super::password_metadata::migrate_password_metadata(
&database,
keys,
domains,
&mut outcome,
)?;
database.execute_batch("VACUUM")?;
Ok(outcome)
})
}