netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
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
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
//! RADIUS wire format (RFC 2865 / RFC 2866).
//!
//! Pure encode/decode plus the two MD5 constructions the protocol actually needs. No I/O,
//! no state, no LLM — so it can be tested against literal RFC bytes.
//!
//! # What is implemented, and what is not
//!
//! Implemented, and exercised by tests:
//! - The 20-byte header (code, identifier, length, 16-byte authenticator) and TLV attributes.
//! - The **Response Authenticator**, `MD5(Code | ID | Length | RequestAuth | Attributes |
//!   Secret)` — RFC 2865 §3. A real client rejects the reply if this is wrong, so it is the
//!   one computation that cannot be faked.
//! - **User-Password** hiding/unhiding — RFC 2865 §5.2.
//! - **Message-Authenticator (attribute 80)**, the HMAC-MD5 of RFC 3579 §3.2: put first in
//!   every Access-Accept, -Reject and -Challenge ([`encode_signed_response`]), and verified on
//!   an Access-Request or Status-Server that carries one
//!   ([`verify_request_message_authenticator`]). A request without one is still answered.
//! - The **Accounting-Request Authenticator**, `MD5(Code | ID | Length | 16 zero bytes |
//!   Attributes | Secret)` — RFC 2866 §3. This one is *verifiable*, and the server verifies it.
//!
//! Deliberately **not** implemented, and not claimed anywhere else:
//! - **CHAP (RFC 2865 §5.3)**, **MS-CHAP**, and **EAP (RFC 3579)**. CHAP-Password and
//!   EAP-Message are decoded to hex and handed to the model as opaque; no challenge is
//!   validated and no EAP state machine exists.
//! - Proxy-State forwarding semantics beyond echoing the attribute back.

use md5::{Digest, Md5};
use std::net::Ipv4Addr;

/// RADIUS packet codes (RFC 2865 §3, RFC 2866 §3, RFC 5997).
pub const CODE_ACCESS_REQUEST: u8 = 1;
pub const CODE_ACCESS_ACCEPT: u8 = 2;
pub const CODE_ACCESS_REJECT: u8 = 3;
pub const CODE_ACCOUNTING_REQUEST: u8 = 4;
pub const CODE_ACCOUNTING_RESPONSE: u8 = 5;
pub const CODE_ACCESS_CHALLENGE: u8 = 11;
pub const CODE_STATUS_SERVER: u8 = 12;

/// Fixed header size: code(1) + identifier(1) + length(2) + authenticator(16).
pub const HEADER_LEN: usize = 20;

/// RFC 2865 §3: "The minimum length is 20 and maximum length is 4096."
pub const MIN_PACKET_LEN: usize = 20;
pub const MAX_PACKET_LEN: usize = 4096;

/// Longest User-Password this format can carry, in octets (RFC 2865 §5.2: "The password is
/// hidden … If the password is longer than 128 characters, it is an error").
///
/// One constant for both directions on purpose. [`decode_user_password`] enforces it against
/// a hostile peer and [`encode_user_password`] against the caller building an Access-Request,
/// and the two used to disagree — the encoder had no bound at all, so it produced ciphertext
/// its own decoder rejects.
pub const MAX_USER_PASSWORD_LEN: usize = 128;

