Skip to main content

dpapi_core/
chrome.rs

1use forensicnomicon::dpapi::{CHROME_COOKIE_V10, CHROME_COOKIE_V20};
2
3use crate::error::DpapiError;
4
5/// How a Chrome/Chromium cookie value is encoded in heap memory.
6#[derive(Debug, PartialEq)]
7pub enum ChromeCookieEncoding {
8    /// Plaintext — no encryption prefix detected.
9    Raw,
10    /// Classic DPAPI blob (prefix `DPAPI`, 5 bytes). Windows 7 / no App-Bound.
11    DpapiBlob(Vec<u8>),
12    /// AES-256-GCM v10: `v10` + 12-byte nonce + ciphertext + 16-byte tag.
13    V10 {
14        nonce: [u8; 12],
15        ciphertext: Vec<u8>,
16    },
17    /// AES-256-GCM v20 (Chrome 127+): same wire format as v10.
18    V20 {
19        nonce: [u8; 12],
20        ciphertext: Vec<u8>,
21    },
22}
23
24/// Detect the encoding of a raw `encrypted_value` blob from Chrome's Cookies DB.
25pub fn detect_chrome_cookie_encoding(data: &[u8]) -> ChromeCookieEncoding {
26    // v10/v20 require at least 3 (prefix) + 12 (nonce) = 15 bytes
27    if data.len() > 15 {
28        if data.starts_with(CHROME_COOKIE_V20) {
29            let mut nonce = [0u8; 12];
30            nonce.copy_from_slice(&data[3..15]);
31            return ChromeCookieEncoding::V20 {
32                nonce,
33                ciphertext: data[15..].to_vec(),
34            };
35        }
36        if data.starts_with(CHROME_COOKIE_V10) {
37            let mut nonce = [0u8; 12];
38            nonce.copy_from_slice(&data[3..15]);
39            return ChromeCookieEncoding::V10 {
40                nonce,
41                ciphertext: data[15..].to_vec(),
42            };
43        }
44    }
45    if data.starts_with(b"DPAPI") {
46        return ChromeCookieEncoding::DpapiBlob(data[5..].to_vec());
47    }
48    ChromeCookieEncoding::Raw
49}
50
51/// Decrypt a v10/v20 AES-256-GCM cookie value.
52/// `key` is the 32-byte AES key from Chrome's `Local State` (already decrypted).
53pub fn decrypt_v10_cookie(
54    nonce: &[u8; 12],
55    ciphertext: &[u8],
56    key: &[u8; 32],
57) -> Result<Vec<u8>, DpapiError> {
58    #[allow(deprecated)]
59    // from_slice deprecated in generic-array 1.x; aes-gcm 0.10 still uses 0.14
60    use aes_gcm::{
61        aead::{Aead, Nonce},
62        Aes256Gcm, KeyInit,
63    };
64    let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| DpapiError::InvalidKeyLength)?;
65    #[allow(deprecated)]
66    let nonce_ga = Nonce::<Aes256Gcm>::from_slice(nonce);
67    cipher
68        .decrypt(nonce_ga, ciphertext)
69        .map_err(|_| DpapiError::DecryptionFailed)
70}
71
72/// Decode a Chrome/Edge `Local State` `os_crypt.encrypted_key` value into the
73/// inner DPAPI blob bytes.
74///
75/// `encrypted_key_b64` is the raw base64 string (the JSON field value bytes).
76/// Chrome stores `base64("DPAPI" + dpapi_blob)`, so this base64-decodes the input
77/// and strips the mandatory 5-byte `DPAPI` prefix, returning the DPAPI blob ready
78/// for [`decrypt_local_state_key`].
79///
80/// Errors loudly — malformed base64 ([`DpapiError::Base64Error`]) or a missing
81/// `DPAPI` prefix ([`DpapiError::MissingDpapiPrefix`], which names the bytes that
82/// were actually present) — rather than guessing.
83pub fn parse_local_state_encrypted_key(encrypted_key_b64: &[u8]) -> Result<Vec<u8>, DpapiError> {
84    use base64::{engine::general_purpose::STANDARD, Engine as _};
85
86    use std::fmt::Write as _;
87
88    let decoded = STANDARD
89        .decode(encrypted_key_b64)
90        .map_err(|_| DpapiError::Base64Error)?;
91    if let Some(blob) = decoded.strip_prefix(b"DPAPI") {
92        Ok(blob.to_vec())
93    } else {
94        // Surface the bytes that WERE there (Show-the-unrecognized-value).
95        let mut shown = String::new();
96        for b in decoded.iter().take(8) {
97            let _ = write!(shown, "{b:02x}");
98        }
99        Err(DpapiError::MissingDpapiPrefix(shown))
100    }
101}
102
103/// Decrypt a Chrome/Edge `Local State` cookie key from its DPAPI blob bytes.
104///
105/// `blob_bytes` is the inner DPAPI blob (post [`parse_local_state_encrypted_key`]).
106/// `master_key` is the 64-byte user master key (from `masterkey.rs`). Parses the
107/// blob, decrypts it with the master key (no entropy), and requires the recovered
108/// plaintext to be exactly the 32-byte AES-256 cookie key.
109///
110/// A wrong/absent master key fails the blob's Sign-HMAC and returns a
111/// [`DpapiError`] — it never returns garbage or a fabricated key. A plaintext of
112/// the wrong length is rejected with [`DpapiError::UnexpectedKeyLength`].
113pub fn decrypt_local_state_key(
114    blob_bytes: &[u8],
115    master_key: &[u8],
116) -> Result<[u8; 32], DpapiError> {
117    let blob = crate::blob::parse_dpapi_blob(blob_bytes)?;
118    // No entropy for the Local State key blob. A wrong/absent master key fails the
119    // blob's Sign-HMAC inside decrypt_dpapi_blob and propagates as an error here.
120    let plaintext = crate::decrypt::decrypt_dpapi_blob(&blob, master_key, None)?;
121    plaintext
122        .as_slice()
123        .try_into()
124        .map_err(|_| DpapiError::UnexpectedKeyLength {
125            expected: 32,
126            got: plaintext.len(),
127        })
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    /// Decode a hex string into bytes (test helper).
135    fn hex(s: &str) -> Vec<u8> {
136        (0..s.len())
137            .step_by(2)
138            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
139            .collect()
140    }
141
142    // --- impacket 0.13.1 oracle vector (provenance: tests/data/README.md) ---
143    // Master key = the tier-1 impacket-validated key from decrypt.rs.
144    const MASTER_KEY_HEX: &str = "9828d9873735439e823dbd216205ff88266d28ad685a413970c640d5ee943154bbade31fada673d542c72d707a163bb3d1bceb0c50465b359ae06998481b0ce3";
145    // The `Local State` `encrypted_key` (base64 of "DPAPI" + this DPAPI blob).
146    // Minted + confirmed by impacket DPAPI_BLOB.decrypt -> COOKIE_KEY below.
147    const LOCAL_STATE_BLOB_HEX: &str = "01000000d08c9ddf0115d1118c7a00c04fc297eb0100000033f19f5ee340be4a8a2e2b4e62bd0cc60000000002000000000010660000000100002000000000112233445566778899aabbccddeeff00112233445566778899aabbccddeeff000000000e80000000020000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000fadff9dbad57d443d7a0d77cf9fbfdd68827ce91b2ca6ff533bf2f7467ce329865d99ceb7b841f271e14d1f508ce3a2c40000000fe727776259faf7d3100849fb4ea49fc69fc16bebd1ec98b5a5227b4cfafbce2983ff94a57afd2f2ba1f9afa32ae3aa2148c10f2f3016ccabc3e71c6f26dd6c0";
148    // base64("DPAPI" + LOCAL_STATE_BLOB) — the exact `os_crypt.encrypted_key` string.
149    const ENCRYPTED_KEY_B64: &str = "RFBBUEkBAAAA0Iyd3wEV0RGMegDAT8KX6wEAAAAz8Z9e40C+SoouK05ivQzGAAAAAAIAAAAAABBmAAAAAQAAIAAAAAARIjNEVWZ3iJmqu8zd7v8AESIzRFVmd4iZqrvM3e7/AAAAAA6AAAAAAgAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAA+t/5261X1EPXoNd8+fv91ognzpGyym/1M78vdGfOMphl2Zzre4QfJx4U0fUIzjosQAAAAP5yd3Yln699MQCEn7TqSfxp/Ba+vR7Ji1pSJ7TPr7zimD/5Slev0vK6H5r6Mq46ohSMEPLzAWzKvD5xxvJt1sA=";
150    // The 32-byte cookie key impacket recovers from the blob (bytes 0x20..0x3f).
151    const COOKIE_KEY_HEX: &str = "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f";
152    // A `v10` cookie produced with COOKIE_KEY: "v10" + nonce(12) + GCM(ct||tag).
153    const V10_COOKIE_HEX: &str = "7631300102030405060708090a0b0c1b5af334ffe7a1fe676c5ab453c8848232ab94aa630c69bae71883958ba23e4dfe4cc5faff526ce54b";
154    const V10_PLAINTEXT: &[u8] = b"forensic-session-token-42";
155
156    // RED: base64-decode + strip "DPAPI" must yield the exact DPAPI blob bytes.
157    #[test]
158    fn local_state_b64_strips_dpapi_prefix_to_blob() {
159        let blob =
160            parse_local_state_encrypted_key(ENCRYPTED_KEY_B64.as_bytes()).expect("decode ok");
161        assert_eq!(blob, hex(LOCAL_STATE_BLOB_HEX));
162    }
163
164    // RED: the recovered cookie key must equal impacket's 32-byte plaintext.
165    #[test]
166    fn decrypt_local_state_key_matches_impacket() {
167        let blob = hex(LOCAL_STATE_BLOB_HEX);
168        let mk = hex(MASTER_KEY_HEX);
169        let key = decrypt_local_state_key(&blob, &mk).expect("decrypt ok");
170        assert_eq!(key.to_vec(), hex(COOKIE_KEY_HEX));
171    }
172
173    // RED: full chain — Local State key then v10 cookie -> known plaintext.
174    #[test]
175    fn end_to_end_local_state_then_v10_cookie() {
176        let blob =
177            parse_local_state_encrypted_key(ENCRYPTED_KEY_B64.as_bytes()).expect("decode ok");
178        let mk = hex(MASTER_KEY_HEX);
179        let key = decrypt_local_state_key(&blob, &mk).expect("decrypt ok");
180
181        let raw = hex(V10_COOKIE_HEX);
182        let enc = detect_chrome_cookie_encoding(&raw);
183        let ChromeCookieEncoding::V10 { nonce, ciphertext } = enc else {
184            panic!("expected v10 encoding");
185        };
186        let plaintext = decrypt_v10_cookie(&nonce, &ciphertext, &key).expect("gcm ok");
187        assert_eq!(plaintext, V10_PLAINTEXT);
188    }
189
190    // RED: refuse, don't fabricate — a good blob with NO usable master key (an
191    // all-zero key) must fail the Sign-HMAC and error, never return a key.
192    #[test]
193    fn no_usable_master_key_refuses_rather_than_fabricates() {
194        let blob = hex(LOCAL_STATE_BLOB_HEX);
195        let bad_mk = [0u8; 64];
196        let result = decrypt_local_state_key(&blob, &bad_mk);
197        assert!(
198            result.is_err(),
199            "must error on an unusable master key, never fabricate a cookie key"
200        );
201    }
202
203    #[test]
204    fn detect_v10_prefix() {
205        let mut data = vec![0u8; 20];
206        data[0..3].copy_from_slice(b"v10");
207        let enc = detect_chrome_cookie_encoding(&data);
208        assert!(matches!(enc, ChromeCookieEncoding::V10 { .. }));
209    }
210
211    #[test]
212    fn detect_v20_prefix() {
213        let mut data = vec![0u8; 20];
214        data[0..3].copy_from_slice(b"v20");
215        let enc = detect_chrome_cookie_encoding(&data);
216        assert!(matches!(enc, ChromeCookieEncoding::V20 { .. }));
217    }
218
219    #[test]
220    fn detect_dpapi_prefix() {
221        let data = b"DPAPI\x00\x01\x02\x03".to_vec();
222        let enc = detect_chrome_cookie_encoding(&data);
223        assert!(matches!(enc, ChromeCookieEncoding::DpapiBlob(_)));
224    }
225
226    #[test]
227    fn detect_plaintext_is_raw() {
228        let enc = detect_chrome_cookie_encoding(b"plaintext_value");
229        assert_eq!(enc, ChromeCookieEncoding::Raw);
230    }
231
232    #[test]
233    #[allow(deprecated)]
234    fn decrypt_v10_roundtrip() {
235        use aes_gcm::{
236            aead::{Aead, Nonce},
237            Aes256Gcm, KeyInit,
238        };
239        let key = [0x42u8; 32];
240        let nonce_bytes = [0x11u8; 12];
241        let plaintext = b"session_token_value";
242        let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
243        #[allow(deprecated)]
244        let nonce = Nonce::<Aes256Gcm>::from_slice(&nonce_bytes);
245        let ciphertext = cipher.encrypt(nonce, plaintext.as_ref()).unwrap();
246        let recovered = decrypt_v10_cookie(&nonce_bytes, &ciphertext, &key).expect("ok");
247        assert_eq!(recovered, plaintext);
248    }
249}