use anyhow::Result;
use clap::Parser;
use crate::ui;
#[derive(Parser)]
pub(crate) struct LapsArgs {
#[arg(long)]
pub url: String,
#[arg(long)]
pub user: String,
#[arg(long, default_value = "")]
pub password: String,
#[arg(long)]
pub insecure: bool,
#[arg(long)]
pub target: Option<String>,
}
async fn decrypt_encrypted_laps(
dc_host: &str,
domain: &str,
user: &str,
password: &str,
laps_attr_value: &[u8],
) -> Result<(String, String)> {
use dpapi_ng::{decrypt, laps_password_from_json, rpc, LapsBlob};
let laps = LapsBlob::parse(laps_attr_value)
.map_err(|e| anyhow::anyhow!("parse LAPS header: {e:?}"))?;
let protected = laps
.protected()
.map_err(|e| anyhow::anyhow!("parse CMS ProtectedBlob: {e:?}"))?;
let id = &protected.key_identifier;
let envelope = rpc::get_key(
dc_host,
domain,
user,
password,
&[], Some(id.root_key_id),
id.l0,
id.l1,
id.l2,
)
.await
.map_err(|e| anyhow::anyhow!("GKDI GetKey: {e}"))?;
let plaintext_utf16 =
decrypt(&protected, &envelope).map_err(|e| anyhow::anyhow!("DPAPI-NG decrypt: {e:?}"))?;
let pw = laps_password_from_json(&plaintext_utf16)
.ok_or_else(|| anyhow::anyhow!("decrypted blob has no 'p' field"))?;
let account = {
let json = String::from_utf16_lossy(
&plaintext_utf16
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect::<Vec<_>>(),
);
json.find("\"n\"")
.and_then(|at| json[at + 3..].find('"').map(|s| at + 3 + s + 1))
.and_then(|s| json[s..].find('"').map(|e| json[s..s + e].to_string()))
.unwrap_or_else(|| "Administrator".into())
};
Ok((account, pw))
}
pub(crate) async fn laps(mut a: LapsArgs) -> Result<()> {
a.password = crate::resolve_secret(&a.password, "ADHAMMER_PASSWORD")?;
use adhammer_collector::{Collector, LdapConfig};
let cfg = LdapConfig {
url: a.url.clone(),
bind_dn: a.user.clone(),
password: a.password.clone(),
base_dn: None,
insecure: a.insecure,
gssapi: false,
};
let sp = ui::Spinner::start("reading LAPS passwords over LDAPS");
let mut c = Collector::connect(&cfg).await?;
let entries = c.read_laps(a.target.as_deref()).await?;
sp.done(&format!("{} LAPS entr(y/ies) returned", entries.len()));
if entries.is_empty() {
anyhow::bail!(
"no LAPS password readable (no LAPS deployed, or the bind identity lacks the read right — try a specific --target <HOST$>)"
);
}
let dc_host = a
.url
.trim_start_matches("ldaps://")
.trim_start_matches("ldap://")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.to_string();
let (domain, user) = a
.user
.split_once('\\')
.map(|(d, u)| (d.to_string(), u.to_string()))
.unwrap_or_else(|| (String::new(), a.user.clone()));
let mut cleartext = 0usize;
for e in &entries {
if let Some(pw) = &e.password {
cleartext += 1;
let exp = e
.expires
.as_deref()
.map(|x| format!(" expires={x}"))
.unwrap_or_default();
println!("{}\t{}\t{}{}", e.sam, e.account, pw, exp);
continue;
}
let Some(bytes) = &e.encrypted_blob else {
eprintln!(
"[!] {}: no cleartext and no encrypted blob to work with",
e.sam
);
continue;
};
match decrypt_encrypted_laps(&dc_host, &domain, &user, &a.password, bytes).await {
Ok((account, pw)) => {
cleartext += 1;
println!("{}\t{}\t{}", e.sam, account, pw);
}
Err(err) => eprintln!(
"[!] {} DPAPI-NG decrypt failed: {err} (bind identity may lack the GKDI read right)",
e.sam
),
}
}
ui::ok(&format!(
"LAPS: {cleartext} cleartext local-admin password(s) recovered"
));
Ok(())
}