Skip to main content

dpapi_core/
blob.rs

1use forensicnomicon::dpapi::{hash_alg_info, PROVIDER_GUID_BYTES};
2
3use crate::error::DpapiError;
4
5/// DPAPI hash-algorithm parameters. Re-exported from forensicnomicon, the fleet
6/// knowledge crate that owns the impacket `ALGORITHMS_DATA` block-size facts;
7/// this crate owns only the parsing + crypto that consume them.
8///
9/// Two distinct block sizes are in play and must not be conflated:
10/// * `derive_block_len` is the table's salt/block field used by `deriveKey`
11///   (impacket index `[4]`): 64 for SHA1 and `CALG_HMAC` (0x8009), 128 for
12///   `CALG_SHA_512` (0x800e).
13/// * `hash_block_len` is the underlying hash module's block size used by the
14///   integrity check (`SHA1.block_size`=64, `SHA512.block_size`=128).
15pub use forensicnomicon::dpapi::HashAlgInfo as HashAlg;
16
17/// Resolve a DPAPI `algId` (the hash algorithm) to its properties.
18///
19/// Delegates the `algId → params` knowledge to
20/// [`forensicnomicon::dpapi::hash_alg_info`] — recognising `CALG_SHA1`
21/// (`0x8004` → SHA1), `CALG_HMAC` (`0x8009` → SHA512 module, 64-byte derive
22/// block) and `CALG_SHA_512` (`0x800e` → SHA512, 128-byte derive block). Any
23/// other value falls back to SHA1, matching the historical default.
24pub fn hash_alg(alg_id_hash: u32) -> HashAlg {
25    hash_alg_info(alg_id_hash).unwrap_or(HashAlg {
26        is_sha512: false,
27        digest_len: 20,
28        derive_block_len: 64,
29        hash_block_len: 64,
30    })
31}
32
33/// A parsed DPAPI data blob, mirroring impacket's `DPAPI_BLOB` structure.
34///
35/// Field names follow impacket: `salt` is the session-key salt, `hmac` is the
36/// length-prefixed `HMac` field, `ciphertext` is `Data`, and `sign` is the
37/// trailing integrity HMAC (`Sign`). `to_sign` is the byte range impacket signs
38/// (`rawData[20 .. len - SignLen - 4]`), retained so the integrity check needs
39/// no re-parse.
40#[derive(Debug, Clone)]
41pub struct DpapiBlob {
42    pub version: u32,
43    pub master_key_guid: [u8; 16],
44    pub description: String,
45    pub alg_id_encrypt: u32,
46    pub alg_id_hash: u32,
47    pub salt: Vec<u8>,
48    pub hmac_key: Vec<u8>,
49    pub hmac: Vec<u8>,
50    pub ciphertext: Vec<u8>,
51    pub sign: Vec<u8>,
52    pub to_sign: Vec<u8>,
53}
54
55/// Read a length-prefixed (`<u32` length then bytes) field at `*pos`.
56fn read_len_prefixed<'a>(data: &'a [u8], pos: &mut usize) -> Result<&'a [u8], DpapiError> {
57    let len = read_u32(data, pos) as usize;
58    let slice = data.get(*pos..*pos + len).ok_or(DpapiError::TooShort {
59        needed: *pos + len,
60        got: data.len(),
61    })?;
62    *pos += len;
63    Ok(slice)
64}
65
66pub fn parse_dpapi_blob(data: &[u8]) -> Result<DpapiBlob, DpapiError> {
67    // Fixed header: version(4) + providerGUID(16) + mkVersion(4) + mkGUID(16)
68    //             + flags(4) + descLen(4) = 48 bytes.
69    if data.len() < 48 {
70        return Err(DpapiError::TooShort {
71            needed: 48,
72            got: data.len(),
73        });
74    }
75
76    let mut pos = 0usize;
77
78    let version = read_u32(data, &mut pos);
79    if version != 1 && version != 2 {
80        return Err(DpapiError::UnsupportedVersion(version));
81    }
82    // The 16 bytes after the version are the DPAPI provider GUID; a blob that
83    // is not DPAPI-protected is rejected loudly rather than mis-parsed.
84    let provider_guid: [u8; 16] =
85        data[pos..pos + 16]
86            .try_into()
87            .map_err(|_| DpapiError::TooShort {
88                needed: pos + 16,
89                got: data.len(),
90            })?;
91    if provider_guid != PROVIDER_GUID_BYTES {
92        use core::fmt::Write as _;
93        let hex = provider_guid.iter().fold(String::new(), |mut s, b| {
94            let _ = write!(s, "{b:02x}");
95            s
96        });
97        return Err(DpapiError::NotDpapiProvider(hex));
98    }
99    pos += 16;
100    let _mk_version = read_u32(data, &mut pos);
101    let master_key_guid: [u8; 16] =
102        data[pos..pos + 16]
103            .try_into()
104            .map_err(|_| DpapiError::TooShort {
105                needed: pos + 16,
106                got: data.len(),
107            })?;
108    pos += 16;
109    let _flags = read_u32(data, &mut pos);
110
111    let desc_bytes = read_len_prefixed(data, &mut pos)?;
112    let description = decode_utf16le(desc_bytes);
113
114    let alg_id_encrypt = read_u32(data, &mut pos);
115    let _crypt_algo_len = read_u32(data, &mut pos);
116
117    let salt = read_len_prefixed(data, &mut pos)?.to_vec();
118    let hmac_key = read_len_prefixed(data, &mut pos)?.to_vec();
119
120    let alg_id_hash = read_u32(data, &mut pos);
121    let _hash_algo_len = read_u32(data, &mut pos);
122
123    let hmac = read_len_prefixed(data, &mut pos)?.to_vec();
124    let ciphertext = read_len_prefixed(data, &mut pos)?.to_vec();
125
126    // Region impacket signs: from offset 20 up to (but excluding) SignLen + Sign.
127    let sign_len_pos = pos;
128    let sign = read_len_prefixed(data, &mut pos)?.to_vec();
129    if sign_len_pos < 20 {
130        return Err(DpapiError::TooShort {
131            needed: 20,
132            got: sign_len_pos,
133        });
134    }
135    let to_sign = data[20..sign_len_pos].to_vec();
136
137    Ok(DpapiBlob {
138        version,
139        master_key_guid,
140        description,
141        alg_id_encrypt,
142        alg_id_hash,
143        salt,
144        hmac_key,
145        hmac,
146        ciphertext,
147        sign,
148        to_sign,
149    })
150}
151
152/// Decode a UTF-16LE byte string, trimming trailing NULs.
153fn decode_utf16le(bytes: &[u8]) -> String {
154    if bytes.len() < 2 {
155        return String::new();
156    }
157    let words: Vec<u16> = bytes
158        .chunks_exact(2)
159        .map(|c| u16::from_le_bytes([c[0], c[1]]))
160        .collect();
161    String::from_utf16_lossy(&words)
162        .trim_end_matches('\0')
163        .to_string()
164}
165
166/// Read a little-endian u32 at `*pos`, advancing `pos` by 4.
167/// Out-of-range yields 0 (never panics); callers range-check before relying on
168/// the value, so a 0 here is a defensive fallback, not a silent success.
169#[inline]
170fn read_u32(data: &[u8], pos: &mut usize) -> u32 {
171    let v = data
172        .get(*pos..*pos + 4)
173        .and_then(|s| s.try_into().ok())
174        .map_or(0, u32::from_le_bytes);
175    *pos += 4;
176    v
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn hex(s: &str) -> Vec<u8> {
184        (0..s.len())
185            .step_by(2)
186            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
187            .collect()
188    }
189
190    /// Build a structurally-valid DPAPI_BLOB (impacket layout) for parse tests.
191    fn make_blob(
192        crypt_algo: u32,
193        hash_algo: u32,
194        salt: &[u8],
195        hmac_key: &[u8],
196        hmac: &[u8],
197        data: &[u8],
198        sign: &[u8],
199    ) -> Vec<u8> {
200        let mut v = Vec::new();
201        v.extend_from_slice(&2u32.to_le_bytes()); // version
202        v.extend_from_slice(&PROVIDER_GUID_BYTES); // DPAPI provider GUID
203        v.extend_from_slice(&0u32.to_le_bytes()); // master key version
204        v.extend_from_slice(&[0xAAu8; 16]); // master key GUID
205        v.extend_from_slice(&0u32.to_le_bytes()); // flags
206        v.extend_from_slice(&0u32.to_le_bytes()); // desc length (empty)
207        v.extend_from_slice(&crypt_algo.to_le_bytes());
208        v.extend_from_slice(&256u32.to_le_bytes()); // crypt algo len
209        v.extend_from_slice(&(salt.len() as u32).to_le_bytes());
210        v.extend_from_slice(salt);
211        v.extend_from_slice(&(hmac_key.len() as u32).to_le_bytes());
212        v.extend_from_slice(hmac_key);
213        v.extend_from_slice(&hash_algo.to_le_bytes());
214        v.extend_from_slice(&512u32.to_le_bytes()); // hash algo len
215        v.extend_from_slice(&(hmac.len() as u32).to_le_bytes());
216        v.extend_from_slice(hmac);
217        v.extend_from_slice(&(data.len() as u32).to_le_bytes());
218        v.extend_from_slice(data);
219        v.extend_from_slice(&(sign.len() as u32).to_le_bytes());
220        v.extend_from_slice(sign);
221        v
222    }
223
224    // A real Windows-minted DPAPI blob (Vector 1, hashAlgo=0x800e SHA512,
225    // cryptAlgo=0x6610 AES-256); fields cross-checked against impacket 0.12.0.
226    const REAL_BLOB_HEX: &str = "01000000d08c9ddf0115d1118c7a00c04fc297eb0100000033f19f5ee340be4a8a2e2b4e62bd0cc6000000000200000000001066000000010000200000000d1af96e5e102266fd36d96ac7d1595552e5a4e972463f77e6e227f22d5fc8df000000000e8000000002000020000000834f3c5710c8a7474f7dbcea8ba28ab8e4d4443f50a0c63ff4eba1cce485295f20000000b61d7576c0c6caf3690edb247bde3f7edaa59580e3b4be1265ea78e8c1b8a61d400000001c03ab807147742649b6bdfd1c1344d178bb163842d70abacfd51233af909cb81a677ec05d8db996f587ef5ac410dc189beda756eb0d1b6ee376823e80968538";
227
228    #[test]
229    fn parse_rejects_too_short() {
230        assert!(parse_dpapi_blob(&[0u8; 10]).is_err());
231    }
232
233    #[test]
234    fn parse_rejects_non_dpapi_provider_guid() {
235        // Structurally valid but the provider GUID is not the DPAPI provider:
236        // it must be rejected loudly (with the offending bytes), not mis-parsed.
237        let mut blob = make_blob(
238            0x6610,
239            0x8004,
240            &[0xEEu8; 16],
241            &[],
242            &[0xCCu8; 20],
243            &[0xDDu8; 16],
244            &[0xCCu8; 20],
245        );
246        blob[4..20].copy_from_slice(&[0x11u8; 16]); // clobber provider GUID
247        match parse_dpapi_blob(&blob) {
248            Err(DpapiError::NotDpapiProvider(guid)) => {
249                assert!(guid.contains("11"), "error must surface the bytes: {guid}");
250            }
251            other => panic!("expected NotDpapiProvider, got {other:?}"),
252        }
253    }
254
255    #[test]
256    fn parse_extracts_master_key_guid() {
257        let blob = make_blob(
258            0x6610,
259            0x8004,
260            &[0xEEu8; 16],
261            &[],
262            &[0xCCu8; 20],
263            &[0xDDu8; 16],
264            &[0xCCu8; 20],
265        );
266        let result = parse_dpapi_blob(&blob).expect("should parse");
267        assert_eq!(result.master_key_guid, [0xAA; 16]);
268    }
269
270    #[test]
271    fn parse_extracts_alg_id_encrypt() {
272        let blob = make_blob(
273            0x6610,
274            0x8004,
275            &[0xEEu8; 16],
276            &[],
277            &[0xCCu8; 20],
278            &[0xDDu8; 16],
279            &[0xCCu8; 20],
280        );
281        let result = parse_dpapi_blob(&blob).expect("should parse");
282        assert_eq!(result.alg_id_encrypt, 0x6610);
283    }
284
285    #[test]
286    fn parse_extracts_salt_and_ciphertext() {
287        let salt = [0xEEu8; 32];
288        let data = [0xDDu8; 32];
289        let blob = make_blob(
290            0x6610,
291            0x8004,
292            &salt,
293            &[],
294            &[0xCCu8; 20],
295            &data,
296            &[0xCCu8; 20],
297        );
298        let result = parse_dpapi_blob(&blob).expect("should parse");
299        assert_eq!(result.salt, salt);
300        assert_eq!(result.ciphertext, data);
301    }
302
303    // Tier-1: field offsets must match impacket's parse of a real Windows blob.
304    #[test]
305    fn parse_real_blob_matches_impacket_fields() {
306        let result = parse_dpapi_blob(&hex(REAL_BLOB_HEX)).expect("should parse");
307        assert_eq!(result.alg_id_encrypt, 0x6610);
308        assert_eq!(result.alg_id_hash, 0x800E);
309        assert_eq!(
310            result.salt,
311            hex("0d1af96e5e102266fd36d96ac7d1595552e5a4e972463f77e6e227f22d5fc8df")
312        );
313        assert!(result.hmac_key.is_empty());
314        assert_eq!(
315            result.hmac,
316            hex("834f3c5710c8a7474f7dbcea8ba28ab8e4d4443f50a0c63ff4eba1cce485295f")
317        );
318        assert_eq!(
319            result.ciphertext,
320            hex("b61d7576c0c6caf3690edb247bde3f7edaa59580e3b4be1265ea78e8c1b8a61d")
321        );
322        assert_eq!(result.sign.len(), 64);
323    }
324}