Skip to main content

dpapi_core/
credential.rs

1//! Windows **Credential Manager** decoder.
2//!
3//! On-disk credential files live at `%APPDATA%\Microsoft\Credentials\<hex>` and
4//! `%LOCALAPPDATA%\Microsoft\Credentials\<hex>`. Each file is impacket's
5//! `CredentialFile` wrapper — `Version(4)`, `Size(4)`, `Unknown(4)`, then a
6//! `Size`-byte `Data` blob that is a **DPAPI blob**. Decrypting that blob with the
7//! user master key yields a `CREDENTIAL_BLOB` carrying the target, username, and
8//! the secret (the stored password/credential), all UTF-16LE.
9//!
10//! Layout and field semantics follow impacket 0.13.1 (`impacket/dpapi.py`,
11//! `CredentialFile` / `CREDENTIAL_BLOB`). This module owns only the parsing; the
12//! DPAPI decrypt reuses [`crate::decrypt::decrypt_dpapi_blob`], and all crypto is
13//! the existing audited RustCrypto path — no hand-rolled primitives.
14
15use crate::blob::decode_utf16le;
16use crate::error::DpapiError;
17
18/// A decoded Credential Manager entry (impacket `CREDENTIAL_BLOB`).
19///
20/// `secret` is impacket's `Unknown` field — the stored credential material
21/// (typically the password) for the `target`. Strings are UTF-16LE-decoded.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Credential {
24    /// The credential target (impacket `Target`), e.g. `Domain:target=...`.
25    pub target: String,
26    /// The account username (impacket `Username`).
27    pub username: String,
28    /// The stored secret / password (impacket `Unknown`).
29    pub secret: String,
30    /// FILETIME of last write (impacket `LastWritten`).
31    pub last_written: u64,
32}
33
34/// Strip the on-disk `CredentialFile` wrapper, returning the inner DPAPI blob.
35///
36/// Reads `Version(4)`, `Size(4)`, `Unknown(4)`, then returns exactly `Size`
37/// bytes of `Data`. A truncated file (Size larger than the remaining bytes) is
38/// rejected with [`DpapiError::TooShort`] rather than over-reading.
39pub fn parse_credential_file(data: &[u8]) -> Result<Vec<u8>, DpapiError> {
40    // Version(4) + Size(4) + Unknown(4) = 12-byte header, then Size bytes of Data.
41    // Size is at offset 4 (impacket CredentialFile: Version, Size, Unknown, Data).
42    const HEADER_LEN: usize = 12;
43    let size = read_u32(data, 4) as usize;
44    let end = HEADER_LEN.checked_add(size).ok_or(DpapiError::TooShort {
45        needed: usize::MAX,
46        got: data.len(),
47    })?;
48    let blob = data.get(HEADER_LEN..end).ok_or(DpapiError::TooShort {
49        needed: end,
50        got: data.len(),
51    })?;
52    Ok(blob.to_vec())
53}
54
55/// Decrypt + decode a Credential Manager entry from its DPAPI blob bytes.
56///
57/// `blob_bytes` is the inner DPAPI blob (post [`parse_credential_file`]).
58/// `master_key` is the 64-byte user master key. Decrypts the blob (no entropy)
59/// through [`crate::decrypt::decrypt_dpapi_blob`], then parses the cleartext as a
60/// `CREDENTIAL_BLOB`.
61///
62/// A wrong/absent master key fails the blob's Sign-HMAC and returns a
63/// [`DpapiError`] — it never returns a fabricated/empty credential.
64pub fn decrypt_credential(blob_bytes: &[u8], master_key: &[u8]) -> Result<Credential, DpapiError> {
65    let blob = crate::blob::parse_dpapi_blob(blob_bytes)?;
66    let cleartext = crate::decrypt::decrypt_dpapi_blob(&blob, master_key, None)?;
67    parse_credential_blob(&cleartext)
68}
69
70/// Parse a decrypted `CREDENTIAL_BLOB` (impacket layout) into a [`Credential`].
71///
72/// The fixed header is `Flags(4) Size(4) Unknown0(4) Type(4) Flags2(4)
73/// LastWritten(8) Unknown2(4) Persist(4) AttrCount(4) Unknown3(8)` (48 bytes),
74/// followed by length-prefixed (`<u32` length + bytes) UTF-16LE fields in order:
75/// `Target`, `TargetAlias`, `Description`, `Unknown` (the secret), `Username`.
76/// Out-of-range length fields are rejected with [`DpapiError::TooShort`].
77fn parse_credential_blob(data: &[u8]) -> Result<Credential, DpapiError> {
78    const HEADER_LEN: usize = 48;
79    if data.len() < HEADER_LEN {
80        return Err(DpapiError::TooShort {
81            needed: HEADER_LEN,
82            got: data.len(),
83        });
84    }
85    // LastWritten is a FILETIME at offset 20 (after Flags+Size+Unknown0+Type+Flags2).
86    let last_written = read_u64(data, 20);
87
88    let mut pos = HEADER_LEN;
89    let target = read_len_prefixed_utf16(data, &mut pos)?;
90    let _target_alias = read_len_prefixed_utf16(data, &mut pos)?;
91    let _description = read_len_prefixed_utf16(data, &mut pos)?;
92    let secret = read_len_prefixed_utf16(data, &mut pos)?;
93    let username = read_len_prefixed_utf16(data, &mut pos)?;
94
95    Ok(Credential {
96        target,
97        username,
98        secret,
99        last_written,
100    })
101}
102
103/// Read a `<u32`-length-prefixed field at `*pos` and UTF-16LE-decode it.
104fn read_len_prefixed_utf16(data: &[u8], pos: &mut usize) -> Result<String, DpapiError> {
105    let len = read_u32(data, *pos) as usize;
106    let start = pos.checked_add(4).ok_or(DpapiError::TooShort {
107        needed: usize::MAX,
108        got: data.len(),
109    })?;
110    let end = start.checked_add(len).ok_or(DpapiError::TooShort {
111        needed: usize::MAX,
112        got: data.len(),
113    })?;
114    let slice = data.get(start..end).ok_or(DpapiError::TooShort {
115        needed: end,
116        got: data.len(),
117    })?;
118    *pos = end;
119    Ok(decode_utf16le(slice))
120}
121
122/// Read a little-endian u32 at `off`; out-of-range yields 0 (never panics).
123#[inline]
124fn read_u32(data: &[u8], off: usize) -> u32 {
125    data.get(off..off + 4)
126        .and_then(|s| s.try_into().ok())
127        .map_or(0, u32::from_le_bytes)
128}
129
130/// Read a little-endian u64 at `off`; out-of-range yields 0 (never panics).
131#[inline]
132fn read_u64(data: &[u8], off: usize) -> u64 {
133    data.get(off..off + 8)
134        .and_then(|s| s.try_into().ok())
135        .map_or(0, u64::from_le_bytes)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn hex(s: &str) -> Vec<u8> {
143        (0..s.len())
144            .step_by(2)
145            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
146            .collect()
147    }
148
149    // --- impacket 0.13.1 oracle vector (provenance: tests/data/README.md,
150    // reproducer tests/data/build_credential_vector.py) ---
151    // Master key = the tier-1 impacket-validated key.
152    const MASTER_KEY_HEX: &str = "9828d9873735439e823dbd216205ff88266d28ad685a413970c640d5ee943154bbade31fada673d542c72d707a163bb3d1bceb0c50465b359ae06998481b0ce3";
153    // The full on-disk CredentialFile (wrapper + inner DPAPI blob).
154    const CRED_FILE_HEX: &str = "01000000b60100000000000001000000d08c9ddf0115d1118c7a00c04fc297eb0100000033f19f5ee340be4a8a2e2b4e62bd0cc600000000020000000000106600000001000020000000aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899000000000e800000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000e2d6d8670704ca1daecd786fe94c133a68fd50708f3ed0ca7013b5e0bc5f61296b5a32b935d6b5404a2162bc26cf561cb7b45f58c7cc8d18305c9dd068860bd4f6cea89ea34db4acde8ebae4606ec1261e8006b104d96eb42975e0df1042aa1161e6c70af5530507238141080d7d7ea1f16a9609963b296143504a4af284826e1436641c74c6dc00d0b1731794887426fc4e4f4d440416c1874aaf34b6a74411d9ed966d73b6a8d05c8546329e7bb4222d2518ab8e2e7d8c47624ec64ecc8a0040000000e0585a675fef9ed63f72673bd9408684dc7fc86ad4926a76c432af933aeab68447e56860b1715cff46516cf38433a856b28a5d0653313a11664b98f2361e8cca";
155    // The inner DPAPI blob alone (post-wrapper).
156    const CRED_BLOB_HEX: &str = "01000000d08c9ddf0115d1118c7a00c04fc297eb0100000033f19f5ee340be4a8a2e2b4e62bd0cc600000000020000000000106600000001000020000000aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899000000000e800000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000e2d6d8670704ca1daecd786fe94c133a68fd50708f3ed0ca7013b5e0bc5f61296b5a32b935d6b5404a2162bc26cf561cb7b45f58c7cc8d18305c9dd068860bd4f6cea89ea34db4acde8ebae4606ec1261e8006b104d96eb42975e0df1042aa1161e6c70af5530507238141080d7d7ea1f16a9609963b296143504a4af284826e1436641c74c6dc00d0b1731794887426fc4e4f4d440416c1874aaf34b6a74411d9ed966d73b6a8d05c8546329e7bb4222d2518ab8e2e7d8c47624ec64ecc8a0040000000e0585a675fef9ed63f72673bd9408684dc7fc86ad4926a76c432af933aeab68447e56860b1715cff46516cf38433a856b28a5d0653313a11664b98f2361e8cca";
157    // impacket-decoded plaintext fields.
158    const EXPECT_TARGET: &str = "Domain:target=TERMSRV/fileserver01";
159    const EXPECT_USERNAME: &str = "CORP\\jdoe";
160    const EXPECT_SECRET: &str = "S3cr3t-P@ssw0rd!";
161
162    // RED: stripping the CredentialFile wrapper yields the inner DPAPI blob.
163    #[test]
164    fn credential_file_wrapper_yields_inner_blob() {
165        let blob = parse_credential_file(&hex(CRED_FILE_HEX)).expect("strip ok");
166        assert_eq!(blob, hex(CRED_BLOB_HEX));
167    }
168
169    // RED: decrypted fields must equal impacket's CREDENTIAL_BLOB decode.
170    #[test]
171    fn decrypt_credential_matches_impacket() {
172        let blob = hex(CRED_BLOB_HEX);
173        let mk = hex(MASTER_KEY_HEX);
174        let cred = decrypt_credential(&blob, &mk).expect("decrypt ok");
175        assert_eq!(cred.target, EXPECT_TARGET);
176        assert_eq!(cred.username, EXPECT_USERNAME);
177        assert_eq!(cred.secret, EXPECT_SECRET);
178    }
179
180    // RED: full chain from the on-disk file through to impacket's fields.
181    #[test]
182    fn end_to_end_credential_file_to_fields() {
183        let blob = parse_credential_file(&hex(CRED_FILE_HEX)).expect("strip ok");
184        let mk = hex(MASTER_KEY_HEX);
185        let cred = decrypt_credential(&blob, &mk).expect("decrypt ok");
186        assert_eq!(cred.target, EXPECT_TARGET);
187        assert_eq!(cred.username, EXPECT_USERNAME);
188        assert_eq!(cred.secret, EXPECT_SECRET);
189    }
190
191    // RED: refuse, don't fabricate — a good blob with NO usable master key (an
192    // all-zero key) must fail the Sign-HMAC and error, never an empty credential.
193    #[test]
194    fn no_usable_master_key_refuses_rather_than_fabricates() {
195        let blob = hex(CRED_BLOB_HEX);
196        let bad_mk = [0u8; 64];
197        let result = decrypt_credential(&blob, &bad_mk);
198        assert!(
199            result.is_err(),
200            "must error on an unusable master key, never fabricate a credential"
201        );
202    }
203}