//! 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. //! //! 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. The tag in each //! record selects the derivation: //! //! | 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; 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; /// 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> { 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::(&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::::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> { 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::(&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::::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 { 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 { #[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: a key the browser cannot persist would live only in /// memory, die with the process, and orphan everything written with it. /// Failing here instead turns that silent data loss into a visible import /// error. pub fn ensure(user_data_dir: &Path) -> Result { 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> { 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), /// 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, pub v11: Option, /// 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, } 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)] #[path = "os_crypt_tests.rs"] mod tests;