/// Attribute type numbers this server names. Anything else is reported to the model by
/// number, never dropped.
pub const ATTR_USER_NAME: u8 = 1;
pub const ATTR_USER_PASSWORD: u8 = 2;
pub const ATTR_CHAP_PASSWORD: u8 = 3;
pub const ATTR_NAS_IP_ADDRESS: u8 = 4;
pub const ATTR_NAS_PORT: u8 = 5;
pub const ATTR_SERVICE_TYPE: u8 = 6;
pub const ATTR_FRAMED_PROTOCOL: u8 = 7;
pub const ATTR_FRAMED_IP_ADDRESS: u8 = 8;
pub const ATTR_FRAMED_IP_NETMASK: u8 = 9;
pub const ATTR_FILTER_ID: u8 = 11;
pub const ATTR_FRAMED_MTU: u8 = 12;
pub const ATTR_REPLY_MESSAGE: u8 = 18;
pub const ATTR_STATE: u8 = 24;
pub const ATTR_CLASS: u8 = 25;
pub const ATTR_SESSION_TIMEOUT: u8 = 27;
pub const ATTR_IDLE_TIMEOUT: u8 = 28;
pub const ATTR_TERMINATION_ACTION: u8 = 29;
pub const ATTR_CALLED_STATION_ID: u8 = 30;
pub const ATTR_CALLING_STATION_ID: u8 = 31;
pub const ATTR_NAS_IDENTIFIER: u8 = 32;
pub const ATTR_PROXY_STATE: u8 = 33;
pub const ATTR_ACCT_STATUS_TYPE: u8 = 40;
pub const ATTR_ACCT_DELAY_TIME: u8 = 41;
pub const ATTR_ACCT_INPUT_OCTETS: u8 = 42;
pub const ATTR_ACCT_OUTPUT_OCTETS: u8 = 43;
pub const ATTR_ACCT_SESSION_ID: u8 = 44;
pub const ATTR_ACCT_AUTHENTIC: u8 = 45;
pub const ATTR_ACCT_SESSION_TIME: u8 = 46;
pub const ATTR_ACCT_TERMINATE_CAUSE: u8 = 49;
pub const ATTR_CHAP_CHALLENGE: u8 = 60;
pub const ATTR_NAS_PORT_TYPE: u8 = 61;
pub const ATTR_PORT_LIMIT: u8 = 62;
pub const ATTR_EAP_MESSAGE: u8 = 79;
pub const ATTR_MESSAGE_AUTHENTICATOR: u8 = 80;

/// How an attribute's value should be rendered for the model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttrKind {
    /// UTF-8 text
    Text,
    /// 32-bit unsigned integer
    Integer,
    /// IPv4 address
    IpAddr,
    /// Opaque bytes; rendered as hex
    Octets,
}

/// The subset of the RADIUS dictionary this server names.
///
/// Returns `(canonical name, value kind)`. Unknown types get `Attribute-<n>` / `Octets`.
pub fn attribute_info(attr_type: u8) -> (&'static str, AttrKind) {
    match attr_type {
        ATTR_USER_NAME => ("User-Name", AttrKind::Text),
        ATTR_USER_PASSWORD => ("User-Password", AttrKind::Octets),
        ATTR_CHAP_PASSWORD => ("CHAP-Password", AttrKind::Octets),
        ATTR_NAS_IP_ADDRESS => ("NAS-IP-Address", AttrKind::IpAddr),
        ATTR_NAS_PORT => ("NAS-Port", AttrKind::Integer),
        ATTR_SERVICE_TYPE => ("Service-Type", AttrKind::Integer),
        ATTR_FRAMED_PROTOCOL => ("Framed-Protocol", AttrKind::Integer),
        ATTR_FRAMED_IP_ADDRESS => ("Framed-IP-Address", AttrKind::IpAddr),
        ATTR_FRAMED_IP_NETMASK => ("Framed-IP-Netmask", AttrKind::IpAddr),
        ATTR_FILTER_ID => ("Filter-Id", AttrKind::Text),
        ATTR_FRAMED_MTU => ("Framed-MTU", AttrKind::Integer),
        ATTR_REPLY_MESSAGE => ("Reply-Message", AttrKind::Text),
        ATTR_STATE => ("State", AttrKind::Octets),
        ATTR_CLASS => ("Class", AttrKind::Octets),
        ATTR_SESSION_TIMEOUT => ("Session-Timeout", AttrKind::Integer),
        ATTR_IDLE_TIMEOUT => ("Idle-Timeout", AttrKind::Integer),
        ATTR_TERMINATION_ACTION => ("Termination-Action", AttrKind::Integer),
        ATTR_CALLED_STATION_ID => ("Called-Station-Id", AttrKind::Text),
        ATTR_CALLING_STATION_ID => ("Calling-Station-Id", AttrKind::Text),
        ATTR_NAS_IDENTIFIER => ("NAS-Identifier", AttrKind::Text),
        ATTR_PROXY_STATE => ("Proxy-State", AttrKind::Octets),
        ATTR_ACCT_STATUS_TYPE => ("Acct-Status-Type", AttrKind::Integer),
        ATTR_ACCT_DELAY_TIME => ("Acct-Delay-Time", AttrKind::Integer),
        ATTR_ACCT_INPUT_OCTETS => ("Acct-Input-Octets", AttrKind::Integer),
        ATTR_ACCT_OUTPUT_OCTETS => ("Acct-Output-Octets", AttrKind::Integer),
        ATTR_ACCT_SESSION_ID => ("Acct-Session-Id", AttrKind::Text),
        ATTR_ACCT_AUTHENTIC => ("Acct-Authentic", AttrKind::Integer),
        ATTR_ACCT_SESSION_TIME => ("Acct-Session-Time", AttrKind::Integer),
        ATTR_ACCT_TERMINATE_CAUSE => ("Acct-Terminate-Cause", AttrKind::Integer),
        ATTR_CHAP_CHALLENGE => ("CHAP-Challenge", AttrKind::Octets),
        ATTR_NAS_PORT_TYPE => ("NAS-Port-Type", AttrKind::Integer),
        ATTR_PORT_LIMIT => ("Port-Limit", AttrKind::Integer),
        ATTR_EAP_MESSAGE => ("EAP-Message", AttrKind::Octets),
        ATTR_MESSAGE_AUTHENTICATOR => ("Message-Authenticator", AttrKind::Octets),
        _ => ("Unknown", AttrKind::Octets),
    }
}

