dpapi-core 0.2.0

Pure-Rust, byte-oriented DPAPI library — parse DPAPI_BLOB, decrypt given a master key, and unwrap Chrome/Edge v10/v20 cookies, over any &[u8] source
Documentation
use forensicnomicon::dpapi::{CHROME_COOKIE_V10, CHROME_COOKIE_V20};

use crate::error::DpapiError;

/// How a Chrome/Chromium cookie value is encoded in heap memory.
#[derive(Debug, PartialEq)]
pub enum ChromeCookieEncoding {
    /// Plaintext — no encryption prefix detected.
    Raw,
    /// Classic DPAPI blob (prefix `DPAPI`, 5 bytes). Windows 7 / no App-Bound.
    DpapiBlob(Vec<u8>),
    /// AES-256-GCM v10: `v10` + 12-byte nonce + ciphertext + 16-byte tag.
    V10 {
        nonce: [u8; 12],
        ciphertext: Vec<u8>,
    },
    /// AES-256-GCM v20 (Chrome 127+): same wire format as v10.
    V20 {
        nonce: [u8; 12],
        ciphertext: Vec<u8>,
    },
}

/// Detect the encoding of a raw `encrypted_value` blob from Chrome's Cookies DB.
pub fn detect_chrome_cookie_encoding(data: &[u8]) -> ChromeCookieEncoding {
    // v10/v20 require at least 3 (prefix) + 12 (nonce) = 15 bytes
    if data.len() > 15 {
        if data.starts_with(CHROME_COOKIE_V20) {
            let mut nonce = [0u8; 12];
            nonce.copy_from_slice(&data[3..15]);
            return ChromeCookieEncoding::V20 {
                nonce,
                ciphertext: data[15..].to_vec(),
            };
        }
        if data.starts_with(CHROME_COOKIE_V10) {
            let mut nonce = [0u8; 12];
            nonce.copy_from_slice(&data[3..15]);
            return ChromeCookieEncoding::V10 {
                nonce,
                ciphertext: data[15..].to_vec(),
            };
        }
    }
    if data.starts_with(b"DPAPI") {
        return ChromeCookieEncoding::DpapiBlob(data[5..].to_vec());
    }
    ChromeCookieEncoding::Raw
}

/// Decrypt a v10/v20 AES-256-GCM cookie value.
/// `key` is the 32-byte AES key from Chrome's `Local State` (already decrypted).
pub fn decrypt_v10_cookie(
    nonce: &[u8; 12],
    ciphertext: &[u8],
    key: &[u8; 32],
) -> Result<Vec<u8>, DpapiError> {
    #[allow(deprecated)]
    // from_slice deprecated in generic-array 1.x; aes-gcm 0.10 still uses 0.14
    use aes_gcm::{
        aead::{Aead, Nonce},
        Aes256Gcm, KeyInit,
    };
    let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| DpapiError::InvalidKeyLength)?;
    #[allow(deprecated)]
    let nonce_ga = Nonce::<Aes256Gcm>::from_slice(nonce);
    cipher
        .decrypt(nonce_ga, ciphertext)
        .map_err(|_| DpapiError::DecryptionFailed)
}

/// Decode a Chrome/Edge `Local State` `os_crypt.encrypted_key` value into the
/// inner DPAPI blob bytes.
///
/// `encrypted_key_b64` is the raw base64 string (the JSON field value bytes).
/// Chrome stores `base64("DPAPI" + dpapi_blob)`, so this base64-decodes the input
/// and strips the mandatory 5-byte `DPAPI` prefix, returning the DPAPI blob ready
/// for [`decrypt_local_state_key`].
///
/// Errors loudly — malformed base64 ([`DpapiError::Base64Error`]) or a missing
/// `DPAPI` prefix ([`DpapiError::MissingDpapiPrefix`], which names the bytes that
/// were actually present) — rather than guessing.
pub fn parse_local_state_encrypted_key(encrypted_key_b64: &[u8]) -> Result<Vec<u8>, DpapiError> {
    use base64::{engine::general_purpose::STANDARD, Engine as _};

    use std::fmt::Write as _;

    let decoded = STANDARD
        .decode(encrypted_key_b64)
        .map_err(|_| DpapiError::Base64Error)?;
    if let Some(blob) = decoded.strip_prefix(b"DPAPI") {
        Ok(blob.to_vec())
    } else {
        // Surface the bytes that WERE there (Show-the-unrecognized-value).
        let mut shown = String::new();
        for b in decoded.iter().take(8) {
            let _ = write!(shown, "{b:02x}");
        }
        Err(DpapiError::MissingDpapiPrefix(shown))
    }
}

