From b7a89ac94e45630b0b6b979945ce021df0550ef5 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:09:02 +0400 Subject: [PATCH] fix: read any os_crypt format --- src-tauri/src/cookie_manager.rs | 315 ++++++++++++------ src-tauri/src/profile_import/os_crypt.rs | 169 +++++----- .../src/profile_import/os_crypt_tests.rs | 106 ++++++ 3 files changed, 419 insertions(+), 171 deletions(-) diff --git a/src-tauri/src/cookie_manager.rs b/src-tauri/src/cookie_manager.rs index 2e26c434..316475b3 100644 --- a/src-tauri/src/cookie_manager.rs +++ b/src-tauri/src/cookie_manager.rs @@ -12,54 +12,33 @@ use tauri::AppHandle; /// so no encryption path is needed here — Chromium reads plaintext when /// `encrypted_value` is empty, regardless of what other cookies store. pub mod chrome_decrypt { - use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, KeyIvInit}; - use ring::pbkdf2; + use crate::profile_import::os_crypt; use sha2::{Digest, Sha256}; - use std::num::NonZeroU32; use std::path::Path; - type Aes128CbcDec = cbc::Decryptor; - - /// PBKDF2 iteration count for deriving the AES key from the password stored - /// in `os_crypt_key`. Must match Chromium's `OSCryptImpl` on each platform: - /// macOS uses 1003 iterations, Linux uses 1. Getting this wrong produces a - /// different AES key → silent decryption failure → empty cookie values. - /// See `components/os_crypt/sync/os_crypt_{mac.mm,linux.cc}` in Chromium. - #[cfg(target_os = "macos")] - const PBKDF2_ITERATIONS: u32 = 1003; - #[cfg(not(target_os = "macos"))] - const PBKDF2_ITERATIONS: u32 = 1; - - const KEY_LEN: usize = 16; // AES-128 - const SALT: &[u8] = b"saltysalt"; - const IV: [u8; 16] = [b' '; 16]; // 16 spaces const HOST_HASH_LEN: usize = 32; // SHA-256 output length - fn derive_key(password: &[u8]) -> [u8; KEY_LEN] { - let mut key = [0u8; KEY_LEN]; - // Using ring::pbkdf2 instead of the `pbkdf2` crate to avoid digest - // version conflicts between sha1 0.11 (digest 0.11) and pbkdf2 0.12 - // (digest 0.10). ring's implementation is self-contained. - pbkdf2::derive( - pbkdf2::PBKDF2_HMAC_SHA1, - NonZeroU32::new(PBKDF2_ITERATIONS).expect("iterations must be non-zero"), - SALT, - password, - &mut key, - ); - key + /// The exact contents of a profile's `os_crypt_key` file. + /// + /// Which cipher opens a record depends on the file's format and the record's + /// tag, never on the OS reading it, so a profile written on any OS decrypts + /// on every OS (see `os_crypt::key_for_tag`). + pub struct EncryptionKey { + contents: Vec, } - pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> { - let key_file = profile_data_path.join("os_crypt_key"); + impl EncryptionKey { + pub fn from_contents(contents: Vec) -> Option { + (!contents.is_empty()).then_some(Self { contents }) + } + } + + pub fn get_encryption_key(profile_data_path: &Path) -> Option { // Read as raw bytes and do NOT trim — Chromium's `ReadFileToString` // passes the exact file contents to `Pbkdf2(file_contents)`. Any // normalisation we do here would produce a different derived key. - let contents = std::fs::read(&key_file).ok()?; - if contents.is_empty() { - return None; - } - Some(derive_key(&contents)) + let contents = std::fs::read(profile_data_path.join(os_crypt::KEY_FILE_NAME)).ok()?; + EncryptionKey::from_contents(contents) } /// Decrypt a Chrome encrypted cookie value. @@ -70,34 +49,36 @@ pub mod chrome_decrypt { /// when present. Passing `host_key` is required to do that verification — /// without it we'd return 32 bytes of hash noise plus the actual value, /// which is not valid UTF-8 and gets thrown away. - pub fn decrypt(encrypted: &[u8], host_key: &str, key: &[u8; KEY_LEN]) -> Option { + /// + /// An AES-128-CBC record that does not open under the key file's key is + /// retried with Chromium's fixed fallback keys; the first key whose output + /// is a valid value wins. + pub fn decrypt(encrypted: &[u8], host_key: &str, key: &EncryptionKey) -> Option { if encrypted.len() < 3 { return None; } - let prefix = &encrypted[..3]; - if prefix != b"v10" && prefix != b"v11" { - return None; - } - let ciphertext = &encrypted[3..]; + let (tag, ciphertext) = encrypted.split_at(3); + let primary = os_crypt::key_for_tag(&key.contents, tag)?; if ciphertext.is_empty() { return Some(String::new()); } + primary + .with_fallbacks() + .iter() + .filter_map(|candidate| candidate.decrypt(ciphertext)) + .find_map(|plaintext| cookie_value(&plaintext, host_key)) + } - let mut buf = ciphertext.to_vec(); - let decrypted = Aes128CbcDec::new(key.into(), &IV.into()) - .decrypt_padded::(&mut buf) - .ok()?; - - // Strip the SHA-256(host_key) integrity prefix if present. Older cookies - // (pre-M100) didn't have this prefix, so we fall back to the raw bytes - // when the first 32 bytes don't match the expected hash. + /// Strip the SHA-256(host_key) integrity prefix if present. Older cookies + /// (pre-M100) didn't have this prefix, so we fall back to the raw bytes + /// when the first 32 bytes don't match the expected hash. + fn cookie_value(decrypted: &[u8], host_key: &str) -> Option { if decrypted.len() >= HOST_HASH_LEN { let expected: [u8; HOST_HASH_LEN] = Sha256::digest(host_key.as_bytes()).into(); if decrypted[..HOST_HASH_LEN] == expected { return String::from_utf8(decrypted[HOST_HASH_LEN..].to_vec()).ok(); } } - String::from_utf8(decrypted.to_vec()).ok() } } @@ -279,20 +260,34 @@ impl CookieManager { /// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01 const WINDOWS_EPOCH_DIFF: i64 = 11644473600; - fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> { + fn get_chrome_encryption_key( + profile: &BrowserProfile, + profiles_dir: &Path, + ) -> Option { let profile_data_path = profile.get_profile_data_path(profiles_dir); chrome_decrypt::get_encryption_key(&profile_data_path) } - fn wayfern_cookie_path(profile_data_path: &Path) -> PathBuf { + /// The cookie database Chromium uses for this profile directory. A profile + /// written on another OS resolves to the file the browser reads here, not to + /// this host's default location. + /// + /// Chromium on Windows moves network data from `Default/` into + /// `Default/Network/` and leaves the `NetworkDataMigrated` checkpoint there. + /// Once that checkpoint exists, Chromium on every OS keeps using `Network/`; + /// without it, other OSes use `Default/`, and Windows migrates a + /// `Default/Cookies` it finds on the next launch. + pub(crate) fn wayfern_cookie_path(profile_data_path: &Path) -> PathBuf { let default_dir = profile_data_path.join("Default"); - #[cfg(target_os = "windows")] + let network_dir = default_dir.join("Network"); + let migrated = network_dir.join("Cookies"); + let unmigrated = default_dir.join("Cookies"); + if network_dir.join("NetworkDataMigrated").exists() + || (cfg!(target_os = "windows") && !unmigrated.exists()) { - default_dir.join("Network").join("Cookies") - } - #[cfg(not(target_os = "windows"))] - { - default_dir.join("Cookies") + migrated + } else { + unmigrated } } @@ -412,7 +407,7 @@ impl CookieManager { /// Handles encrypted cookies by decrypting encrypted_value using the profile's encryption key. fn read_chrome_cookies( db_path: &Path, - encryption_key: Option<&[u8; 16]>, + encryption_key: Option<&chrome_decrypt::EncryptionKey>, ) -> Result, String> { let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?; @@ -1218,23 +1213,57 @@ impl CookieManager { mod tests { use super::*; - #[cfg(target_os = "macos")] + // Known-answer vectors, sealed outside this code base from the plaintext + // SHA-256(SYNTHETIC_COOKIE_HOST) || SYNTHETIC_COOKIE_VALUE. They hold for + // every host: the key file's format and the record's tag choose the cipher. const SYNTHETIC_COOKIE_HOST: &str = ".example.test"; - #[cfg(target_os = "macos")] const SYNTHETIC_COOKIE_VALUE: &str = "synthetic-cookie-value"; - #[cfg(target_os = "macos")] + /// A password key file, the format Chromium uses on macOS and Linux. const SYNTHETIC_OS_CRYPT_PASSWORD: &[u8] = b"donut-synthetic-cookie-key"; - #[cfg(target_os = "macos")] + /// A raw 32-byte key file (0x40..0x5f), the format Chromium uses on Windows. + const SYNTHETIC_OS_CRYPT_RAW_KEY_HEX: &str = + "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"; + /// `v10`, password file: AES-128-CBC, PBKDF2(password, "saltysalt", 1003). const SYNTHETIC_ENCRYPTED_COOKIE_HEX: &str = "763130d83b9fd3e6d1b1c793769f55251f5e9d1193be72c0c08ea32e2cf068a85d9d0b97d8b2e6deca93a2b3c290e98e1a851f83d5566f9aa9314befe56dc6bdbd423d"; + /// `v10`, raw key file: AES-256-GCM, nonce 0xa0..0xab, then ciphertext and tag. + const V10_RAW_KEY_GCM_HEX: &str = "763130a0a1a2a3a4a5a6a7a8a9aaab80ca93cabb34c5dd58110a3d46900c1fa9ecd0d2de71d7b20447d42094e312b029ee1c37a83550c88b77cbf131874eaccf3a660a9e9c574aba285fd5136dc8dfcc4f4cf4f000"; + /// `v11`, password file: AES-128-CBC, PBKDF2(password, "saltysalt", 1). + const V11_PASSWORD_HEX: &str = "76313134ac798d9e3721442b1ff0eeaf137cbaa12cbf87938b967e5f63791f03bd1928007773702a89a8f4a53145d1ea1814815f2ab922cd4e1dbe341cd327af6cd02f"; + /// `v11`, raw key file: AES-128-CBC, PBKDF2(the 32 bytes, "saltysalt", 1). + const V11_RAW_KEY_HEX: &str = "763131d909f5941863272d931e7186ee58d7f87864945cf01dbb4f55b379e6c0fb56e0033d63bbf2bb96bf36168e8a1b7211c6916b205b5b0974d02e93e92444fc9ab7"; + /// `v10` sealed with PBKDF2("", "saltysalt", 1). + const V10_EMPTY_FALLBACK_HEX: &str = "763130e8da432f0721b44064bf87de3cb1eba14d33853aeb85ca13e60bbe64565cc4c776b3bf2bea6268b199fd7038864f0e156a09f00ebb80bb68ed0db71d217ceac3"; + /// `v10` sealed with PBKDF2("peanuts", "saltysalt", 1). + const V10_PEANUTS_FALLBACK_HEX: &str = "76313059f2bdd15dd07447497282fada0e1febf791a1cfea589774156a60a2dc399ec0171af1de360e7f35019c4e9ac34477b265d5eb9758a95daca408325f9e80e2d0"; + /// `v11` sealed with PBKDF2("peanuts", "saltysalt", 1). + const V11_PEANUTS_FALLBACK_HEX: &str = "76313159f2bdd15dd07447497282fada0e1febf791a1cfea589774156a60a2dc399ec0171af1de360e7f35019c4e9ac34477b265d5eb9758a95daca408325f9e80e2d0"; + /// `v10`, password file, a pre-M100 record without the host hash. + const V10_PASSWORD_NO_HOST_HASH_HEX: &str = + "763130a90053a1148568ad32692552b4016aeca1e381f70defa7f8ca833dcb9d29ed5b"; - #[cfg(target_os = "macos")] - fn synthetic_encrypted_cookie() -> Vec { - (0..SYNTHETIC_ENCRYPTED_COOKIE_HEX.len()) + fn unhex(hex: &str) -> Vec { + (0..hex.len()) .step_by(2) - .map(|i| u8::from_str_radix(&SYNTHETIC_ENCRYPTED_COOKIE_HEX[i..i + 2], 16).unwrap()) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) .collect() } + fn synthetic_encrypted_cookie() -> Vec { + unhex(SYNTHETIC_ENCRYPTED_COOKIE_HEX) + } + + fn password_key() -> chrome_decrypt::EncryptionKey { + chrome_decrypt::EncryptionKey::from_contents(SYNTHETIC_OS_CRYPT_PASSWORD.to_vec()).unwrap() + } + + fn raw_key() -> chrome_decrypt::EncryptionKey { + chrome_decrypt::EncryptionKey::from_contents(unhex(SYNTHETIC_OS_CRYPT_RAW_KEY_HEX)).unwrap() + } + + fn open(hex: &str, key: &chrome_decrypt::EncryptionKey) -> Option { + chrome_decrypt::decrypt(&unhex(hex), SYNTHETIC_COOKIE_HOST, key) + } + #[test] fn test_format_netscape_cookies() { let cookies = vec![UnifiedCookie { @@ -1835,49 +1864,149 @@ mod tests { } #[test] - #[cfg(target_os = "macos")] fn test_decrypt_v10_cookie_with_synthetic_vector() { - let profile_dir = - std::env::temp_dir().join(format!("donut_decrypt_vector_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&profile_dir).unwrap(); + let profile_dir = tempfile::TempDir::new().unwrap(); std::fs::write( - profile_dir.join("os_crypt_key"), + profile_dir.path().join("os_crypt_key"), SYNTHETIC_OS_CRYPT_PASSWORD, ) .unwrap(); - let key = chrome_decrypt::get_encryption_key(&profile_dir) - .expect("should derive key from os_crypt_key file"); + let key = chrome_decrypt::get_encryption_key(profile_dir.path()) + .expect("should read the os_crypt_key file"); let decrypted = chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), SYNTHETIC_COOKIE_HOST, &key) .expect("decryption must succeed with correct key and host"); assert_eq!(decrypted, SYNTHETIC_COOKIE_VALUE); - - let _ = std::fs::remove_dir_all(&profile_dir); } #[test] - #[cfg(target_os = "macos")] fn test_decrypt_with_wrong_host_returns_none_or_raw() { - let profile_dir = - std::env::temp_dir().join(format!("donut_decrypt_wrong_host_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&profile_dir).unwrap(); - std::fs::write( - profile_dir.join("os_crypt_key"), - SYNTHETIC_OS_CRYPT_PASSWORD, - ) - .unwrap(); - - let key = chrome_decrypt::get_encryption_key(&profile_dir).unwrap(); - let result = - chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), ".wrong.example.test", &key); + let result = chrome_decrypt::decrypt( + &synthetic_encrypted_cookie(), + ".wrong.example.test", + &password_key(), + ); assert!( result.as_deref() != Some(SYNTHETIC_COOKIE_VALUE), "decrypt must not return the cookie value when host_key is wrong" ); + } - let _ = std::fs::remove_dir_all(&profile_dir); + #[test] + fn a_raw_key_file_opens_v10_as_aes_256_gcm() { + let profile_dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + profile_dir.path().join("os_crypt_key"), + unhex(SYNTHETIC_OS_CRYPT_RAW_KEY_HEX), + ) + .unwrap(); + let key = chrome_decrypt::get_encryption_key(profile_dir.path()).unwrap(); + assert_eq!( + open(V10_RAW_KEY_GCM_HEX, &key).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE) + ); + // The same record under a password file is not GCM, so it does not open. + assert_eq!(open(V10_RAW_KEY_GCM_HEX, &password_key()), None); + } + + #[test] + fn a_password_key_file_opens_v10_with_1003_iterations() { + assert_eq!( + open(SYNTHETIC_ENCRYPTED_COOKIE_HEX, &password_key()).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE) + ); + // A raw key file reads v10 as GCM, so this CBC record does not open there. + assert_eq!(open(SYNTHETIC_ENCRYPTED_COOKIE_HEX, &raw_key()), None); + } + + #[test] + fn every_key_file_opens_v11_with_one_iteration() { + assert_eq!( + open(V11_PASSWORD_HEX, &password_key()).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE) + ); + assert_eq!( + open(V11_RAW_KEY_HEX, &raw_key()).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE) + ); + } + + #[test] + fn cbc_records_fall_back_to_chromiums_fixed_keys() { + for key in [password_key(), raw_key()] { + assert_eq!( + open(V11_PEANUTS_FALLBACK_HEX, &key).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE), + "a v11 record sealed with the peanuts key must open" + ); + } + let key = password_key(); + assert_eq!( + open(V10_EMPTY_FALLBACK_HEX, &key).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE), + "a v10 record sealed with the empty password must open" + ); + assert_eq!( + open(V10_PEANUTS_FALLBACK_HEX, &key).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE), + "a v10 record sealed with the peanuts key must open" + ); + } + + #[test] + fn a_record_without_the_host_hash_reads_as_is() { + assert_eq!( + open(V10_PASSWORD_NO_HOST_HASH_HEX, &password_key()).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE) + ); + } + + #[test] + fn unknown_tags_and_empty_key_files_decrypt_nothing() { + let mut v20 = unhex(V10_RAW_KEY_GCM_HEX); + v20[..3].copy_from_slice(b"v20"); + assert_eq!( + chrome_decrypt::decrypt(&v20, SYNTHETIC_COOKIE_HOST, &raw_key()), + None + ); + assert!(chrome_decrypt::EncryptionKey::from_contents(Vec::new()).is_none()); + let empty_dir = tempfile::TempDir::new().unwrap(); + std::fs::write(empty_dir.path().join("os_crypt_key"), b"").unwrap(); + assert!(chrome_decrypt::get_encryption_key(empty_dir.path()).is_none()); + } + + #[test] + fn the_cookie_database_follows_chromiums_network_checkpoint() { + let dir = tempfile::TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + let network_dir = default_dir.join("Network"); + + // Nothing on disk: the host's own location. + let fresh = CookieManager::wayfern_cookie_path(dir.path()); + if cfg!(target_os = "windows") { + assert_eq!(fresh, network_dir.join("Cookies")); + } else { + assert_eq!(fresh, default_dir.join("Cookies")); + } + + // A profile Chromium has not migrated keeps its database in Default/. + std::fs::create_dir_all(&default_dir).unwrap(); + std::fs::write(default_dir.join("Cookies"), b"db").unwrap(); + assert_eq!( + CookieManager::wayfern_cookie_path(dir.path()), + default_dir.join("Cookies") + ); + + // Once migrated, every OS reads Network/, whatever Default/ still holds. + std::fs::create_dir_all(&network_dir).unwrap(); + std::fs::write(network_dir.join("Cookies"), b"db").unwrap(); + std::fs::write(network_dir.join("NetworkDataMigrated"), b"").unwrap(); + assert_eq!( + CookieManager::wayfern_cookie_path(dir.path()), + network_dir.join("Cookies") + ); } /// Regression: a brand-new Wayfern profile has no `Default/Cookies` file diff --git a/src-tauri/src/profile_import/os_crypt.rs b/src-tauri/src/profile_import/os_crypt.rs index 021cb998..641f371e 100644 --- a/src-tauri/src/profile_import/os_crypt.rs +++ b/src-tauri/src/profile_import/os_crypt.rs @@ -1,27 +1,30 @@ //! Key material for profile import. //! -//! Wayfern keeps os_crypt key material in `/os_crypt_key` -//! rather than the OS keyring, so a profile directory is self-contained and -//! portable. +//! A Donut profile keeps its os_crypt key material in +//! `/os_crypt_key` rather than the OS keyring, so a profile +//! directory is self-contained and portable. //! //! 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 Freedesktop secret service, which the profile directory does not +//! contain. Import has to open the source's lock and re-seal everything with +//! the profile's own key. //! -//! The on-disk format is per-platform and NOT interchangeable. The tag in each -//! record selects the derivation: +//! The key file holds one of Chromium's two key formats: the raw 32-byte +//! AES-256-GCM key Chromium uses on Windows, or the password Chromium feeds to +//! PBKDF2 on macOS and Linux. The format of the file and the tag of each record +//! select the cipher, the same on every host ([`key_for_tag`]): //! -//! | 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` | +//! | `os_crypt_key` | Tag | Cipher | Key | +//! |--------------------------|-------|--------------|-------------------------------------------| +//! | 32 raw bytes | `v10` | AES-256-GCM | the bytes | +//! | any other bytes | `v10` | AES-128-CBC | PBKDF2-HMAC-SHA1(contents, saltysalt, 1003) | +//! | any | `v11` | AES-128-CBC | PBKDF2-HMAC-SHA1(contents, saltysalt, 1) | //! -//! 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. +//! A new key file is minted in the host's format and records are written under +//! the host's tag (`v10` on macOS and Windows, `v11` on Linux), so a profile +//! created here reads exactly like one Chromium wrote on this OS. Linux writes +//! `v11`, not `v10`: Chromium's `PosixKeyProvider` owns `v10` there. use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit}; use aes_gcm::aead::{Aead, KeyInit, Payload}; @@ -39,13 +42,13 @@ type Aes128CbcDec = cbc::Decryptor; type Aes128CbcEnc = cbc::Encryptor; /// 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; +/// Length of a raw AES-256-GCM key file, the format Chromium uses on Windows. +pub const GCM_KEY_LEN: usize = 32; /// The `os_crypt_key` name, at the root of the user-data dir. pub const KEY_FILE_NAME: &str = "os_crypt_key"; @@ -65,12 +68,18 @@ pub const EMPTY_PASSWORD_KEY: [u8; 16] = [ #[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)] +/// `PBKDF2-HMAC-SHA1(password = "peanuts", salt = "saltysalt", iterations = 1)`. +/// +/// Linux Chromium seals `v10` records under this fixed key when it has no +/// other, and such records keep it when the profile moves to another OS, so +/// every AES-128-CBC decrypt falls back to it after [`EMPTY_PASSWORD_KEY`]. +pub const POSIX_FALLBACK_KEY: [u8; 16] = [ + 0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78, +]; + +/// PBKDF2 iteration counts: macOS Chromium derives its `v10` key with 1003, +/// Linux Chromium derives its keys with 1. 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. @@ -78,9 +87,6 @@ pub const POSIX_ITERATIONS: u32 = 1; /// `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 @@ -95,18 +101,54 @@ pub fn derive_key(password: &[u8], iterations: u32) -> [u8; 16] { key } +/// The key for records tagged `tag` in a profile whose `os_crypt_key` holds +/// `contents`, or `None` for an empty file or a tag no key file serves. The +/// file's format decides, never the host, so a profile written on any OS reads +/// the same everywhere: +/// +/// - 32 raw bytes: `v10` is AES-256-GCM keyed by the bytes. +/// - any other contents: `v10` is AES-128-CBC keyed by +/// PBKDF2-HMAC-SHA1(contents, "saltysalt", 1003). +/// - any contents: `v11` is AES-128-CBC keyed by +/// PBKDF2-HMAC-SHA1(contents, "saltysalt", 1). +pub fn key_for_tag(contents: &[u8], tag: &[u8]) -> Option { + if contents.is_empty() { + return None; + } + match tag { + b"v10" => Some(match <[u8; GCM_KEY_LEN]>::try_from(contents) { + Ok(raw) => CryptoKey::Aes256Gcm(raw), + Err(_) => CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)), + }), + b"v11" => Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS))), + _ => None, + } +} + /// One os_crypt cipher, keyed. Which variant applies is decided by the tag the -/// record carries, never by the host platform. +/// record carries and the format of the key file, 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 { + /// This key, then, for AES-128-CBC, the fixed keys Chromium retries a CBC + /// record with when it does not open: the empty password + /// ([`EMPTY_PASSWORD_KEY`]) and Linux's fixed password + /// ([`POSIX_FALLBACK_KEY`]). An AES-256-GCM key has no fallbacks. + pub fn with_fallbacks(&self) -> Vec { + match self { + Self::Aes128Cbc(_) => vec![ + self.clone(), + Self::Aes128Cbc(EMPTY_PASSWORD_KEY), + Self::Aes128Cbc(POSIX_FALLBACK_KEY), + ], + Self::Aes256Gcm(_) => vec![self.clone()], + } + } + /// Decrypt a *tagless* ciphertext (the caller has already stripped the /// 3-byte version prefix). pub fn decrypt(&self, ciphertext: &[u8]) -> Option> { @@ -191,37 +233,17 @@ impl TargetKey { } } - /// Build the key from the raw `os_crypt_key` file contents. + /// Build the key from the raw `os_crypt_key` file contents: the host's tag, + /// keyed by the file's format ([`key_for_tag`]). A key file another OS wrote + /// works as well as one minted here, so it is never replaced. /// - /// 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. + /// Returns `None` only for an empty file. fn from_file_contents(contents: &[u8]) -> Option { - 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(), - }) - } + let tag = Self::host_tag(); + Some(Self { + key: key_for_tag(contents, tag)?, + tag, + }) } /// Fresh key material in the host platform's `os_crypt_key` format. @@ -254,18 +276,12 @@ impl TargetKey { pub fn ensure(user_data_dir: &Path) -> Result { let key_file = user_data_dir.join(KEY_FILE_NAME); + // Any non-empty key file keys every tag, whichever OS wrote it, and the + // profile's existing records depend on it, so it is reused as it is. 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={} path=\"{}\"", - existing.len(), - key_file.display() - ); } std::fs::create_dir_all(user_data_dir) @@ -361,18 +377,15 @@ impl SourceKeyring { return Decrypted::Unrecoverable; }; - if let Some(plaintext) = key.decrypt(body) { - return Decrypted::Value(plaintext); + // Chromium's own fallbacks for CBC records sealed with a fixed key. + match key + .with_fallbacks() + .iter() + .find_map(|candidate| candidate.decrypt(body)) + { + Some(plaintext) => Decrypted::Value(plaintext), + None => Decrypted::Unrecoverable, } - - // 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 } } diff --git a/src-tauri/src/profile_import/os_crypt_tests.rs b/src-tauri/src/profile_import/os_crypt_tests.rs index 222883e9..317784a8 100644 --- a/src-tauri/src/profile_import/os_crypt_tests.rs +++ b/src-tauri/src/profile_import/os_crypt_tests.rs @@ -18,6 +18,94 @@ fn peanuts_key_matches_known_vector() { 0x78 ] ); + assert_eq!( + derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS), + POSIX_FALLBACK_KEY + ); +} + +fn key_bytes(key: &CryptoKey) -> Vec { + match key { + CryptoKey::Aes128Cbc(k) => k.to_vec(), + CryptoKey::Aes256Gcm(k) => k.to_vec(), + } +} + +#[test] +fn the_key_file_format_and_the_tag_choose_the_key_on_every_host() { + let raw = [0x42u8; GCM_KEY_LEN]; + let password = b"bG9jYWwtcGFzc3dvcmQhIQ=="; + + assert!(matches!(key_for_tag(&raw, b"v10"), Some(CryptoKey::Aes256Gcm(k)) if k == raw)); + assert!(matches!( + key_for_tag(password, b"v10"), + Some(CryptoKey::Aes128Cbc(k)) if k == derive_key(password, MAC_ITERATIONS) + )); + assert!(matches!( + key_for_tag(password, b"v11"), + Some(CryptoKey::Aes128Cbc(k)) if k == derive_key(password, POSIX_ITERATIONS) + )); + assert!(matches!( + key_for_tag(&raw, b"v11"), + Some(CryptoKey::Aes128Cbc(k)) if k == derive_key(&raw, POSIX_ITERATIONS) + )); + // One byte short of a raw key is a password. + assert!(matches!( + key_for_tag(&raw[..31], b"v10"), + Some(CryptoKey::Aes128Cbc(_)) + )); + assert!(key_for_tag(&raw, b"v20").is_none()); + assert!(key_for_tag(b"", b"v10").is_none()); +} + +#[test] +fn only_cbc_keys_carry_chromiums_fallbacks() { + let cbc = CryptoKey::Aes128Cbc([1u8; 16]); + let keys: Vec> = cbc.with_fallbacks().iter().map(key_bytes).collect(); + assert_eq!( + keys, + vec![ + vec![1u8; 16], + EMPTY_PASSWORD_KEY.to_vec(), + POSIX_FALLBACK_KEY.to_vec() + ] + ); + assert_eq!(CryptoKey::Aes256Gcm([1u8; 32]).with_fallbacks().len(), 1); +} + +#[test] +fn a_key_file_from_another_os_is_kept_and_written_with() { + // Both Chromium formats, on whichever host runs this: a profile synced from + // another OS must keep its key, or every record it already holds is lost. + let raw = vec![0x42u8; GCM_KEY_LEN]; + let password = b"bG9jYWwtcGFzc3dvcmQhIQ==".to_vec(); + for contents in [raw, password] { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join(KEY_FILE_NAME), &contents).unwrap(); + + let target = TargetKey::ensure(dir.path()).expect("an existing key file is usable"); + assert_eq!( + std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap(), + contents, + "an existing key file must never be replaced" + ); + + let sealed = target.encrypt(b"value").expect("encrypt"); + let (tag, body) = sealed.split_at(3); + assert_eq!(tag, TargetKey::host_tag()); + let key = key_for_tag(&contents, tag).expect("the tag is served"); + assert_eq!(key.decrypt(body).expect("decrypt"), b"value"); + } +} + +#[test] +fn an_empty_key_file_is_replaced_with_a_new_one() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join(KEY_FILE_NAME), b"").unwrap(); + TargetKey::ensure(dir.path()).expect("mint"); + assert!(!std::fs::read(dir.path().join(KEY_FILE_NAME)) + .unwrap() + .is_empty()); } #[test] @@ -167,6 +255,24 @@ fn empty_password_fallback_recovers_the_record() { } } +#[test] +fn peanuts_fallback_recovers_the_record() { + let sealed_body = CryptoKey::Aes128Cbc(POSIX_FALLBACK_KEY) + .encrypt(b"linux") + .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"linux"), + _ => panic!("peanuts 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]) {