/// True for the codes whose safe default is *denial* rather than silence.
///
/// Access-Request and Status-Server both ask a yes/no question, so a missing answer must
/// become Access-Reject. Accounting-Request asks nothing — it reports — so its safe default
/// is to send nothing and let the NAS retransmit.
pub fn is_authorization_request(code: u8) -> bool {
    matches!(code, CODE_ACCESS_REQUEST | CODE_STATUS_SERVER)
}

/// Human-readable name for a packet code.
pub fn code_name(code: u8) -> &'static str {
    match code {
        CODE_ACCESS_REQUEST => "Access-Request",
        CODE_ACCESS_ACCEPT => "Access-Accept",
        CODE_ACCESS_REJECT => "Access-Reject",
        CODE_ACCOUNTING_REQUEST => "Accounting-Request",
        CODE_ACCOUNTING_RESPONSE => "Accounting-Response",
        CODE_ACCESS_CHALLENGE => "Access-Challenge",
        CODE_STATUS_SERVER => "Status-Server",
        _ => "Unknown",
    }
}

/// One decoded attribute, still in wire form.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribute {
    pub attr_type: u8,
    pub value: Vec<u8>,
}

impl Attribute {
    pub fn new(attr_type: u8, value: Vec<u8>) -> Self {
        Self { attr_type, value }
    }

    pub fn text(attr_type: u8, value: &str) -> Self {
        Self::new(attr_type, value.as_bytes().to_vec())
    }

    pub fn integer(attr_type: u8, value: u32) -> Self {
        Self::new(attr_type, value.to_be_bytes().to_vec())
    }

    pub fn ipv4(attr_type: u8, value: Ipv4Addr) -> Self {
        Self::new(attr_type, value.octets().to_vec())
    }

    /// Wire encoding: `Type(1) | Length(1, including this header) | Value`.
    ///
    /// A value longer than 253 bytes cannot be expressed and is rejected rather than
    /// silently truncated.
    pub fn encode(&self) -> Result<Vec<u8>, RadiusError> {
        if self.value.len() > 253 {
            return Err(RadiusError::AttributeTooLong {
                attr_type: self.attr_type,
                len: self.value.len(),
            });
        }
        let mut out = Vec::with_capacity(2 + self.value.len());
        out.push(self.attr_type);
        out.push((self.value.len() + 2) as u8);
        out.extend_from_slice(&self.value);
        Ok(out)
    }
}

/// A decoded RADIUS packet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RadiusPacket {
    pub code: u8,
    pub identifier: u8,
    pub authenticator: [u8; 16],
    pub attributes: Vec<Attribute>,
}

/// Anything that makes a datagram unusable. Every variant means "drop the packet",
/// which is what RFC 2865 §3 requires of a silently-discarded request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RadiusError {
    TooShort(usize),
    TooLong(usize),
    /// Header length field disagrees with the datagram
    LengthMismatch {
        declared: usize,
        actual: usize,
    },
    /// Attribute length byte is < 2 or runs past the end of the packet
    MalformedAttribute {
        offset: usize,
    },
    AttributeTooLong {
        attr_type: u8,
        len: usize,
    },
    /// User-Password ciphertext is not 16..=128 bytes, or not a multiple of 16
    BadPasswordLength(usize),
    /// Accounting-Request Authenticator did not verify against the shared secret
    BadAccountingAuthenticator,
    /// Encoded packet would exceed 4096 bytes
    ResponseTooLong(usize),
}

