Skip to main content

browser_commander/browser/migration/
firefox_nss.rs

1//! NSS decryption for Firefox saved logins, mirroring
2//! `js/src/browser/migration/firefox-nss.js`.
3//!
4//! Firefox does not use OSCrypt; it stores logins in `logins.json` and protects
5//! them with NSS. The decryption key lives in `key4.db`:
6//!
7//! - `metadata` (id `password`) holds a global salt and a password-check blob.
8//! - `nssPrivate` holds the encrypted 3DES key used for the actual logins.
9//!
10//! Two key-wrapping schemes appear in the wild:
11//!
12//! - Modern Firefox uses PKCS#5 PBES2 (PBKDF2-HMAC-SHA256 then AES-256-CBC).
13//!   The PBKDF2 password is `SHA1(globalSalt + primaryPassword)` and NSS stores
14//!   only 14 IV bytes, so the real 16-byte CBC IV is `0x04 0x0e` followed by
15//!   those bytes.
16//! - Legacy profiles use `pbeWithSha1AndTripleDES-CBC`: a SHA1-based KDF
17//!   derives a 3DES key and IV from the global salt, the entry salt and the
18//!   primary password.
19//!
20//! The individual login fields are always 3DES-CBC under the recovered key. If
21//! a primary password is set and no correct one is supplied, the
22//! password-check fails with [`PrimaryPasswordError`] and the caller reports
23//! `primary-password-set` instead of guessing.
24//!
25//! References:
26//! - <https://searchfox.org/mozilla-central/source/security/nss> (NSS sources)
27//! - <https://github.com/unode/firefox_decrypt> (the reference algorithm)
28
29use aes::Aes256;
30use anyhow::{anyhow, Context, Result};
31use base64::engine::general_purpose::STANDARD as BASE64;
32use base64::Engine;
33use cbc::cipher::block_padding::{NoPadding, Pkcs7};
34use cbc::cipher::{BlockModeDecrypt, KeyIvInit};
35use des::TdesEde3;
36use hmac::{Hmac, KeyInit, Mac};
37use pbkdf2::pbkdf2_hmac;
38use rusqlite::{Connection, OptionalExtension};
39use sha1::{Digest, Sha1};
40use sha2::Sha256;
41
42use super::firefox_der::{decode_der_element, der_children, oid_to_string, DerElement};
43
44type Aes256CbcDecryptor = cbc::Decryptor<Aes256>;
45type TdesCbcDecryptor = cbc::Decryptor<TdesEde3>;
46type HmacSha1 = Hmac<Sha1>;
47
48const OID_PBES2: &str = "1.2.840.113549.1.5.13";
49const OID_PBE_SHA1_3DES: &str = "1.2.840.113549.1.12.5.1.3";
50
51const PASSWORD_CHECK: &[u8] = b"password-check\x02\x02";
52
53/// A primary (master) password blocks decryption.
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
55#[error("Firefox primary password is set")]
56pub struct PrimaryPasswordError;
57
58fn child(children: &[DerElement], index: usize) -> Result<&DerElement> {
59    children
60        .get(index)
61        .ok_or_else(|| anyhow!("NSS DER structure is missing a field"))
62}
63
64fn integer_value(element: &DerElement) -> Result<u64> {
65    element.content.iter().try_fold(0_u64, |value, byte| {
66        value
67            .checked_mul(256)
68            .map(|value| value + u64::from(*byte))
69            .ok_or_else(|| anyhow!("NSS DER integer is too large"))
70    })
71}
72
73/// `SHA-1(globalSalt + primaryPassword)`.
74///
75/// This is not password storage: `key4.db` fixes the scheme as
76/// PBKDF2(SHA-1(globalSalt + primaryPassword)) (and the legacy SHA-1/HMAC KDF
77/// below), and reading the profile Firefox wrote requires reproducing that
78/// fixed NSS format byte for byte, so SHA-1 cannot be swapped for a slower
79/// hash.
80fn password_hash(global_salt: &[u8], primary_password: &[u8]) -> Vec<u8> {
81    Sha1::new()
82        .chain_update(global_salt)
83        .chain_update(primary_password)
84        .finalize()
85        .to_vec()
86}
87
88/// Decrypt a PBES2 (PBKDF2-HMAC-SHA256 + AES-256-CBC) blob NSS wrote.
89fn decrypt_pbes2(
90    algorithm_params: &DerElement,
91    ciphertext: &[u8],
92    global_salt: &[u8],
93    primary_password: &[u8],
94) -> Result<Vec<u8>> {
95    let scheme = der_children(algorithm_params)?;
96    let kdf = der_children(child(&scheme, 0)?)?;
97    let encryption_scheme = der_children(child(&scheme, 1)?)?;
98    let params = der_children(child(&kdf, 1)?)?;
99    let entry_salt = &child(&params, 0)?.content;
100    let iterations = u32::try_from(integer_value(child(&params, 1)?)?)
101        .context("NSS PBKDF2 iteration count is too large")?;
102    let key_length = usize::try_from(integer_value(child(&params, 2)?)?)
103        .ok()
104        .filter(|length| *length <= 1024)
105        .ok_or_else(|| anyhow!("NSS PBKDF2 key length is invalid"))?;
106    let iv_bytes = &child(&encryption_scheme, 1)?.content;
107
108    let hash = password_hash(global_salt, primary_password);
109    let mut key = vec![0_u8; key_length];
110    pbkdf2_hmac::<Sha256>(&hash, entry_salt, iterations, &mut key);
111    let iv = [&[0x04_u8, 0x0e][..], iv_bytes].concat();
112    Aes256CbcDecryptor::new_from_slices(&key, &iv)
113        .map_err(|_| anyhow!("NSS AES-256-CBC key or IV has the wrong length"))?
114        .decrypt_padded_vec::<Pkcs7>(ciphertext)
115        .map_err(|_| anyhow!("NSS AES-256-CBC padding is invalid"))
116}
117
118/// Strip PKCS#7 padding when the last byte is a plausible pad length, the way
119/// the reference algorithm does (without verifying every pad byte).
120fn pkcs7_unpad(mut buffer: Vec<u8>) -> Vec<u8> {
121    if let Some(&pad) = buffer.last() {
122        let pad = usize::from(pad);
123        if pad > 0 && pad <= buffer.len() {
124            buffer.truncate(buffer.len() - pad);
125        }
126    }
127    buffer
128}
129
130fn hmac_sha1(key: &[u8], parts: &[&[u8]]) -> Result<Vec<u8>> {
131    let mut mac = HmacSha1::new_from_slice(key).map_err(|_| anyhow!("invalid HMAC key"))?;
132    for part in parts {
133        mac.update(part);
134    }
135    Ok(mac.finalize().into_bytes().to_vec())
136}
137
138fn decrypt_3des_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
139    // Firefox's legacy NSS blobs are 3DES-CBC on disk. Migration only
140    // decrypts these source bytes; Chromium receives newly encrypted values.
141    let plaintext = TdesCbcDecryptor::new_from_slices(key, iv)
142        .map_err(|_| anyhow!("3DES key or IV has the wrong length"))?
143        .decrypt_padded_vec::<NoPadding>(ciphertext)
144        .map_err(|_| anyhow!("3DES ciphertext is not a whole number of blocks"))?;
145    Ok(pkcs7_unpad(plaintext))
146}
147
148/// Decrypt the legacy pbeWithSha1AndTripleDES-CBC key blob.
149fn decrypt_pbe_sha1_3des(
150    algorithm_params: &DerElement,
151    ciphertext: &[u8],
152    global_salt: &[u8],
153    primary_password: &[u8],
154) -> Result<Vec<u8>> {
155    let params = der_children(algorithm_params)?;
156    let entry_salt = &child(&params, 0)?.content;
157
158    // NSS's SHA1-based KDF for pbeWithSha1AndTripleDES-CBC. The entry salt is
159    // right-padded with zeros to 20 bytes, and the 3DES key and IV are derived
160    // through three chained HMAC-SHA1 rounds. The format is fixed by NSS.
161    let hp = password_hash(global_salt, primary_password);
162    let mut pes = entry_salt.clone();
163    pes.resize(20, 0);
164    let chp = Sha1::new()
165        .chain_update(&hp)
166        .chain_update(entry_salt)
167        .finalize()
168        .to_vec();
169    let k1 = hmac_sha1(&chp, &[&pes, entry_salt])?;
170    let tk = hmac_sha1(&chp, &[&pes])?;
171    let k2 = hmac_sha1(&chp, &[&tk, entry_salt])?;
172    let derived = [k1, k2].concat();
173    let key = &derived[..24];
174    let iv = &derived[derived.len() - 8..];
175    decrypt_3des_cbc(key, iv, ciphertext)
176}
177
178fn decrypt_nss_blob(blob: &[u8], global_salt: &[u8], primary_password: &[u8]) -> Result<Vec<u8>> {
179    let top = decode_der_element(blob, 0)?;
180    let top_children = der_children(&top)?;
181    let algorithm = der_children(child(&top_children, 0)?)?;
182    let ciphertext = &child(&top_children, 1)?.content;
183    let oid = oid_to_string(&child(&algorithm, 0)?.content);
184    let params = child(&algorithm, 1)?;
185    match oid.as_str() {
186        OID_PBES2 => decrypt_pbes2(params, ciphertext, global_salt, primary_password),
187        OID_PBE_SHA1_3DES => {
188            decrypt_pbe_sha1_3des(params, ciphertext, global_salt, primary_password)
189        }
190        _ => Err(anyhow!("Unsupported NSS key algorithm {oid}")),
191    }
192}
193
194/// Recover the 24-byte 3DES login key from an open, read-only `key4.db`.
195/// `primary_password` is empty when no primary password is set.
196pub(crate) fn recover_firefox_key_from_database(
197    database: &Connection,
198    primary_password: &[u8],
199) -> Result<Vec<u8>> {
200    let meta: Option<(Vec<u8>, Vec<u8>)> = database
201        .query_row(
202            "SELECT item1, item2 FROM metadata WHERE id = 'password'",
203            [],
204            |row| Ok((row.get(0)?, row.get(1)?)),
205        )
206        .optional()?;
207    let (global_salt, check_blob) =
208        meta.ok_or_else(|| anyhow!("key4.db has no password metadata"))?;
209    // A wrong or missing primary password makes the CBC padding check fail;
210    // treat any decryption failure of the password-check blob as a locked key.
211    let check = decrypt_nss_blob(&check_blob, &global_salt, primary_password)
212        .map_err(|_| anyhow!(PrimaryPasswordError))?;
213    if !check.starts_with(PASSWORD_CHECK) {
214        return Err(anyhow!(PrimaryPasswordError));
215    }
216    let private: Option<Vec<u8>> = database
217        .query_row("SELECT a11, a102 FROM nssPrivate", [], |row| row.get(0))
218        .optional()?;
219    let private = private.ok_or_else(|| anyhow!("key4.db has no nssPrivate key"))?;
220    let mut decrypted = decrypt_nss_blob(&private, &global_salt, primary_password)?;
221    decrypted.truncate(24);
222    Ok(decrypted)
223}
224
225/// Decrypt one NSS-protected `logins.json` field (username or password).
226pub(crate) fn decrypt_firefox_field(base64_value: &str, key: &[u8]) -> Result<String> {
227    let blob = BASE64
228        .decode(base64_value)
229        .context("NSS login field is not base64")?;
230    let top = decode_der_element(&blob, 0)?;
231    let top_children = der_children(&top)?;
232    let algorithm = der_children(child(&top_children, 1)?)?;
233    let iv = &child(&algorithm, 1)?.content;
234    let ciphertext = &child(&top_children, 2)?.content;
235    let plaintext = decrypt_3des_cbc(key, iv, ciphertext)?;
236    String::from_utf8(plaintext).context("decrypted login field is not UTF-8")
237}