1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
//! `creds` — offline credential recovery / decode primitives that do not need
//! a live target.
//!
//! - `creds gpp-decrypt` (F6) — decode an MS14-025 Group Policy Preferences
//! `cpassword` blob to plaintext using the public MS AES-256 key.
//! - `creds kdbx-extract` (F4b) — given a KNOWN KeePass master password,
//! decrypt a KDBX4 body and unmask every protected field. Brute-forcing
//! the master password is intentionally NOT in-tree — that job belongs to
//! `keepass2john <file> | hashcat -m 13400 <wordlist>`, and adhammer's
//! `docs/GAPS.md#kdbx-crack` row points at exactly that.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
#[derive(Subcommand)]
pub(crate) enum CredsCmd {
/// Decrypt an MS14-025 GPP `cpassword` blob to plaintext. Uses the public
/// MS-GPPREF AES-256 key (identical on every domain — GPP passwords are
/// plaintext-equivalent, which is the whole finding). Takes the base64
/// string on the CLI or reads it from `--file`.
GppDecrypt(GppDecryptArgs),
/// **1.5.2 F4b** — KDBX4 protected-field extract. Given a KNOWN
/// master password (recover it via `keepass2john <file> | hashcat -m 13400`
/// first — hashcat owns the brute), this verb runs the full KeePass
/// pipeline: Argon2d KDF → SHA-512 HMAC base key → outer cipher
/// (AES256-CBC / ChaCha20) → gzip inflate → inner header parse → XML
/// walk → protected-field unmask via inner ChaCha20 stream. Prints the
/// recovered `Title / UserName / Password / URL` per entry. adhammer
/// does NOT ship an in-tree brute — that would just be a slower hashcat.
KdbxExtract(KdbxExtractArgs),
}
#[derive(Parser)]
pub(crate) struct KdbxExtractArgs {
/// Path to a `.kdbx` file (KDBX4 format).
#[arg(long, value_name = "PATH")]
pub file: String,
/// Master password (accepts `env:VAR` / `@file:PATH` / secure prompt).
/// Mutually exclusive with `--key`.
#[arg(long, conflicts_with = "key")]
pub password: Option<adhammer_core::SecretString>,
/// Hex-encoded composite key (from a prior `kdbx-crack` — 64 hex chars).
/// Mutually exclusive with `--password`.
#[arg(long, value_name = "HEX")]
pub key: Option<String>,
/// Emit JSON envelope.
#[arg(long)]
pub json: bool,
}
#[derive(Parser)]
pub(crate) struct GppDecryptArgs {
/// Base64 cpassword string (e.g. `j1Uyj3Vx8TY9LtLZ...`). Mutually exclusive with `--file`.
#[arg(value_name = "CPASSWORD", conflicts_with = "file")]
pub cpassword: Option<String>,
/// Path to a file containing the base64 cpassword (whitespace ignored).
/// Mutually exclusive with the positional CPASSWORD.
#[arg(long, value_name = "PATH")]
pub file: Option<String>,
/// Prefix the output line with the recovered account name (if the caller
/// pulled one out of the XML). Useful for scripted pipelines.
#[arg(long, value_name = "NAME")]
pub account: Option<String>,
}
pub(crate) async fn gpp_decrypt(a: GppDecryptArgs) -> Result<()> {
let raw = match (a.cpassword.as_deref(), a.file.as_deref()) {
(Some(s), _) => s.trim().to_string(),
(None, Some(p)) => std::fs::read_to_string(p)
.with_context(|| format!("read --file {p}"))?
.split_whitespace()
.collect::<String>(),
(None, None) => {
anyhow::bail!("supply the cpassword as an argument or via --file <PATH> (see --help)")
}
};
if raw.is_empty() {
anyhow::bail!("empty cpassword");
}
let pt = adhammer_sysvol::gpp::decrypt_cpassword(&raw)
.context("MS14-025 AES-CBC decrypt failed — is this a real cpassword blob?")?;
// TAB-separated so shell pipelines can `cut -f`.
match a.account.as_deref() {
Some(name) => println!("{name}\t{}", pt.expose_secret()),
None => println!("{}", pt.expose_secret()),
}
Ok(())
}
pub(crate) async fn kdbx_extract(a: KdbxExtractArgs) -> Result<()> {
let bytes = std::fs::read(&a.file).with_context(|| format!("read --file {}", a.file))?;
let header =
crate::attacks::kdbx::parse_header(&bytes).with_context(|| format!("parse {}", a.file))?;
let pw = match (a.password.as_ref(), a.key.as_ref()) {
(Some(p), None) => p.expose_secret().to_string(),
(None, Some(_)) => {
anyhow::bail!("--key (composite-key hex) path is deferred; supply --password for now");
}
(None, None) => anyhow::bail!("supply --password or --key"),
(Some(_), Some(_)) => unreachable!("clap enforces conflicts_with"),
};
let sp = crate::ui::Spinner::start(format!(
"KDBX{}.{} — Argon2d + body decrypt + XML walk",
header.major, header.minor
));
// Derive the Argon2d key ONCE, verify it against the header HMAC (fail fast
// on a wrong password before body decrypt), then reuse the same key for the
// body extract — Argon2d is expensive, so we never run the KDF twice.
let final_key =
crate::attacks::kdbx::derive_final_key(&pw, &header).context("KDBX4 key derivation")?;
if !crate::attacks::kdbx::verify_header_hmac(&header, &final_key) {
sp.done_warn("wrong password (header HMAC mismatch)");
anyhow::bail!("wrong password");
}
let entries =
crate::attacks::kdbx::extract(&bytes, &header, &final_key).context("KDBX4 body extract")?;
sp.done(&format!(
"recovered {} entries — protected fields unmasked",
entries.len()
));
if a.json {
let arr: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"title": e.title, "username": e.username, "password": e.password,
"url": e.url, "notes": e.notes, "custom": e.custom,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"command": "adhammer creds kdbx-extract",
"success": true,
"evidence": {"file": a.file, "entries": arr},
}))?
);
} else {
for e in &entries {
println!(
"{}\t{}\t{}\t{}",
e.title.clone().unwrap_or_default(),
e.username.clone().unwrap_or_default(),
e.password.clone().unwrap_or_default(),
e.url.clone().unwrap_or_default(),
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
// Round-trip test: adhammer_sysvol carries its own vector; this just proves the
// CLI-side plumbing calls it, without duplicating the key material or an expected
// cleartext string in this file (leak-hook shape discipline).
use super::*;
#[tokio::test]
async fn empty_input_bails_with_a_named_message() {
let err = gpp_decrypt(GppDecryptArgs {
cpassword: None,
file: None,
account: None,
})
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("cpassword"), "message names the input: {msg}");
}
}