impl std::fmt::Display for RadiusError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RadiusError::TooShort(n) => write!(f, "packet is {} bytes, minimum is 20", n),
            RadiusError::TooLong(n) => write!(f, "packet is {} bytes, maximum is 4096", n),
            RadiusError::LengthMismatch { declared, actual } => write!(
                f,
                "header declares {} bytes but datagram carries {}",
                declared, actual
            ),
            RadiusError::MalformedAttribute { offset } => {
                write!(f, "malformed attribute at offset {}", offset)
            }
            RadiusError::AttributeTooLong { attr_type, len } => write!(
                f,
                "attribute {} value is {} bytes, maximum is 253",
                attr_type, len
            ),
            RadiusError::BadPasswordLength(n) => write!(
                f,
                "User-Password is {} bytes; must be 16..=128 and a multiple of 16",
                n
            ),
            RadiusError::BadAccountingAuthenticator => {
                write!(f, "Accounting-Request Authenticator did not verify")
            }
            RadiusError::ResponseTooLong(n) => {
                write!(f, "response would be {} bytes, maximum is 4096", n)
            }
        }
    }
}

impl std::error::Error for RadiusError {}

impl RadiusPacket {
    /// Decode a datagram.
    ///
    /// RFC 2865 §3: octets outside the range of the Length field MUST be treated as padding
    /// and ignored on receipt, so a datagram longer than `length` is accepted and truncated.
    /// A datagram *shorter* than `length` is discarded.
    pub fn decode(data: &[u8]) -> Result<Self, RadiusError> {
        if data.len() < MIN_PACKET_LEN {
            return Err(RadiusError::TooShort(data.len()));
        }
        if data.len() > MAX_PACKET_LEN {
            return Err(RadiusError::TooLong(data.len()));
        }

        let declared = u16::from_be_bytes([data[2], data[3]]) as usize;
        if declared < MIN_PACKET_LEN || declared > data.len() {
            return Err(RadiusError::LengthMismatch {
                declared,
                actual: data.len(),
            });
        }

        let mut authenticator = [0u8; 16];
        authenticator.copy_from_slice(&data[4..20]);

        let attributes = decode_attributes(&data[HEADER_LEN..declared])?;

        Ok(Self {
            code: data[0],
            identifier: data[1],
            authenticator,
            attributes,
        })
    }

    /// First attribute of the given type, if any.
    pub fn first(&self, attr_type: u8) -> Option<&[u8]> {
        self.attributes
            .iter()
            .find(|a| a.attr_type == attr_type)
            .map(|a| a.value.as_slice())
    }

    /// All attributes of the given type, in order (EAP-Message and Proxy-State repeat).
    pub fn all(&self, attr_type: u8) -> Vec<&[u8]> {
        self.attributes
            .iter()
            .filter(|a| a.attr_type == attr_type)
            .map(|a| a.value.as_slice())
            .collect()
    }

    /// The wire bytes of just the attribute section.
    pub fn encoded_attributes(&self) -> Result<Vec<u8>, RadiusError> {
        let mut out = Vec::new();
        for attr in &self.attributes {
            out.extend_from_slice(&attr.encode()?);
        }
        Ok(out)
    }
}

/// Parse the attribute section: repeated `Type(1) | Length(1) | Value(Length-2)`.
pub fn decode_attributes(mut body: &[u8]) -> Result<Vec<Attribute>, RadiusError> {
    let total = body.len();
    let mut attributes = Vec::new();
    while !body.is_empty() {
        let offset = total - body.len();
        if body.len() < 2 {
            return Err(RadiusError::MalformedAttribute { offset });
        }
        let attr_type = body[0];
        let len = body[1] as usize;
        if len < 2 || len > body.len() {
            return Err(RadiusError::MalformedAttribute { offset });
        }
        attributes.push(Attribute::new(attr_type, body[2..len].to_vec()));
        body = &body[len..];
    }
    Ok(attributes)
}

