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
//! Windows **Wi-Fi** (`Wlansvc`) WPA/WPA2 PSK decoder.
//!
//! WLAN profiles live at
//! `%PROGRAMDATA%\Microsoft\Wlansvc\Profiles\Interfaces\<GUID>\<GUID>.xml`. A
//! protected profile stores the pre-shared key in `<keyMaterial>` as the
//! **hex-encoded bytes of a DPAPI blob**, protected by the SYSTEM (`Wlansvc`)
//! master key. Decrypting it yields the PSK as a UTF-8 string terminated with a
//! NUL.
//!
//! This module owns the hex-decode + PSK extraction; the DPAPI decrypt reuses
//! [`crate::decrypt::decrypt_dpapi_blob`] (RustCrypto, no hand-rolled crypto). A
//! thin [`extract_key_material`] helper pulls the `<keyMaterial>` text out of the
//! profile XML so callers need no XML parser, but the PSK decode is the core and
//! takes the hex directly.

use crate::error::DpapiError;

/// Extract the `<keyMaterial>` hex text from a WLAN profile XML, if present.
///
/// A minimal, robust scan for the first `<keyMaterial>...</keyMaterial>` element
/// (the profile schema has exactly one). Returns `None` when the tag is absent
/// (e.g. an open network with no key). Whitespace around the value is trimmed.
pub fn extract_key_material(profile_xml: &str) -> Option<&str> {
    const OPEN: &str = "<keyMaterial>";
    const CLOSE: &str = "</keyMaterial>";
    let start = profile_xml.find(OPEN)? + OPEN.len();
    let rest = profile_xml.get(start..)?;
    let end = rest.find(CLOSE)?;
    Some(rest.get(..end)?.trim())
}

/// Decrypt a WLAN `<keyMaterial>` hex string into the plaintext PSK.
///
/// `key_material_hex` is the hex text from `<keyMaterial>` (case-insensitive).
/// `master_key` is the 64-byte SYSTEM (`Wlansvc`) master key. Hex-decodes to the
/// DPAPI blob, decrypts it (no entropy) via [`crate::decrypt::decrypt_dpapi_blob`],
/// strips the trailing NUL, and UTF-8-decodes the PSK.
///
/// A wrong/absent master key fails the blob's Sign-HMAC and returns a
/// [`DpapiError`] — it never returns a fabricated/empty PSK.
pub fn decrypt_wlan_key_material(
    key_material_hex: &str,
    master_key: &[u8],
) -> Result<String, DpapiError> {
    let blob_bytes = hex_decode(key_material_hex)?;
    let blob = crate::blob::parse_dpapi_blob(&blob_bytes)?;
    let cleartext = crate::decrypt::decrypt_dpapi_blob(&blob, master_key, None)?;
    // Windows stores the PSK as UTF-8 with a trailing NUL.
    let trimmed = cleartext
        .iter()
        .position(|&b| b == 0)
        .map_or(cleartext.as_slice(), |nul| &cleartext[..nul]);
    String::from_utf8(trimmed.to_vec()).map_err(|_| DpapiError::Utf16Error)
}

/// Decode an ASCII hex string into bytes. Rejects odd length or non-hex digits
/// loudly (with the offending value range) rather than guessing.
fn hex_decode(s: &str) -> Result<Vec<u8>, DpapiError> {
    let bytes = s.as_bytes();
    if bytes.len() % 2 != 0 {
        return Err(DpapiError::InvalidHex(format!(
            "odd length ({} chars)",
            bytes.len()
        )));
    }
    let mut out = Vec::with_capacity(bytes.len() / 2);
    let mut i = 0;
    while i < bytes.len() {
        let hi = hex_nibble(bytes[i])?;
        let lo = hex_nibble(bytes[i + 1])?;
        out.push((hi << 4) | lo);
        i += 2;
    }
    Ok(out)
}

/// Convert one ASCII hex digit to its nibble; non-hex bytes error with the value.
fn hex_nibble(b: u8) -> Result<u8, DpapiError> {
    match b {
        b'0'..=b'9' => Ok(b - b'0'),
        b'a'..=b'f' => Ok(b - b'a' + 10),
        b'A'..=b'F' => Ok(b - b'A' + 10),
        other => Err(DpapiError::InvalidHex(format!("non-hex byte {other:#04x}"))),
    }
}

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

    // --- impacket 0.13.1 oracle vector (provenance: tests/data/README.md,
    // reproducer tests/data/build_wifi_vector.py) ---
    const MASTER_KEY_HEX: &str = "9828d9873735439e823dbd216205ff88266d28ad685a413970c640d5ee943154bbade31fada673d542c72d707a163bb3d1bceb0c50465b359ae06998481b0ce3";
    // A WLAN <keyMaterial> hex (the DPAPI blob over the PSK + NUL), as stored.
    const KEY_MATERIAL_HEX: &str = "01000000D08C9DDF0115D1118C7A00C04FC297EB0100000033F19F5EE340BE4A8A2E2B4E62BD0CC600000000020000000000106600000001000020000000DEADBEEFCAFEBABE0011223344556677DEADBEEFCAFEBABE0011223344556677000000000E8000000002000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000051E2C9E20723EF48230A95FCEBBA31AF8BC567EDABBBD958F6E42E4CCE9236C240000000538815E34921B886D09A9CEAD4024E596A73C9C3B53E37A4481D05D7097751049323C613F78C8BD0D8A3AAAB8BF9FBC966E87526245734D0C781DFE0214B1D70";
    const EXPECT_PSK: &str = "CorrectHorseBatteryStaple";

    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()
    }

    // RED: the keyMaterial hex decrypts to impacket's plaintext PSK.
    #[test]
    fn decrypt_wlan_key_material_matches_impacket() {
        let mk = hex(MASTER_KEY_HEX);
        let psk = decrypt_wlan_key_material(KEY_MATERIAL_HEX, &mk).expect("decrypt ok");
        assert_eq!(psk, EXPECT_PSK);
    }

    // RED: the thin XML helper extracts the keyMaterial text.
    #[test]
    fn extract_key_material_from_profile_xml() {
        let xml = format!(
            "<WLANProfile><MSM><security><sharedKey>\
             <keyMaterial>{KEY_MATERIAL_HEX}</keyMaterial>\
             </sharedKey></security></MSM></WLANProfile>"
        );
        let km = extract_key_material(&xml).expect("keyMaterial present");
        assert_eq!(km, KEY_MATERIAL_HEX);
    }

    // RED: end-to-end — XML → keyMaterial → PSK.
    #[test]
    fn end_to_end_profile_xml_to_psk() {
        let xml = format!(
            "<WLANProfile><MSM><security><sharedKey>\
             <keyMaterial>\n  {KEY_MATERIAL_HEX}\n  </keyMaterial>\
             </sharedKey></security></MSM></WLANProfile>"
        );
        let km = extract_key_material(&xml).expect("keyMaterial present");
        let mk = hex(MASTER_KEY_HEX);
        let psk = decrypt_wlan_key_material(km, &mk).expect("decrypt ok");
        assert_eq!(psk, EXPECT_PSK);
    }

    // RED: refuse, don't fabricate — no usable master key (all-zero) must error.
    #[test]
    fn no_usable_master_key_refuses_rather_than_fabricates() {
        let bad_mk = [0u8; 64];
        let result = decrypt_wlan_key_material(KEY_MATERIAL_HEX, &bad_mk);
        assert!(
            result.is_err(),
            "must error on an unusable master key, never fabricate a PSK"
        );
    }
}