/// Decrypt a Chrome/Edge `Local State` cookie key from its DPAPI blob bytes.
///
/// `blob_bytes` is the inner DPAPI blob (post [`parse_local_state_encrypted_key`]).
/// `master_key` is the 64-byte user master key (from `masterkey.rs`). Parses the
/// blob, decrypts it with the master key (no entropy), and requires the recovered
/// plaintext to be exactly the 32-byte AES-256 cookie key.
///
/// A wrong/absent master key fails the blob's Sign-HMAC and returns a
/// [`DpapiError`] — it never returns garbage or a fabricated key. A plaintext of
/// the wrong length is rejected with [`DpapiError::UnexpectedKeyLength`].
pub fn decrypt_local_state_key(
    blob_bytes: &[u8],
    master_key: &[u8],
) -> Result<[u8; 32], DpapiError> {
    let blob = crate::blob::parse_dpapi_blob(blob_bytes)?;
    // No entropy for the Local State key blob. A wrong/absent master key fails the
    // blob's Sign-HMAC inside decrypt_dpapi_blob and propagates as an error here.
    let plaintext = crate::decrypt::decrypt_dpapi_blob(&blob, master_key, None)?;
    plaintext
        .as_slice()
        .try_into()
        .map_err(|_| DpapiError::UnexpectedKeyLength {
            expected: 32,
            got: plaintext.len(),
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Decode a hex string into bytes (test helper).
    fn hex(s: &str) -> Vec<u8> {
        (0..s.len())
            .step_by(2)
            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
            .collect()
    }

    // --- impacket 0.13.1 oracle vector (provenance: tests/data/README.md) ---
    // Master key = the tier-1 impacket-validated key from decrypt.rs.
    const MASTER_KEY_HEX: &str = "9828d9873735439e823dbd216205ff88266d28ad685a413970c640d5ee943154bbade31fada673d542c72d707a163bb3d1bceb0c50465b359ae06998481b0ce3";
    // The `Local State` `encrypted_key` (base64 of "DPAPI" + this DPAPI blob).
    // Minted + confirmed by impacket DPAPI_BLOB.decrypt -> COOKIE_KEY below.
    const LOCAL_STATE_BLOB_HEX: &str = "01000000d08c9ddf0115d1118c7a00c04fc297eb0100000033f19f5ee340be4a8a2e2b4e62bd0cc60000000002000000000010660000000100002000000000112233445566778899aabbccddeeff00112233445566778899aabbccddeeff000000000e80000000020000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000fadff9dbad57d443d7a0d77cf9fbfdd68827ce91b2ca6ff533bf2f7467ce329865d99ceb7b841f271e14d1f508ce3a2c40000000fe727776259faf7d3100849fb4ea49fc69fc16bebd1ec98b5a5227b4cfafbce2983ff94a57afd2f2ba1f9afa32ae3aa2148c10f2f3016ccabc3e71c6f26dd6c0";
    // base64("DPAPI" + LOCAL_STATE_BLOB) — the exact `os_crypt.encrypted_key` string.
    const ENCRYPTED_KEY_B64: &str = "RFBBUEkBAAAA0Iyd3wEV0RGMegDAT8KX6wEAAAAz8Z9e40C+SoouK05ivQzGAAAAAAIAAAAAABBmAAAAAQAAIAAAAAARIjNEVWZ3iJmqu8zd7v8AESIzRFVmd4iZqrvM3e7/AAAAAA6AAAAAAgAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAA+t/5261X1EPXoNd8+fv91ognzpGyym/1M78vdGfOMphl2Zzre4QfJx4U0fUIzjosQAAAAP5yd3Yln699MQCEn7TqSfxp/Ba+vR7Ji1pSJ7TPr7zimD/5Slev0vK6H5r6Mq46ohSMEPLzAWzKvD5xxvJt1sA=";
    // The 32-byte cookie key impacket recovers from the blob (bytes 0x20..0x3f).
    const COOKIE_KEY_HEX: &str = "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f";
    // A `v10` cookie produced with COOKIE_KEY: "v10" + nonce(12) + GCM(ct||tag).
    const V10_COOKIE_HEX: &str = "7631300102030405060708090a0b0c1b5af334ffe7a1fe676c5ab453c8848232ab94aa630c69bae71883958ba23e4dfe4cc5faff526ce54b";
    const V10_PLAINTEXT: &[u8] = b"forensic-session-token-42";

    // RED: base64-decode + strip "DPAPI" must yield the exact DPAPI blob bytes.
    #[test]
    fn local_state_b64_strips_dpapi_prefix_to_blob() {
        let blob =
            parse_local_state_encrypted_key(ENCRYPTED_KEY_B64.as_bytes()).expect("decode ok");
        assert_eq!(blob, hex(LOCAL_STATE_BLOB_HEX));
    }

    // RED: the recovered cookie key must equal impacket's 32-byte plaintext.
    #[test]
    fn decrypt_local_state_key_matches_impacket() {
        let blob = hex(LOCAL_STATE_BLOB_HEX);
        let mk = hex(MASTER_KEY_HEX);
        let key = decrypt_local_state_key(&blob, &mk).expect("decrypt ok");
        assert_eq!(key.to_vec(), hex(COOKIE_KEY_HEX));
    }

    // RED: full chain — Local State key then v10 cookie -> known plaintext.
    #[test]
    fn end_to_end_local_state_then_v10_cookie() {
        let blob =
            parse_local_state_encrypted_key(ENCRYPTED_KEY_B64.as_bytes()).expect("decode ok");
        let mk = hex(MASTER_KEY_HEX);
        let key = decrypt_local_state_key(&blob, &mk).expect("decrypt ok");

        let raw = hex(V10_COOKIE_HEX);
        let enc = detect_chrome_cookie_encoding(&raw);
        let ChromeCookieEncoding::V10 { nonce, ciphertext } = enc else {
            panic!("expected v10 encoding");
        };
        let plaintext = decrypt_v10_cookie(&nonce, &ciphertext, &key).expect("gcm ok");
        assert_eq!(plaintext, V10_PLAINTEXT);
    }

    // RED: refuse, don't fabricate — a good blob with NO usable master key (an
    // all-zero key) must fail the Sign-HMAC and error, never return a key.
    #[test]
    fn no_usable_master_key_refuses_rather_than_fabricates() {
        let blob = hex(LOCAL_STATE_BLOB_HEX);
        let bad_mk = [0u8; 64];
        let result = decrypt_local_state_key(&blob, &bad_mk);
        assert!(
            result.is_err(),
            "must error on an unusable master key, never fabricate a cookie key"
        );
    }

    #[test]
    fn detect_v10_prefix() {
        let mut data = vec![0u8; 20];
        data[0..3].copy_from_slice(b"v10");
        let enc = detect_chrome_cookie_encoding(&data);
        assert!(matches!(enc, ChromeCookieEncoding::V10 { .. }));
    }

    #[test]
    fn detect_v20_prefix() {
        let mut data = vec![0u8; 20];
        data[0..3].copy_from_slice(b"v20");
        let enc = detect_chrome_cookie_encoding(&data);
        assert!(matches!(enc, ChromeCookieEncoding::V20 { .. }));
    }

    #[test]
    fn detect_dpapi_prefix() {
        let data = b"DPAPI\x00\x01\x02\x03".to_vec();
        let enc = detect_chrome_cookie_encoding(&data);
        assert!(matches!(enc, ChromeCookieEncoding::DpapiBlob(_)));
    }

    #[test]
    fn detect_plaintext_is_raw() {
        let enc = detect_chrome_cookie_encoding(b"plaintext_value");
        assert_eq!(enc, ChromeCookieEncoding::Raw);
    }

    #[test]
    #[allow(deprecated)]
    fn decrypt_v10_roundtrip() {
        use aes_gcm::{
            aead::{Aead, Nonce},
            Aes256Gcm, KeyInit,
        };
        let key = [0x42u8; 32];
        let nonce_bytes = [0x11u8; 12];
        let plaintext = b"session_token_value";
        let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
        #[allow(deprecated)]
        let nonce = Nonce::<Aes256Gcm>::from_slice(&nonce_bytes);
        let ciphertext = cipher.encrypt(nonce, plaintext.as_ref()).unwrap();
        let recovered = decrypt_v10_cookie(&nonce_bytes, &ciphertext, &key).expect("ok");
        assert_eq!(recovered, plaintext);
    }
}