/// Response Authenticator, RFC 2865 §3.
///
/// `MD5(Code | Identifier | Length | RequestAuthenticator | Attributes | Secret)`
///
/// Length is the *response's* total length (20 + attributes), not the request's. Getting
/// that wrong produces a reply every real client silently discards, which is
/// indistinguishable from the server being down — so it is worth stating explicitly.
pub fn response_authenticator(
    code: u8,
    identifier: u8,
    attributes: &[u8],
    request_authenticator: &[u8; 16],
    secret: &[u8],
) -> [u8; 16] {
    let length = (HEADER_LEN + attributes.len()) as u16;
    let mut hasher = Md5::new();
    hasher.update([code, identifier]);
    hasher.update(length.to_be_bytes());
    hasher.update(request_authenticator);
    hasher.update(attributes);
    hasher.update(secret);
    hasher.finalize().into()
}

/// Encode a reply, computing and inserting its Response Authenticator.
pub fn encode_response(
    code: u8,
    identifier: u8,
    attributes: &[Attribute],
    request_authenticator: &[u8; 16],
    secret: &[u8],
) -> Result<Vec<u8>, RadiusError> {
    let mut attr_bytes = Vec::new();
    for attr in attributes {
        attr_bytes.extend_from_slice(&attr.encode()?);
    }

    let total = HEADER_LEN + attr_bytes.len();
    if total > MAX_PACKET_LEN {
        return Err(RadiusError::ResponseTooLong(total));
    }

    let auth = response_authenticator(code, identifier, &attr_bytes, request_authenticator, secret);

    let mut out = Vec::with_capacity(total);
    out.push(code);
    out.push(identifier);
    out.extend_from_slice(&(total as u16).to_be_bytes());
    out.extend_from_slice(&auth);
    out.extend_from_slice(&attr_bytes);
    Ok(out)
}

/// HMAC-MD5 (RFC 2104), which RFC 3579's Message-Authenticator is.
pub fn hmac_md5(key: &[u8], message: &[u8]) -> [u8; 16] {
    let mut block = [0u8; 64];
    if key.len() > 64 {
        block[..16].copy_from_slice(&Md5::digest(key));
    } else {
        block[..key.len()].copy_from_slice(key);
    }
    let mut inner = Md5::new();
    inner.update(block.map(|b| b ^ 0x36));
    inner.update(message);
    let inner = inner.finalize();
    let mut outer = Md5::new();
    outer.update(block.map(|b| b ^ 0x5c));
    outer.update(inner);
    outer.finalize().into()
}

/// [`encode_response`] with a Message-Authenticator (RFC 3579 §3.2) as the first attribute,
/// for Access-Accept, -Reject and -Challenge. It is the HMAC-MD5 of the reply with the
/// *Request* Authenticator in the authenticator field and itself zeroed, computed before the
/// Response Authenticator, which then covers it. Clients patched for BlastRADIUS
/// (CVE-2024-3596) refuse an Access reply without one; NetGet's own RADIUS client does.
pub fn encode_signed_response(
    code: u8,
    identifier: u8,
    attributes: &[Attribute],
    request_authenticator: &[u8; 16],
    secret: &[u8],
) -> Result<Vec<u8>, RadiusError> {
    let mut attr_bytes = Attribute::new(ATTR_MESSAGE_AUTHENTICATOR, vec![0; 16]).encode()?;
    for attr in attributes
        .iter()
        .filter(|a| a.attr_type != ATTR_MESSAGE_AUTHENTICATOR)
    {
        attr_bytes.extend_from_slice(&attr.encode()?);
    }
    let total = HEADER_LEN + attr_bytes.len();
    if total > MAX_PACKET_LEN {
        return Err(RadiusError::ResponseTooLong(total));
    }
    let mut out = Vec::with_capacity(total);
    out.push(code);
    out.push(identifier);
    out.extend_from_slice(&(total as u16).to_be_bytes());
    out.extend_from_slice(request_authenticator);
    out.extend_from_slice(&attr_bytes);
    let mac = hmac_md5(secret, &out);
    out[HEADER_LEN + 2..HEADER_LEN + 18].copy_from_slice(&mac);
    let auth = response_authenticator(
        code,
        identifier,
        &out[HEADER_LEN..],
        request_authenticator,
        secret,
    );
    out[4..20].copy_from_slice(&auth);
    Ok(out)
}

