Skip to main content

dpapi_core/
vault.rs

1//! Windows **Vault** decoder (`VAULT_VPOL` policy + `VAULT_VCRD` records).
2//!
3//! The Vault store (`%APPDATA%\Microsoft\Vault\<GUID>\` and the `%LOCALAPPDATA%`
4//! counterpart) holds web/app credentials. Decryption is two-stage, following
5//! impacket 0.13.1 (`impacket/dpapi.py` + `examples/dpapi.py`, the `VAULT` action):
6//!
7//! 1. **Policy** — `Policy.vpol` is a `VAULT_VPOL` whose inner `Blob` is a DPAPI
8//!    blob. Decrypting it with the user master key yields `VAULT_VPOL_KEYS`: two
9//!    BCRYPT-wrapped AES keys ([`VaultVpolKeys`]). These keys are *not* per-record
10//!    DPAPI blobs — they are the symmetric keys that decrypt the records.
11//! 2. **Records** — each `<GUID>.vcrd` is a `VAULT_VCRD`: a header, an attribute
12//!    map, and per-attribute encrypted payloads. Each sizeable attribute carries
13//!    an optional IV and AES-CBC ciphertext; AES-CBC-decrypting it with a VPOL key
14//!    yields the cleartext, which for web credentials is a `VAULT_INTERNET_EXPLORER`
15//!    schema (username / resource / password).
16//!
17//! This module owns only the parsing + the AES-CBC record decrypt (RustCrypto
18//! `aes`/`cbc`, no hand-rolled crypto); the VPOL blob decrypt reuses
19//! [`crate::decrypt::decrypt_dpapi_blob`]. If the VPOL key cannot be derived
20//! (wrong master key), the policy decrypt errors loudly rather than guessing.
21
22use crate::blob::decode_utf16le;
23use crate::error::DpapiError;
24use crate::error::DpapiError::TooShort;
25
26/// The two AES keys recovered from a decrypted `VAULT_VPOL_KEYS` blob.
27///
28/// `key1` is the key impacket's `VAULT` example uses to AES-CBC-decrypt record
29/// attributes; `key2` is retained for completeness.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct VaultVpolKeys {
32    pub key1: Vec<u8>,
33    pub key2: Vec<u8>,
34}
35
36/// One `VAULT_VCRD` attribute's encrypted payload.
37///
38/// `iv` is empty when the attribute carries no IV (impacket then uses AES-CBC
39/// with a zero IV); `data` is the AES-CBC ciphertext.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct VcrdAttribute {
42    pub id: u32,
43    pub iv: Vec<u8>,
44    pub data: Vec<u8>,
45}
46
47/// A decoded web credential (`VAULT_INTERNET_EXPLORER` schema).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct WebCredential {
50    pub username: String,
51    pub resource: String,
52    pub password: String,
53}
54
55/// Strip the `VAULT_VPOL` wrapper, returning the inner DPAPI blob bytes.
56///
57/// Reads `Version(4)`, `Guid(16)`, length-prefixed `Description`, `Unknown(12)`,
58/// `Size(4)`, `Guid2(16)`, `Guid3(16)`, `KeySize(4)`, then `KeySize` bytes of the
59/// inner DPAPI blob.
60pub fn parse_vpol_file(data: &[u8]) -> Result<Vec<u8>, DpapiError> {
61    let mut pos = 0usize;
62    let _version = read_u32(data, &mut pos);
63    pos = pos.saturating_add(16); // Guid
64    let desc_len = read_u32(data, &mut pos) as usize;
65    pos = advance(pos, desc_len, data.len())?; // Description
66    pos = advance(pos, 12, data.len())?; // Unknown
67    let _size = read_u32(data, &mut pos); // Size
68    pos = advance(pos, 32, data.len())?; // Guid2 + Guid3
69    let key_size = read_u32(data, &mut pos) as usize;
70    let end = pos.checked_add(key_size).ok_or(too_short(data.len()))?;
71    let blob = data.get(pos..end).ok_or(TooShort {
72        needed: end,
73        got: data.len(),
74    })?;
75    Ok(blob.to_vec())
76}
77
78/// Decrypt the VPOL policy blob with the master key → the two AES keys.
79///
80/// `vpol_blob` is the inner DPAPI blob (post [`parse_vpol_file`]). A wrong/absent
81/// master key fails the blob's Sign-HMAC and returns a [`DpapiError`] — it never
82/// returns guessed keys.
83pub fn decrypt_vpol_keys(vpol_blob: &[u8], master_key: &[u8]) -> Result<VaultVpolKeys, DpapiError> {
84    let blob = crate::blob::parse_dpapi_blob(vpol_blob)?;
85    let cleartext = crate::decrypt::decrypt_dpapi_blob(&blob, master_key, None)?;
86    // VAULT_VPOL_KEYS = Key1(BCRYPT_KEY_WRAP) || Key2(BCRYPT_KEY_WRAP).
87    let mut pos = 0usize;
88    let key1 = read_bcrypt_key(&cleartext, &mut pos)?;
89    let key2 = read_bcrypt_key(&cleartext, &mut pos)?;
90    Ok(VaultVpolKeys { key1, key2 })
91}
92
93/// Read one impacket `BCRYPT_KEY_WRAP` at `*pos` and return its raw AES key.
94///
95/// Layout: `Size(4) Version(4) Unknown2(4)` then a `BCRYPT_KEY_DATA_BLOB_HEADER`
96/// (`dwMagic(4) dwVersion(4) cbKeyData(4) bKey(cbKeyData)`). The wrap's `Size`
97/// field over-states the inner blob length (impacket counts 8 padding bytes the
98/// nested struct does not consume), so `*pos` is advanced by the *actual* header
99/// consumption (`12 + 12 + cbKeyData`), not by `Size` — keeping the two keys
100/// correctly delimited regardless of that quirk.
101fn read_bcrypt_key(data: &[u8], pos: &mut usize) -> Result<Vec<u8>, DpapiError> {
102    let _size = read_u32(data, pos);
103    let _version = read_u32(data, pos);
104    let _unknown2 = read_u32(data, pos);
105    // BCRYPT_KEY_DATA_BLOB_HEADER: dwMagic(4) dwVersion(4) cbKeyData(4) bKey(cbKeyData).
106    let _magic = read_u32(data, pos);
107    let _hver = read_u32(data, pos);
108    let cb = read_u32(data, pos) as usize;
109    let key_end = pos.checked_add(cb).ok_or(too_short(data.len()))?;
110    let key = data.get(*pos..key_end).ok_or(TooShort {
111        needed: key_end,
112        got: data.len(),
113    })?;
114    *pos = key_end;
115    Ok(key.to_vec())
116}
117
118/// Parse a `VAULT_VCRD` record into its encrypted attributes.
119pub fn parse_vcrd_attributes(vcrd: &[u8]) -> Result<Vec<VcrdAttribute>, DpapiError> {
120    // Header: SchemaGuid(16) Unknown0(4) LastWritten(8) Unknown1(4) Unknown2(4)
121    //         FriendlyNameLen(4) FriendlyName(var) AttributesMapsSize(4) AttributeMaps(var)
122    let mut pos = 0usize;
123    pos = advance(pos, 16 + 4 + 8 + 4 + 4, vcrd.len())?;
124    let fn_len = read_u32(vcrd, &mut pos) as usize;
125    pos = advance(pos, fn_len, vcrd.len())?;
126    let maps_size = read_u32(vcrd, &mut pos) as usize;
127    let maps_start = pos;
128    let maps_end = maps_start
129        .checked_add(maps_size)
130        .ok_or(too_short(vcrd.len()))?;
131    // Each map entry: Id(4) Offset(4) Unknown1(4) = 12 bytes. Offsets are absolute
132    // into the whole record; each attribute runs to the next entry's offset (or EOF).
133    const MAP_ENTRY: usize = 12;
134    if maps_size % MAP_ENTRY != 0 {
135        return Err(too_short(vcrd.len()));
136    }
137    let count = maps_size / MAP_ENTRY;
138    let mut offsets: Vec<(u32, usize)> = Vec::with_capacity(count);
139    let mut mp = maps_start;
140    for _ in 0..count {
141        let id = read_u32(vcrd, &mut mp);
142        let offset = read_u32(vcrd, &mut mp) as usize;
143        let _unknown1 = read_u32(vcrd, &mut mp);
144        offsets.push((id, offset));
145    }
146    if mp > maps_end {
147        return Err(too_short(vcrd.len()));
148    }
149
150    let mut attrs = Vec::with_capacity(count);
151    for i in 0..count {
152        let (id, start) = offsets[i];
153        let end = offsets.get(i + 1).map_or(vcrd.len(), |&(_, o)| o);
154        let attr_bytes = vcrd.get(start..end).ok_or(TooShort {
155            needed: end,
156            got: vcrd.len(),
157        })?;
158        if let Some((iv, payload)) = parse_attribute_payload(attr_bytes) {
159            attrs.push(VcrdAttribute {
160                id,
161                iv,
162                data: payload,
163            });
164        }
165    }
166    Ok(attrs)
167}
168
169/// Extract `(IV, Data)` from one `VAULT_ATTRIBUTE`'s raw bytes (impacket layout).
170///
171/// Returns `None` for attributes too small to carry an encrypted payload (the
172/// impacket example skips attributes whose length is `<= 28`). The fixed prefix is
173/// `Id(4) Unknown1(4) Unknown2(4) Unknown3(4)` (16 bytes), then an optional 6-byte
174/// `Pad` when bytes `[16..22]` are zero, then an optional 4-byte `Unknown5` when
175/// `Id >= 100`, then `Size(4) IVPresent(1) IVSize(4) IV[IVSize] Data[...]`.
176fn parse_attribute_payload(attr: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
177    if attr.len() <= 28 {
178        return None;
179    }
180    let id = u32::from_le_bytes(attr.get(0..4)?.try_into().ok()?);
181    let mut pos = 16usize;
182    if attr.get(16..22)? == [0u8; 6] {
183        pos += 6; // Pad
184    }
185    if id >= 100 {
186        pos += 4; // Unknown5
187    }
188    let size = u32::from_le_bytes(attr.get(pos..pos + 4)?.try_into().ok()?) as usize;
189    pos += 4;
190    let iv_present = *attr.get(pos)?;
191    pos += 1;
192    let iv_size = u32::from_le_bytes(attr.get(pos..pos + 4)?.try_into().ok()?) as usize;
193    pos += 4;
194    let (iv, data_len) = if iv_present != 0 {
195        let iv = attr.get(pos..pos + iv_size)?.to_vec();
196        pos += iv_size;
197        (iv, size.checked_sub(iv_size + 5)?)
198    } else {
199        (Vec::new(), size.checked_sub(1)?)
200    };
201    let data = attr.get(pos..pos + data_len)?.to_vec();
202    Some((iv, data))
203}
204
205/// AES-CBC-decrypt one VCRD attribute with a VPOL key.
206///
207/// Mirrors impacket's `VAULT` example: AES-CBC with `attr.iv` when a 16-byte IV is
208/// present, else a zero IV; the payload is decrypted without PKCS#7 unpadding (the
209/// schema parse bounds the cleartext, not a pad byte).
210pub fn decrypt_vcrd_attribute(
211    attr: &VcrdAttribute,
212    vpol_key: &[u8],
213) -> Result<Vec<u8>, DpapiError> {
214    use aes::Aes256;
215    use cbc::Decryptor as CbcDec;
216    use cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
217
218    let zero_iv = [0u8; 16];
219    let iv: &[u8] = if attr.iv.len() == 16 {
220        &attr.iv
221    } else {
222        &zero_iv
223    };
224    let mut buf = attr.data.clone();
225    let dec = CbcDec::<Aes256>::new_from_slices(vpol_key, iv)
226        .map_err(|_| DpapiError::InvalidKeyLength)?;
227    let out = dec
228        .decrypt_padded_mut::<NoPadding>(&mut buf)
229        .map_err(|_| DpapiError::DecryptionFailed)?;
230    Ok(out.to_vec())
231}
232
233/// Parse a decrypted `VAULT_INTERNET_EXPLORER` cleartext into a [`WebCredential`].
234///
235/// Layout: `Version(4) Count(4) Unknown(4)` then three `Id(4) Len(4) Value(Len)`
236/// triples (Username, Resource, Password), each UTF-16LE.
237pub fn parse_internet_explorer(cleartext: &[u8]) -> Result<WebCredential, DpapiError> {
238    let mut pos = 12usize; // Version + Count + Unknown
239    let username = read_id_len_utf16(cleartext, &mut pos)?;
240    let resource = read_id_len_utf16(cleartext, &mut pos)?;
241    let password = read_id_len_utf16(cleartext, &mut pos)?;
242    Ok(WebCredential {
243        username,
244        resource,
245        password,
246    })
247}
248
249/// Read an `Id(4) Len(4) Value(Len)` triple at `*pos` and UTF-16LE-decode `Value`.
250fn read_id_len_utf16(data: &[u8], pos: &mut usize) -> Result<String, DpapiError> {
251    let _id = read_u32(data, pos);
252    let len = read_u32(data, pos) as usize;
253    let end = pos.checked_add(len).ok_or(too_short(data.len()))?;
254    let slice = data.get(*pos..end).ok_or(TooShort {
255        needed: end,
256        got: data.len(),
257    })?;
258    *pos = end;
259    Ok(decode_utf16le(slice))
260}
261
262/// Read a little-endian u32 at `*pos`, advancing by 4; out-of-range yields 0.
263#[inline]
264fn read_u32(data: &[u8], pos: &mut usize) -> u32 {
265    let v = data
266        .get(*pos..*pos + 4)
267        .and_then(|s| s.try_into().ok())
268        .map_or(0, u32::from_le_bytes);
269    *pos = pos.saturating_add(4);
270    v
271}
272
273/// Advance `pos` by `n`, erroring if it would exceed `len`.
274fn advance(pos: usize, n: usize, len: usize) -> Result<usize, DpapiError> {
275    let end = pos.checked_add(n).ok_or(too_short(len))?;
276    if end > len {
277        return Err(TooShort {
278            needed: end,
279            got: len,
280        });
281    }
282    Ok(end)
283}
284
285/// Build a `TooShort` error with `needed = usize::MAX` (overflow guard sentinel).
286#[inline]
287fn too_short(got: usize) -> DpapiError {
288    TooShort {
289        needed: usize::MAX,
290        got,
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn hex(s: &str) -> Vec<u8> {
299        (0..s.len())
300            .step_by(2)
301            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
302            .collect()
303    }
304
305    // --- impacket 0.13.1 oracle vector (provenance: tests/data/README.md,
306    // reproducer tests/data/build_vault_vector.py) ---
307    const MASTER_KEY_HEX: &str = "9828d9873735439e823dbd216205ff88266d28ad685a413970c640d5ee943154bbade31fada673d542c72d707a163bb3d1bceb0c50465b359ae06998481b0ce3";
308    const VPOL_FILE_HEX: &str = "01000000000000000000000000000000000000000a000000760070006f006c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007601000001000000d08c9ddf0115d1118c7a00c04fc297eb0100000033f19f5ee340be4a8a2e2b4e62bd0cc6000000000200000000001066000000010000200000001122334455667788112233445566778811223344556677881122334455667788000000000e8000000002000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000065a02b0acc4ef1fb8f698d7e6c9214d19144177556a964a02b9c13445d8cb6b640779e5cf1e273f2f8ede6445631e12c28c81e907373f71b9d26ffca96bba17b5147feb3b70fb9ebe9aa4150a1ec63c1f4e7356e600ed1bb5c15b2f850b8f21b3fac4e658821cf41f8b779baa815284576fa06d4aa59509eb7541d331958e7ca4000000057da67b85c3f524f46e18fd82b0bcfedba543efe03877d8f7d81daef61e311df7f8509d7ee7cd25becdfac53fa47c20a4150417065518659c72f200da584e654";
309    // The inner DPAPI blob alone (post-VPOL-wrapper) for the key-derivation test.
310    const VPOL_BLOB_HEX: &str = "01000000d08c9ddf0115d1118c7a00c04fc297eb0100000033f19f5ee340be4a8a2e2b4e62bd0cc6000000000200000000001066000000010000200000001122334455667788112233445566778811223344556677881122334455667788000000000e8000000002000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000065a02b0acc4ef1fb8f698d7e6c9214d19144177556a964a02b9c13445d8cb6b640779e5cf1e273f2f8ede6445631e12c28c81e907373f71b9d26ffca96bba17b5147feb3b70fb9ebe9aa4150a1ec63c1f4e7356e600ed1bb5c15b2f850b8f21b3fac4e658821cf41f8b779baa815284576fa06d4aa59509eb7541d331958e7ca4000000057da67b85c3f524f46e18fd82b0bcfedba543efe03877d8f7d81daef61e311df7f8509d7ee7cd25becdfac53fa47c20a4150417065518659c72f200da584e654";
311    const VPOL_KEY1_HEX: &str = "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f";
312    const VPOL_KEY2_HEX: &str = "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f";
313    const VCRD_FILE_HEX: &str = "0000000000000000000000000000000000000000000000000000d801000000000000000040000000570069006e0064006f007700730020005700650062002000500061007300730077006f00720064002000430072006500640065006e007400690061006c0000000c0000006400000078000000000000006400000000000000000000000000000000000000000000000000a500000001100000000f1e2d3c4b5a69788796a5b4c3d2e1f0d3183526451c5a94e61918a73ef697b98b4aeee92c9d96997727f81033c97368e2dc5fde038d14400f40259325c614d74e6309d7ee0b222a3f172b072b06118cb34acbb407d6a7d73c4f2d02034715c3ca8eea7993ab826edc8ddcf92bb9a9a3176e4af211ae8d18fbbe9a9574d01d67a98ec41b9e743d3ddf616db94c33c77a00a7abd0cffb59455947816dc11ff61c";
314    const ATTR_IV_HEX: &str = "0f1e2d3c4b5a69788796a5b4c3d2e1f0";
315    const EXPECT_USER: &str = "alice@example.com";
316    const EXPECT_RES: &str = "https://portal.example.com";
317    const EXPECT_PWD: &str = "V@ultP4ss!";
318
319    // RED: stripping the VAULT_VPOL wrapper yields the inner DPAPI blob.
320    #[test]
321    fn vpol_wrapper_yields_inner_blob() {
322        let blob = parse_vpol_file(&hex(VPOL_FILE_HEX)).expect("strip ok");
323        assert_eq!(blob, hex(VPOL_BLOB_HEX));
324    }
325
326    // RED: decrypting the VPOL blob yields impacket's two AES keys.
327    #[test]
328    fn decrypt_vpol_keys_matches_impacket() {
329        let mk = hex(MASTER_KEY_HEX);
330        let keys = decrypt_vpol_keys(&hex(VPOL_BLOB_HEX), &mk).expect("vpol ok");
331        assert_eq!(keys.key1, hex(VPOL_KEY1_HEX));
332        assert_eq!(keys.key2, hex(VPOL_KEY2_HEX));
333    }
334
335    // RED: the VCRD parses to one attribute with the expected IV.
336    #[test]
337    fn vcrd_parses_attribute_with_iv() {
338        let attrs = parse_vcrd_attributes(&hex(VCRD_FILE_HEX)).expect("parse ok");
339        assert_eq!(attrs.len(), 1);
340        assert_eq!(attrs[0].iv, hex(ATTR_IV_HEX));
341        assert!(!attrs[0].data.is_empty());
342    }
343
344    // RED: full chain — VPOL key1 AES-CBC-decrypts the VCRD attribute to the
345    // VAULT_INTERNET_EXPLORER fields impacket recovered.
346    #[test]
347    fn end_to_end_vault_web_credential() {
348        let mk = hex(MASTER_KEY_HEX);
349        let vpol_blob = parse_vpol_file(&hex(VPOL_FILE_HEX)).expect("strip ok");
350        let keys = decrypt_vpol_keys(&vpol_blob, &mk).expect("vpol ok");
351        let attrs = parse_vcrd_attributes(&hex(VCRD_FILE_HEX)).expect("parse ok");
352        let cleartext = decrypt_vcrd_attribute(&attrs[0], &keys.key1).expect("attr ok");
353        let cred = parse_internet_explorer(&cleartext).expect("ie ok");
354        assert_eq!(cred.username, EXPECT_USER);
355        assert_eq!(cred.resource, EXPECT_RES);
356        assert_eq!(cred.password, EXPECT_PWD);
357    }
358
359    // RED: refuse, don't fabricate — VPOL key derivation with NO usable master
360    // key (all-zero) must fail the Sign-HMAC and error, never return keys.
361    #[test]
362    fn no_usable_master_key_refuses_rather_than_fabricates() {
363        let bad_mk = [0u8; 64];
364        let result = decrypt_vpol_keys(&hex(VPOL_BLOB_HEX), &bad_mk);
365        assert!(
366            result.is_err(),
367            "must error when the VPOL key can't be derived, never fabricate keys"
368        );
369    }
370}