1pub use dpapi_core;
14
15use std::path::PathBuf;
16
17use clap::{Parser, Subcommand};
18use serde::Serialize;
19
20use dpapi_core::{
21 decrypt_credential, decrypt_local_state_key, decrypt_v10_cookie, decrypt_vcrd_attribute,
22 decrypt_vpol_keys, decrypt_wlan_key_material, detect_chrome_cookie_encoding,
23 extract_key_material, parse_credential_file, parse_dpapi_blob, parse_internet_explorer,
24 parse_local_state_encrypted_key, parse_vcrd_attributes, parse_vpol_file, ChromeCookieEncoding,
25 DpapiError,
26};
27
28#[derive(Debug)]
30pub enum CliError {
31 Dpapi(DpapiError),
33 Io(String),
35 BadMasterKey(String),
37 Locked { store: String, mk_guid: String },
40}
41
42impl std::fmt::Display for CliError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 CliError::Dpapi(e) => write!(f, "decode failed: {e}"),
46 CliError::Io(s) => write!(f, "io error: {s}"),
47 CliError::BadMasterKey(s) => write!(f, "bad master key: {s}"),
48 CliError::Locked { store, mk_guid } => write!(
49 f,
50 "{store} store present but LOCKED: no usable master key (master-key GUID {mk_guid}); supply the key for that GUID"
51 ),
52 }
53 }
54}
55
56impl std::error::Error for CliError {}
57
58impl From<DpapiError> for CliError {
59 fn from(e: DpapiError) -> Self {
60 CliError::Dpapi(e)
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct StoreResult {
67 pub store: String,
69 pub label: String,
71 pub secret: String,
73 pub account: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct CliReport {
80 pub results: Vec<StoreResult>,
81}
82
83#[derive(Debug, Parser)]
85#[command(name = "dpapi4n6", version, about = "Forensic DPAPI store auditor")]
86pub struct Cli {
87 #[command(subcommand)]
88 pub command: Command,
89 #[arg(long, global = true)]
91 pub json: bool,
92}
93
94#[derive(Debug, clap::Args)]
97pub struct MasterKeyArg {
98 #[arg(long = "master-key", value_name = "HEX")]
100 pub master_key_hex: String,
101}
102
103#[derive(Debug, Subcommand)]
104pub enum Command {
105 Browser {
107 #[arg(long)]
109 local_state: PathBuf,
110 #[arg(long)]
112 cookie: Option<PathBuf>,
113 #[command(flatten)]
114 key: MasterKeyArg,
115 },
116 Credman {
118 #[arg(long)]
120 file: PathBuf,
121 #[command(flatten)]
122 key: MasterKeyArg,
123 },
124 Vault {
126 #[arg(long)]
128 vpol: PathBuf,
129 #[arg(long)]
131 vcrd: PathBuf,
132 #[command(flatten)]
133 key: MasterKeyArg,
134 },
135 Wifi {
137 #[arg(long)]
139 profile: PathBuf,
140 #[command(flatten)]
141 key: MasterKeyArg,
142 },
143}
144
145pub fn parse_master_key_hex(hex: &str) -> Result<Vec<u8>, CliError> {
147 let s = hex.strip_prefix("0x").unwrap_or(hex);
148 if s.len() % 2 != 0 {
149 return Err(CliError::BadMasterKey(format!(
150 "odd length ({} chars)",
151 s.len()
152 )));
153 }
154 let bytes = s.as_bytes();
155 let mut out = Vec::with_capacity(s.len() / 2);
156 let mut i = 0;
157 while i < bytes.len() {
158 let hi = nibble(bytes[i])?;
159 let lo = nibble(bytes[i + 1])?;
160 out.push((hi << 4) | lo);
161 i += 2;
162 }
163 Ok(out)
164}
165
166fn nibble(b: u8) -> Result<u8, CliError> {
167 match b {
168 b'0'..=b'9' => Ok(b - b'0'),
169 b'a'..=b'f' => Ok(b - b'a' + 10),
170 b'A'..=b'F' => Ok(b - b'A' + 10),
171 other => Err(CliError::BadMasterKey(format!("non-hex byte {other:#04x}"))),
172 }
173}
174
175fn locked_or_err(store: &str, blob_bytes: &[u8], e: DpapiError) -> CliError {
179 match e {
180 DpapiError::HmacMismatch
181 | DpapiError::DecryptionFailed
182 | DpapiError::DomainBackupUnsupported => {
183 let mk_guid = parse_dpapi_blob(blob_bytes).map_or_else(
184 |_| "unknown".to_string(),
185 |b| guid_to_string(&b.master_key_guid),
186 );
187 CliError::Locked {
188 store: store.to_string(),
189 mk_guid,
190 }
191 }
192 other => CliError::Dpapi(other),
193 }
194}
195
196fn guid_to_string(g: &[u8; 16]) -> String {
198 format!(
199 "{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
200 u32::from_le_bytes([g[0], g[1], g[2], g[3]]),
201 u16::from_le_bytes([g[4], g[5]]),
202 u16::from_le_bytes([g[6], g[7]]),
203 g[8],
204 g[9],
205 g[10],
206 g[11],
207 g[12],
208 g[13],
209 g[14],
210 g[15]
211 )
212}
213
214pub fn decode_browser(
224 local_state_json: &str,
225 cookie_blob: Option<&[u8]>,
226 master_key: &[u8],
227) -> Result<Vec<StoreResult>, CliError> {
228 let value: serde_json::Value = serde_json::from_str(local_state_json)
229 .map_err(|e| CliError::Io(format!("Local State is not valid JSON: {e}")))?;
230 let encrypted_key_b64 = value
231 .get("os_crypt")
232 .and_then(|o| o.get("encrypted_key"))
233 .and_then(serde_json::Value::as_str)
234 .ok_or_else(|| CliError::Io("Local State has no os_crypt.encrypted_key".to_string()))?;
235
236 let key_blob = parse_local_state_encrypted_key(encrypted_key_b64.as_bytes())?;
237 let cookie_key = decrypt_local_state_key(&key_blob, master_key)
238 .map_err(|e| locked_or_err("browser", &key_blob, e))?;
239
240 let mut results = vec![StoreResult {
241 store: "browser".to_string(),
242 label: "cookie-key".to_string(),
243 secret: hex_encode(&cookie_key),
244 account: None,
245 }];
246
247 if let Some(blob) = cookie_blob {
248 let plaintext = match detect_chrome_cookie_encoding(blob) {
249 ChromeCookieEncoding::V10 { nonce, ciphertext }
250 | ChromeCookieEncoding::V20 { nonce, ciphertext } => {
251 decrypt_v10_cookie(&nonce, &ciphertext, &cookie_key)?
252 }
253 ChromeCookieEncoding::DpapiBlob(_) | ChromeCookieEncoding::Raw => {
254 return Err(CliError::Io(
255 "cookie value is not a v10/v20 AES-GCM blob".to_string(),
256 ))
257 }
258 };
259 results.push(StoreResult {
260 store: "browser".to_string(),
261 label: "cookie".to_string(),
262 secret: String::from_utf8_lossy(&plaintext).into_owned(),
263 account: None,
264 });
265 }
266 Ok(results)
267}
268
269pub fn decode_credman(file_bytes: &[u8], master_key: &[u8]) -> Result<Vec<StoreResult>, CliError> {
271 let blob = parse_credential_file(file_bytes)?;
272 let cred =
273 decrypt_credential(&blob, master_key).map_err(|e| locked_or_err("credman", &blob, e))?;
274 Ok(vec![StoreResult {
275 store: "credman".to_string(),
276 label: cred.target,
277 secret: cred.secret,
278 account: Some(cred.username),
279 }])
280}
281
282pub fn decode_vault(
284 vpol_bytes: &[u8],
285 vcrd_bytes: &[u8],
286 master_key: &[u8],
287) -> Result<Vec<StoreResult>, CliError> {
288 let vpol_blob = parse_vpol_file(vpol_bytes)?;
289 let keys = decrypt_vpol_keys(&vpol_blob, master_key)
290 .map_err(|e| locked_or_err("vault", &vpol_blob, e))?;
291
292 let attrs = parse_vcrd_attributes(vcrd_bytes)?;
293 let mut results = Vec::new();
294 for attr in &attrs {
295 let Ok(cleartext) = decrypt_vcrd_attribute(attr, &keys.key1) else {
297 continue;
298 };
299 if let Ok(cred) = parse_internet_explorer(&cleartext) {
300 results.push(StoreResult {
301 store: "vault".to_string(),
302 label: cred.resource,
303 secret: cred.password,
304 account: Some(cred.username),
305 });
306 }
307 }
308 Ok(results)
309}
310
311pub fn decode_wifi(profile_xml: &str, master_key: &[u8]) -> Result<Vec<StoreResult>, CliError> {
313 let key_material = extract_key_material(profile_xml)
314 .ok_or_else(|| CliError::Io("profile XML has no <keyMaterial>".to_string()))?;
315 let blob = hex_decode(key_material)?;
317 let psk = decrypt_wlan_key_material(key_material, master_key)
318 .map_err(|e| locked_or_err("wifi", &blob, e))?;
319 Ok(vec![StoreResult {
320 store: "wifi".to_string(),
321 label: "PSK".to_string(),
322 secret: psk,
323 account: None,
324 }])
325}
326
327impl Cli {
328 pub fn run(&self) -> Result<CliReport, CliError> {
331 let results = match &self.command {
332 Command::Browser {
333 local_state,
334 cookie,
335 key,
336 } => {
337 let mk = parse_master_key_hex(&key.master_key_hex)?;
338 let json = read_to_string(local_state)?;
339 let cookie_bytes = match cookie {
340 Some(p) => Some(read_bytes(p)?),
341 None => None,
342 };
343 decode_browser(&json, cookie_bytes.as_deref(), &mk)?
344 }
345 Command::Credman { file, key } => {
346 let mk = parse_master_key_hex(&key.master_key_hex)?;
347 decode_credman(&read_bytes(file)?, &mk)?
348 }
349 Command::Vault { vpol, vcrd, key } => {
350 let mk = parse_master_key_hex(&key.master_key_hex)?;
351 decode_vault(&read_bytes(vpol)?, &read_bytes(vcrd)?, &mk)?
352 }
353 Command::Wifi { profile, key } => {
354 let mk = parse_master_key_hex(&key.master_key_hex)?;
355 decode_wifi(&read_to_string(profile)?, &mk)?
356 }
357 };
358 Ok(CliReport { results })
359 }
360}
361
362fn read_to_string(path: &std::path::Path) -> Result<String, CliError> {
364 std::fs::read_to_string(path).map_err(|e| CliError::Io(format!("{}: {e}", path.display())))
365}
366
367fn read_bytes(path: &std::path::Path) -> Result<Vec<u8>, CliError> {
369 std::fs::read(path).map_err(|e| CliError::Io(format!("{}: {e}", path.display())))
370}
371
372fn hex_encode(bytes: &[u8]) -> String {
374 use std::fmt::Write as _;
375 let mut s = String::with_capacity(bytes.len() * 2);
376 for b in bytes {
377 let _ = write!(s, "{b:02x}");
378 }
379 s
380}
381
382fn hex_decode(s: &str) -> Result<Vec<u8>, CliError> {
384 let bytes = s.as_bytes();
385 if bytes.len() % 2 != 0 {
386 return Err(CliError::BadMasterKey(format!(
387 "odd-length hex ({} chars)",
388 bytes.len()
389 )));
390 }
391 let mut out = Vec::with_capacity(bytes.len() / 2);
392 let mut i = 0;
393 while i < bytes.len() {
394 out.push((nibble(bytes[i])? << 4) | nibble(bytes[i + 1])?);
395 i += 2;
396 }
397 Ok(out)
398}
399
400pub fn render_text(report: &CliReport) -> String {
402 use std::fmt::Write as _;
403 let mut out = String::new();
404 if report.results.is_empty() {
405 return "No secrets recovered.\n".to_string();
406 }
407 for r in &report.results {
408 let account = r.account.as_deref().unwrap_or("-");
409 let _ = writeln!(
410 out,
411 "[{}] {} | account={} | secret={}",
412 r.store, r.label, account, r.secret
413 );
414 }
415 out
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 fn hex(s: &str) -> Vec<u8> {
423 (0..s.len())
424 .step_by(2)
425 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
426 .collect()
427 }
428
429 const MASTER_KEY_HEX: &str = "9828d9873735439e823dbd216205ff88266d28ad685a413970c640d5ee943154bbade31fada673d542c72d707a163bb3d1bceb0c50465b359ae06998481b0ce3";
431
432 const ENCRYPTED_KEY_B64: &str = "RFBBUEkBAAAA0Iyd3wEV0RGMegDAT8KX6wEAAAAz8Z9e40C+SoouK05ivQzGAAAAAAIAAAAAABBmAAAAAQAAIAAAAAARIjNEVWZ3iJmqu8zd7v8AESIzRFVmd4iZqrvM3e7/AAAAAA6AAAAAAgAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAA+t/5261X1EPXoNd8+fv91ognzpGyym/1M78vdGfOMphl2Zzre4QfJx4U0fUIzjosQAAAAP5yd3Yln699MQCEn7TqSfxp/Ba+vR7Ji1pSJ7TPr7zimD/5Slev0vK6H5r6Mq46ohSMEPLzAWzKvD5xxvJt1sA=";
434 const V10_COOKIE_HEX: &str = "7631300102030405060708090a0b0c1b5af334ffe7a1fe676c5ab453c8848232ab94aa630c69bae71883958ba23e4dfe4cc5faff526ce54b";
435 const V10_PLAINTEXT: &str = "forensic-session-token-42";
436
437 const CRED_FILE_HEX: &str = "01000000b60100000000000001000000d08c9ddf0115d1118c7a00c04fc297eb0100000033f19f5ee340be4a8a2e2b4e62bd0cc600000000020000000000106600000001000020000000aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899000000000e800000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000e2d6d8670704ca1daecd786fe94c133a68fd50708f3ed0ca7013b5e0bc5f61296b5a32b935d6b5404a2162bc26cf561cb7b45f58c7cc8d18305c9dd068860bd4f6cea89ea34db4acde8ebae4606ec1261e8006b104d96eb42975e0df1042aa1161e6c70af5530507238141080d7d7ea1f16a9609963b296143504a4af284826e1436641c74c6dc00d0b1731794887426fc4e4f4d440416c1874aaf34b6a74411d9ed966d73b6a8d05c8546329e7bb4222d2518ab8e2e7d8c47624ec64ecc8a0040000000e0585a675fef9ed63f72673bd9408684dc7fc86ad4926a76c432af933aeab68447e56860b1715cff46516cf38433a856b28a5d0653313a11664b98f2361e8cca";
439 const CRED_EXPECT_TARGET: &str = "Domain:target=TERMSRV/fileserver01";
440 const CRED_EXPECT_SECRET: &str = "S3cr3t-P@ssw0rd!";
441
442 const WIFI_KEY_MATERIAL_HEX: &str = "01000000D08C9DDF0115D1118C7A00C04FC297EB0100000033F19F5EE340BE4A8A2E2B4E62BD0CC600000000020000000000106600000001000020000000DEADBEEFCAFEBABE0011223344556677DEADBEEFCAFEBABE0011223344556677000000000E8000000002000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000051E2C9E20723EF48230A95FCEBBA31AF8BC567EDABBBD958F6E42E4CCE9236C240000000538815E34921B886D09A9CEAD4024E596A73C9C3B53E37A4481D05D7097751049323C613F78C8BD0D8A3AAAB8BF9FBC966E87526245734D0C781DFE0214B1D70";
444 const WIFI_EXPECT_PSK: &str = "CorrectHorseBatteryStaple";
445
446 fn local_state_json() -> String {
447 format!("{{\"os_crypt\":{{\"encrypted_key\":\"{ENCRYPTED_KEY_B64}\"}}}}")
448 }
449
450 fn wifi_xml() -> String {
451 format!("<WLANProfile><MSM><security><sharedKey><keyMaterial>{WIFI_KEY_MATERIAL_HEX}</keyMaterial></sharedKey></security></MSM></WLANProfile>")
452 }
453
454 #[test]
457 fn cli_parses_browser_subcommand() {
458 let cli = Cli::try_parse_from([
459 "dpapi4n6",
460 "browser",
461 "--local-state",
462 "/tmp/Local State",
463 "--master-key",
464 MASTER_KEY_HEX,
465 ])
466 .expect("parse");
467 assert!(matches!(cli.command, Command::Browser { .. }));
468 }
469
470 #[test]
471 fn cli_version_flag_supported() {
472 let err = Cli::try_parse_from(["dpapi4n6", "--version"]).unwrap_err();
474 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
475 }
476
477 #[test]
478 fn master_key_hex_parses_and_rejects_bad() {
479 assert_eq!(parse_master_key_hex("0a0b").unwrap(), vec![0x0a, 0x0b]);
480 assert_eq!(parse_master_key_hex("0x0a0b").unwrap(), vec![0x0a, 0x0b]);
481 assert!(parse_master_key_hex("0a0").is_err()); assert!(parse_master_key_hex("zz").is_err()); }
484
485 #[test]
488 fn browser_decodes_cookie_to_plaintext() {
489 let mk = hex(MASTER_KEY_HEX);
490 let results =
491 decode_browser(&local_state_json(), Some(&hex(V10_COOKIE_HEX)), &mk).expect("ok");
492 assert!(results.iter().any(|r| r.secret == V10_PLAINTEXT));
493 }
494
495 #[test]
496 fn credman_decodes_target_and_secret() {
497 let mk = hex(MASTER_KEY_HEX);
498 let results = decode_credman(&hex(CRED_FILE_HEX), &mk).expect("ok");
499 let r = &results[0];
500 assert_eq!(r.label, CRED_EXPECT_TARGET);
501 assert_eq!(r.secret, CRED_EXPECT_SECRET);
502 }
503
504 #[test]
505 fn wifi_decodes_psk() {
506 let mk = hex(MASTER_KEY_HEX);
507 let results = decode_wifi(&wifi_xml(), &mk).expect("ok");
508 assert_eq!(results[0].secret, WIFI_EXPECT_PSK);
509 }
510
511 #[test]
514 fn locked_store_reports_guid_not_a_secret() {
515 let bad_mk = [0u8; 64];
516 let err = decode_wifi(&wifi_xml(), &bad_mk).unwrap_err();
517 match err {
518 CliError::Locked { store, mk_guid } => {
519 assert_eq!(store, "wifi");
520 assert!(mk_guid.contains('-'), "GUID surfaced: {mk_guid}");
521 }
522 other => panic!("expected Locked, got {other:?}"),
523 }
524 }
525
526 #[test]
527 fn json_report_serializes() {
528 let report = CliReport {
529 results: vec![StoreResult {
530 store: "wifi".into(),
531 label: "PSK".into(),
532 secret: WIFI_EXPECT_PSK.into(),
533 account: None,
534 }],
535 };
536 let json = serde_json::to_string(&report).expect("json");
537 assert!(json.contains(WIFI_EXPECT_PSK));
538 }
539}