Files
donutbrowser/src-tauri/src/profile_import/os_crypt.rs
T
2026-08-11 00:18:49 +04:00

569 lines
20 KiB
Rust

//! Key material for profile import.
//!
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
//! instead, so a profile directory is self-contained and portable. See
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
//!
//! That portability is exactly why an imported Chrome profile carries nothing:
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
//! to open the source's lock and re-seal everything with Wayfern's.
//!
//! The on-disk format is per-platform and NOT interchangeable, matching the
//! provider that owns each tag in the patched Chromium 151 tree:
//!
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
//! |---------|---------------------|-------------------------------------|--------------|-------|
//! | macOS | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1003) | AES-128-CBC | `v10` |
//! | Linux | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1) | AES-128-CBC | `v11` |
//! | Windows | 32 raw bytes | none, the bytes are the key | AES-256-GCM | `v10` |
//!
//! Linux must write `v11`, not `v10`: `PosixKeyProvider` owns `v10` with the
//! hardcoded "peanuts" password and `Encryptor::DecryptData` dispatches on the
//! tag prefix, so a `v10` record on Linux would be decrypted with the wrong key
//! forever.
use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
// Windows stores the raw 32-byte key, so it neither encodes nor decodes
// base64; only the mac/Linux password paths below need the trait in scope.
#[cfg(not(target_os = "windows"))]
use base64::Engine;
use rand::RngExt;
use ring::pbkdf2;
use std::num::NonZeroU32;
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
/// Chromium's fixed PBKDF2 salt for every CBC-based os_crypt provider.
// Only the CBC hosts derive a key; Windows uses the file's bytes directly.
#[allow(dead_code)]
pub const SALT: &[u8] = b"saltysalt";
/// Chromium's fixed CBC IV: sixteen spaces.
pub const CBC_IV: [u8; 16] = [b' '; 16];
/// AES-256-GCM nonce length, prepended to the ciphertext by `Encryptor::Key::Encrypt`.
const GCM_NONCE_LEN: usize = 12;
/// The `os_crypt_key` name, at the root of the user-data dir.
pub const KEY_FILE_NAME: &str = "os_crypt_key";
/// `PBKDF2-HMAC-SHA1(password = "", salt = "saltysalt", iterations = 1)`.
///
/// Chromium retries every failed AES-128-CBC decrypt with this key
/// (`encryptor.cc`, crbug.com/40055416) because profiles created while the
/// keyring was unavailable were sealed with an empty password. Import has to do
/// the same or those records look corrupt.
pub const EMPTY_PASSWORD_KEY: [u8; 16] = [
0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a, 0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
];
/// The password Chromium's `PosixKeyProvider` uses when no secret service is
/// available (`--password-store=basic`). Records sealed with it carry `v10`.
// Read on Linux and by the known-answer tests; unreferenced on other hosts.
#[allow(dead_code)]
pub const POSIX_FALLBACK_PASSWORD: &[u8] = b"peanuts";
/// PBKDF2 iteration counts, per the provider that owns each platform.
// Each host only ever derives with its own count, but both are needed to read
// a profile produced on the other one.
#[allow(dead_code)]
pub const MAC_ITERATIONS: u32 = 1003;
#[allow(dead_code)]
pub const POSIX_ITERATIONS: u32 = 1;
/// Derive a 16-byte AES-128 key the way every CBC os_crypt provider does.
///
/// `password` is the raw bytes, never trimmed: Chromium passes the exact
/// `ReadFileToString` result to the KDF, so normalising here would silently
/// produce a different key and every decrypt would fail.
// Called from the mac and Linux branches only: `DPAPIKeyProvider` takes the
// 32 bytes on disk as the AES-256 key with no derivation step at all.
#[allow(dead_code)]
pub fn derive_key(password: &[u8], iterations: u32) -> [u8; 16] {
let mut key = [0u8; 16];
// ring rather than the `pbkdf2` crate: sha1 0.11 (digest 0.11) and
// pbkdf2 0.12 (digest 0.10) cannot coexist. ring is self-contained.
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA1,
NonZeroU32::new(iterations).expect("iterations must be non-zero"),
SALT,
password,
&mut key,
);
key
}
/// One os_crypt cipher, keyed. Which variant applies is decided by the tag the
/// record carries, never by the host platform.
#[derive(Clone)]
pub enum CryptoKey {
Aes128Cbc([u8; 16]),
// Only Windows keys with GCM, but the variant has to exist everywhere so the
// tag dispatch in `SourceKeyring` stays platform-independent.
#[allow(dead_code)]
Aes256Gcm([u8; 32]),
}
impl CryptoKey {
/// Decrypt a *tagless* ciphertext (the caller has already stripped the
/// 3-byte version prefix).
pub fn decrypt(&self, ciphertext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
if ciphertext.is_empty() {
return Some(Vec::new());
}
let mut buf = ciphertext.to_vec();
Aes128CbcDec::new(key.into(), &CBC_IV.into())
.decrypt_padded::<Pkcs7>(&mut buf)
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
if ciphertext.len() < GCM_NONCE_LEN {
return None;
}
let (nonce, body) = ciphertext.split_at(GCM_NONCE_LEN);
let nonce: [u8; GCM_NONCE_LEN] = nonce.try_into().ok()?;
Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.decrypt(
&Nonce::from(nonce),
Payload {
msg: body,
aad: &[],
},
)
.ok()
}
}
}
/// Encrypt to a *tagless* ciphertext. The caller prepends the tag.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
let mut buf = vec![0u8; plaintext.len() + 16];
buf[..plaintext.len()].copy_from_slice(plaintext);
Aes128CbcEnc::new(key.into(), &CBC_IV.into())
.encrypt_padded::<Pkcs7>(&mut buf, plaintext.len())
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
let nonce: [u8; GCM_NONCE_LEN] = rand::rng().random();
let sealed = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.encrypt(
&Nonce::from(nonce),
Payload {
msg: plaintext,
aad: &[],
},
)
.ok()?;
// The nonce goes at the front, matching `Encryptor::Key::Encrypt`.
let mut out = Vec::with_capacity(GCM_NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Some(out)
}
}
}
}
/// Wayfern's key for the profile being created.
pub struct TargetKey {
key: CryptoKey,
tag: &'static [u8; 3],
}
impl TargetKey {
/// The tag the host platform's key provider claims.
pub const fn host_tag() -> &'static [u8; 3] {
#[cfg(target_os = "linux")]
{
b"v11"
}
#[cfg(not(target_os = "linux"))]
{
b"v10"
}
}
/// Build the key from the raw `os_crypt_key` file contents.
///
/// Returns `None` when the contents cannot key the host cipher — on Windows
/// that means anything other than exactly 32 bytes, which is what
/// `DPAPIKeyProvider` requires before it will adopt a portable key.
fn from_file_contents(contents: &[u8]) -> Option<Self> {
if contents.is_empty() {
return None;
}
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().ok()?;
Some(Self {
key: CryptoKey::Aes256Gcm(bytes),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "macos")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "linux")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)),
tag: Self::host_tag(),
})
}
}
/// Fresh key material in the host platform's `os_crypt_key` format.
fn generate_file_contents() -> Vec<u8> {
#[cfg(target_os = "windows")]
{
// Windows stores the AES-256 key itself, so it must be 32 bytes.
let key: [u8; 32] = rand::rng().random();
key.to_vec()
}
#[cfg(not(target_os = "windows"))]
{
// mac/Linux store a *password* that is fed to PBKDF2. Wayfern mints
// `base64(16 random bytes)`; match it so the file is indistinguishable
// from one the browser wrote itself.
let raw: [u8; 16] = rand::rng().random();
base64::engine::general_purpose::STANDARD
.encode(raw)
.into_bytes()
}
}
/// Read the existing `os_crypt_key`, or mint and persist one.
///
/// Writing eagerly at import time — rather than letting the first launch do
/// it — is deliberate. The mac and Linux patches have no `else` branch when
/// the write fails, so the browser would run on an in-memory key that dies
/// with the process and orphans everything it wrote. Failing here instead
/// turns that silent data loss into a visible import error.
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
let key_file = user_data_dir.join(KEY_FILE_NAME);
if let Ok(existing) = std::fs::read(&key_file) {
if let Some(key) = Self::from_file_contents(&existing) {
return Ok(key);
}
// Present but unusable (a Windows-format key on macOS, say, or a
// truncated write). Replacing it is safe only because import always
// re-encrypts into whatever key we end up with.
log::warn!(
"Replacing unusable {KEY_FILE_NAME} ({} bytes) at {}",
existing.len(),
key_file.display()
);
}
std::fs::create_dir_all(user_data_dir)
.map_err(|e| format!("Failed to create profile directory: {e}"))?;
let contents = Self::generate_file_contents();
std::fs::write(&key_file, &contents)
.map_err(|e| format!("Failed to write os_crypt_key: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600));
}
// Read back rather than trust the write: a key that did not land is the
// one failure mode that silently destroys every secret we are about to
// write with it.
let written =
std::fs::read(&key_file).map_err(|e| format!("Failed to verify os_crypt_key: {e}"))?;
if written != contents {
return Err("os_crypt_key verification failed after write".to_string());
}
Self::from_file_contents(&contents).ok_or_else(|| "Failed to derive os_crypt_key".to_string())
}
/// Seal a value the way Wayfern will expect to find it: `tag || ciphertext`.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
let body = self.key.encrypt(plaintext)?;
let mut out = Vec::with_capacity(3 + body.len());
out.extend_from_slice(self.tag);
out.extend_from_slice(&body);
Some(out)
}
}
/// What a decrypt attempt produced.
pub enum Decrypted {
/// Recovered plaintext.
Value(Vec<u8>),
/// Already plaintext — no recognised version tag.
NotEncrypted,
/// Correctly identified but not openable: no key for the tag (Windows
/// App-Bound `v20`), or every candidate key failed.
Unrecoverable,
}
/// The source browser's keys, indexed by the tag the records carry.
///
/// Indexing by tag rather than by platform is not pedantry: a single Linux
/// profile can legitimately hold both `v10` (peanuts) and `v11` (keyring)
/// records, because the available secret service changes between sessions.
#[derive(Default)]
pub struct SourceKeyring {
pub v10: Option<CryptoKey>,
pub v11: Option<CryptoKey>,
/// Seen at least one `v20` (Windows App-Bound) record, which no third party
/// can open. Tracked so the import report can say so explicitly.
pub saw_app_bound: std::cell::Cell<bool>,
}
impl SourceKeyring {
pub fn is_empty(&self) -> bool {
self.v10.is_none() && self.v11.is_none()
}
/// Open one stored value, dispatching on its version tag exactly as
/// `Encryptor::DecryptData` does.
pub fn decrypt(&self, stored: &[u8]) -> Decrypted {
if stored.len() < 3 {
return if stored.is_empty() {
Decrypted::Value(Vec::new())
} else {
Decrypted::NotEncrypted
};
}
let (tag, body) = stored.split_at(3);
let key = match tag {
b"v10" => self.v10.as_ref(),
b"v11" => self.v11.as_ref(),
b"v20" => {
// App-Bound Encryption. The key is wrapped by the SYSTEM-level Chrome
// Elevation Service, which validates the calling binary. There is no
// legitimate way for us to unwrap it.
self.saw_app_bound.set(true);
return Decrypted::Unrecoverable;
}
_ => return Decrypted::NotEncrypted,
};
let Some(key) = key else {
return Decrypted::Unrecoverable;
};
if let Some(plaintext) = key.decrypt(body) {
return Decrypted::Value(plaintext);
}
// Chromium's own fallback for CBC records sealed with an empty password.
if matches!(key, CryptoKey::Aes128Cbc(_)) {
if let Some(plaintext) = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY).decrypt(body) {
return Decrypted::Value(plaintext);
}
}
Decrypted::Unrecoverable
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn empty_password_key_matches_chromium_constant() {
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
}
#[test]
fn peanuts_key_matches_known_vector() {
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
// breaks every Linux `--password-store=basic` import.
assert_eq!(
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
[
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
0x78
]
);
}
#[test]
fn cbc_round_trip() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"session-token").expect("encrypt");
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
}
#[test]
fn cbc_round_trip_empty_plaintext() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"").expect("encrypt");
// PKCS7 always emits a full padding block, so this must not be empty.
assert_eq!(sealed.len(), 16);
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
}
#[test]
fn gcm_round_trip_with_fresh_nonce_each_time() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let a = key.encrypt(b"session-token").expect("encrypt");
let b = key.encrypt(b"session-token").expect("encrypt");
assert_ne!(a, b, "nonce must be random per call");
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
}
#[test]
fn gcm_rejects_tampered_ciphertext() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
let last = sealed.len() - 1;
sealed[last] ^= 0xff;
assert!(key.decrypt(&sealed).is_none());
}
#[test]
fn target_key_is_stable_across_calls() {
let dir = TempDir::new().unwrap();
let first = TargetKey::ensure(dir.path()).expect("mint");
let sealed = first.encrypt(b"value").expect("encrypt");
let second = TargetKey::ensure(dir.path()).expect("reuse");
// Re-running import over the same directory must not orphan what the
// previous run wrote.
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
assert_eq!(
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
second.encrypt(b"probe").map(|v| v[..3].to_vec())
);
let mut keyring = SourceKeyring::default();
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
install_host_key(&mut keyring, &contents);
match keyring.decrypt(&sealed) {
Decrypted::Value(v) => assert_eq!(v, b"value"),
_ => panic!("target key must round-trip through the source keyring"),
}
}
#[test]
fn minted_key_matches_wayfern_file_format() {
let dir = TempDir::new().unwrap();
TargetKey::ensure(dir.path()).expect("mint");
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
assert_eq!(
contents.len(),
32,
"DPAPIKeyProvider only adopts a 32-byte portable key"
);
#[cfg(not(target_os = "windows"))]
{
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
assert_eq!(contents.len(), 24);
let text = String::from_utf8(contents).expect("ascii");
assert!(
base64::engine::general_purpose::STANDARD
.decode(&text)
.map(|b| b.len())
== Ok(16),
"expected base64 of 16 bytes, got {text}"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
let keyring = SourceKeyring::default();
assert!(matches!(
keyring.decrypt(b"plain cookie value"),
Decrypted::NotEncrypted
));
}
#[test]
fn app_bound_records_are_flagged_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v20".to_vec();
sealed.extend_from_slice(&[0u8; 40]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
assert!(
keyring.saw_app_bound.get(),
"v20 must be reported to the user, not silently dropped"
);
}
#[test]
fn missing_key_for_known_tag_is_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v10".to_vec();
sealed.extend_from_slice(&[0u8; 32]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
}
#[test]
fn empty_password_fallback_recovers_the_record() {
// A record sealed with the empty-password key must still open when the
// keyring holds a different primary key, mirroring Chromium.
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
.encrypt(b"legacy")
.unwrap();
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&sealed_body);
let keyring = SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
..Default::default()
};
match keyring.decrypt(&stored) {
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
_ => panic!("empty-password fallback must be attempted"),
}
}
/// Load the host-format key into a keyring under the host tag, for tests
/// that need to verify what we wrote is what Wayfern will read.
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().unwrap();
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
}
#[cfg(target_os = "macos")]
{
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
}
#[cfg(target_os = "linux")]
{
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
}
}
}