/// Check the Message-Authenticator of an Access-Request or Status-Server as received (RFC 3579
/// §3.2: the HMAC-MD5 of the packet with the attribute zeroed). `Ok(false)` when the packet
/// carries none; `Err` when it carries one that does not verify, which means the sender does
/// not hold the shared secret — the packet is to be discarded.
pub fn verify_request_message_authenticator(raw: &[u8], secret: &[u8]) -> Result<bool, String> {
    if raw.len() < HEADER_LEN {
        return Err("shorter than a RADIUS header".into());
    }
    let declared = usize::from(u16::from_be_bytes([raw[2], raw[3]])).min(raw.len());
    let mut offset = HEADER_LEN;
    let mut found = None;
    while offset + 2 <= declared {
        let len = usize::from(raw[offset + 1]);
        if len < 2 {
            break;
        }
        if raw[offset] == ATTR_MESSAGE_AUTHENTICATOR {
            if len != 18 {
                return Err("a Message-Authenticator that is not 16 octets".into());
            }
            found = Some(offset);
        }
        offset += len;
    }
    let Some(at) = found else { return Ok(false) };
    let mut copy = raw[..declared].to_vec();
    copy[at + 2..at + 18].fill(0);
    let mac = hmac_md5(secret, &copy);
    if mac
        .iter()
        .zip(&raw[at + 2..at + 18])
        .fold(0u8, |d, (x, y)| d | (x ^ y))
        == 0
    {
        Ok(true)
    } else {
        Err(
            "the Message-Authenticator does not verify: the sender does not hold the shared secret"
                .into(),
        )
    }
}

/// Accounting-Request Authenticator, RFC 2866 §3.
///
/// `MD5(Code | Identifier | Length | 16 zero octets | Attributes | Secret)`
///
/// Unlike an Access-Request's authenticator (a random nonce, unverifiable), this one is a
/// keyed digest over the whole packet, so it *can* be checked — and is.
pub fn accounting_request_authenticator(
    identifier: u8,
    length: u16,
    attributes: &[u8],
    secret: &[u8],
) -> [u8; 16] {
    let mut hasher = Md5::new();
    hasher.update([CODE_ACCOUNTING_REQUEST, identifier]);
    hasher.update(length.to_be_bytes());
    hasher.update([0u8; 16]);
    hasher.update(attributes);
    hasher.update(secret);
    hasher.finalize().into()
}

/// Verify an Accounting-Request's authenticator against the shared secret.
///
/// A mismatch means the sender does not hold the secret; the packet must be dropped.
pub fn verify_accounting_request(packet: &RadiusPacket, secret: &[u8]) -> Result<(), RadiusError> {
    let attrs = packet.encoded_attributes()?;
    let length = (HEADER_LEN + attrs.len()) as u16;
    let expected = accounting_request_authenticator(packet.identifier, length, &attrs, secret);
    if constant_time_eq(&expected, &packet.authenticator) {
        Ok(())
    } else {
        Err(RadiusError::BadAccountingAuthenticator)
    }
}

/// Fixed-time 16-byte comparison, so a wrong secret cannot be recovered byte by byte.
fn constant_time_eq(a: &[u8; 16], b: &[u8; 16]) -> bool {
    let mut diff = 0u8;
    for i in 0..16 {
        diff |= a[i] ^ b[i];
    }
    diff == 0
}

/// Reverse the User-Password hiding of RFC 2865 §5.2.
///
/// ```text
/// b1 = MD5(S + RA)          p1 = c(1) xor b1
/// b2 = MD5(S + c(1))        p2 = c(2) xor b2
/// ...
/// ```
///
/// Trailing NULs are the pad the NAS added to reach a 16-octet boundary and are stripped.
/// The result is returned as bytes: a password is not guaranteed to be UTF-8, and callers
/// that need a string should say what they do with invalid sequences.
pub fn decode_user_password(
    ciphertext: &[u8],
    request_authenticator: &[u8; 16],
    secret: &[u8],
) -> Result<Vec<u8>, RadiusError> {
    if ciphertext.is_empty()
        || ciphertext.len() > MAX_USER_PASSWORD_LEN
        || !ciphertext.len().is_multiple_of(16)
    {
        return Err(RadiusError::BadPasswordLength(ciphertext.len()));
    }

    let mut plaintext = Vec::with_capacity(ciphertext.len());
    let mut previous: [u8; 16] = *request_authenticator;

    for chunk in ciphertext.chunks(16) {
        let mut hasher = Md5::new();
        hasher.update(secret);
        hasher.update(previous);
        let b: [u8; 16] = hasher.finalize().into();

        for (i, byte) in chunk.iter().enumerate() {
            plaintext.push(byte ^ b[i]);
        }
        previous.copy_from_slice(chunk);
    }

    // Strip the NUL padding the NAS added, per §5.2.
    while plaintext.last() == Some(&0) {
        plaintext.pop();
    }
    Ok(plaintext)
}

/// Apply the §5.2 hiding. Used only by tests and by anything that needs to *build* an
/// Access-Request; the server itself never encrypts a password.
///
/// Refuses a plaintext past [`MAX_USER_PASSWORD_LEN`], which is the same ceiling
/// [`decode_user_password`] enforces against the wire. Without the check the two directions
/// disagreed: a 129-octet plaintext encrypted happily into 144 octets of ciphertext that this
/// module's own decoder — and every RFC 2865 implementation — rejects.
pub fn encode_user_password(
    plaintext: &[u8],
    request_authenticator: &[u8; 16],
    secret: &[u8],
) -> Result<Vec<u8>, RadiusError> {
    if plaintext.len() > MAX_USER_PASSWORD_LEN {
        return Err(RadiusError::BadPasswordLength(plaintext.len()));
    }

    let mut padded = plaintext.to_vec();
    if padded.is_empty() {
        padded.resize(16, 0);
    } else if !padded.len().is_multiple_of(16) {
        let pad = 16 - (padded.len() % 16);
        padded.resize(padded.len() + pad, 0);
    }

    let mut out = Vec::with_capacity(padded.len());
    let mut previous: [u8; 16] = *request_authenticator;

    for chunk in padded.chunks(16) {
        let mut hasher = Md5::new();
        hasher.update(secret);
        hasher.update(previous);
        let b: [u8; 16] = hasher.finalize().into();

        let mut cipher = [0u8; 16];
        for i in 0..16 {
            cipher[i] = chunk[i] ^ b[i];
        }
        out.extend_from_slice(&cipher);
        previous = cipher;
    }
    Ok(out)
}

/// Render one attribute's value the way the model should see it: never raw bytes,
/// always a typed JSON value, with hex reserved for genuinely opaque fields.
pub fn attribute_value_json(attr_type: u8, value: &[u8]) -> serde_json::Value {
    let (_, kind) = attribute_info(attr_type);
    match kind {
        AttrKind::Text => serde_json::Value::String(String::from_utf8_lossy(value).into_owned()),
        AttrKind::Integer => {
            if value.len() == 4 {
                serde_json::Value::from(u32::from_be_bytes([
                    value[0], value[1], value[2], value[3],
                ]))
            } else {
                serde_json::Value::String(hex::encode(value))
            }
        }
        AttrKind::IpAddr => {
            if value.len() == 4 {
                serde_json::Value::String(
                    Ipv4Addr::new(value[0], value[1], value[2], value[3]).to_string(),
                )
            } else {
                serde_json::Value::String(hex::encode(value))
            }
        }
        AttrKind::Octets => serde_json::Value::String(hex::encode(value)),
    }
}

/// Read whole RADIUS packets from a stream: the 4-byte header's Length says how much follows.
/// A length outside 20..=4096 ends the connection (RFC 6614 §2.5: the stream cannot be
/// resynchronised), as does EOF.
pub async fn read_frames<R: tokio::io::AsyncRead + Unpin>(
    mut reader: R,
    frames: tokio::sync::mpsc::Sender<std::io::Result<Vec<u8>>>,
) {
    use tokio::io::AsyncReadExt;
    loop {
        let mut header = [0u8; 4];
        let result = match reader.read_exact(&mut header).await {
            Ok(_) => {
                let len = u16::from_be_bytes([header[2], header[3]]) as usize;
                if !(MIN_PACKET_LEN..=MAX_PACKET_LEN).contains(&len) {
                    Err(std::io::Error::new(
                        std::io::ErrorKind::InvalidData,
                        format!("a packet length of {len} is outside 20..=4096"),
                    ))
                } else {
                    let mut packet = header.to_vec();
                    packet.resize(len, 0);
                    reader.read_exact(&mut packet[4..]).await.map(|_| packet)
                }
            }
            Err(e) => Err(e),
        };
        let done = result.is_err();
        if frames.send(result).await.is_err() || done {
            return;
        }
    }
}