Skip to main content

netget/server/radius/
packet.rs

1//! RADIUS wire format (RFC 2865 / RFC 2866).
2//!
3//! Pure encode/decode plus the two MD5 constructions the protocol actually needs. No I/O,
4//! no state, no LLM — so it can be tested against literal RFC bytes.
5//!
6//! # What is implemented, and what is not
7//!
8//! Implemented, and exercised by tests:
9//! - The 20-byte header (code, identifier, length, 16-byte authenticator) and TLV attributes.
10//! - The **Response Authenticator**, `MD5(Code | ID | Length | RequestAuth | Attributes |
11//!   Secret)` — RFC 2865 §3. A real client rejects the reply if this is wrong, so it is the
12//!   one computation that cannot be faked.
13//! - **User-Password** hiding/unhiding — RFC 2865 §5.2.
14//! - **Message-Authenticator (attribute 80)**, the HMAC-MD5 of RFC 3579 §3.2: put first in
15//!   every Access-Accept, -Reject and -Challenge ([`encode_signed_response`]), and verified on
16//!   an Access-Request or Status-Server that carries one
17//!   ([`verify_request_message_authenticator`]). A request without one is still answered.
18//! - The **Accounting-Request Authenticator**, `MD5(Code | ID | Length | 16 zero bytes |
19//!   Attributes | Secret)` — RFC 2866 §3. This one is *verifiable*, and the server verifies it.
20//!
21//! Deliberately **not** implemented, and not claimed anywhere else:
22//! - **CHAP (RFC 2865 §5.3)**, **MS-CHAP**, and **EAP (RFC 3579)**. CHAP-Password and
23//!   EAP-Message are decoded to hex and handed to the model as opaque; no challenge is
24//!   validated and no EAP state machine exists.
25//! - Proxy-State forwarding semantics beyond echoing the attribute back.
26
27use md5::{Digest, Md5};
28use std::net::Ipv4Addr;
29
30/// RADIUS packet codes (RFC 2865 §3, RFC 2866 §3, RFC 5997).
31pub const CODE_ACCESS_REQUEST: u8 = 1;
32pub const CODE_ACCESS_ACCEPT: u8 = 2;
33pub const CODE_ACCESS_REJECT: u8 = 3;
34pub const CODE_ACCOUNTING_REQUEST: u8 = 4;
35pub const CODE_ACCOUNTING_RESPONSE: u8 = 5;
36pub const CODE_ACCESS_CHALLENGE: u8 = 11;
37pub const CODE_STATUS_SERVER: u8 = 12;
38
39/// Fixed header size: code(1) + identifier(1) + length(2) + authenticator(16).
40pub const HEADER_LEN: usize = 20;
41
42/// RFC 2865 §3: "The minimum length is 20 and maximum length is 4096."
43pub const MIN_PACKET_LEN: usize = 20;
44pub const MAX_PACKET_LEN: usize = 4096;
45
46/// Longest User-Password this format can carry, in octets (RFC 2865 §5.2: "The password is
47/// hidden … If the password is longer than 128 characters, it is an error").
48///
49/// One constant for both directions on purpose. [`decode_user_password`] enforces it against
50/// a hostile peer and [`encode_user_password`] against the caller building an Access-Request,
51/// and the two used to disagree — the encoder had no bound at all, so it produced ciphertext
52/// its own decoder rejects.
53pub const MAX_USER_PASSWORD_LEN: usize = 128;
54
55/// Attribute type numbers this server names. Anything else is reported to the model by
56/// number, never dropped.
57pub const ATTR_USER_NAME: u8 = 1;
58pub const ATTR_USER_PASSWORD: u8 = 2;
59pub const ATTR_CHAP_PASSWORD: u8 = 3;
60pub const ATTR_NAS_IP_ADDRESS: u8 = 4;
61pub const ATTR_NAS_PORT: u8 = 5;
62pub const ATTR_SERVICE_TYPE: u8 = 6;
63pub const ATTR_FRAMED_PROTOCOL: u8 = 7;
64pub const ATTR_FRAMED_IP_ADDRESS: u8 = 8;
65pub const ATTR_FRAMED_IP_NETMASK: u8 = 9;
66pub const ATTR_FILTER_ID: u8 = 11;
67pub const ATTR_FRAMED_MTU: u8 = 12;
68pub const ATTR_REPLY_MESSAGE: u8 = 18;
69pub const ATTR_STATE: u8 = 24;
70pub const ATTR_CLASS: u8 = 25;
71pub const ATTR_SESSION_TIMEOUT: u8 = 27;
72pub const ATTR_IDLE_TIMEOUT: u8 = 28;
73pub const ATTR_TERMINATION_ACTION: u8 = 29;
74pub const ATTR_CALLED_STATION_ID: u8 = 30;
75pub const ATTR_CALLING_STATION_ID: u8 = 31;
76pub const ATTR_NAS_IDENTIFIER: u8 = 32;
77pub const ATTR_PROXY_STATE: u8 = 33;
78pub const ATTR_ACCT_STATUS_TYPE: u8 = 40;
79pub const ATTR_ACCT_DELAY_TIME: u8 = 41;
80pub const ATTR_ACCT_INPUT_OCTETS: u8 = 42;
81pub const ATTR_ACCT_OUTPUT_OCTETS: u8 = 43;
82pub const ATTR_ACCT_SESSION_ID: u8 = 44;
83pub const ATTR_ACCT_AUTHENTIC: u8 = 45;
84pub const ATTR_ACCT_SESSION_TIME: u8 = 46;
85pub const ATTR_ACCT_TERMINATE_CAUSE: u8 = 49;
86pub const ATTR_CHAP_CHALLENGE: u8 = 60;
87pub const ATTR_NAS_PORT_TYPE: u8 = 61;
88pub const ATTR_PORT_LIMIT: u8 = 62;
89pub const ATTR_EAP_MESSAGE: u8 = 79;
90pub const ATTR_MESSAGE_AUTHENTICATOR: u8 = 80;
91
92/// How an attribute's value should be rendered for the model.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum AttrKind {
95    /// UTF-8 text
96    Text,
97    /// 32-bit unsigned integer
98    Integer,
99    /// IPv4 address
100    IpAddr,
101    /// Opaque bytes; rendered as hex
102    Octets,
103}
104
105/// The subset of the RADIUS dictionary this server names.
106///
107/// Returns `(canonical name, value kind)`. Unknown types get `Attribute-<n>` / `Octets`.
108pub fn attribute_info(attr_type: u8) -> (&'static str, AttrKind) {
109    match attr_type {
110        ATTR_USER_NAME => ("User-Name", AttrKind::Text),
111        ATTR_USER_PASSWORD => ("User-Password", AttrKind::Octets),
112        ATTR_CHAP_PASSWORD => ("CHAP-Password", AttrKind::Octets),
113        ATTR_NAS_IP_ADDRESS => ("NAS-IP-Address", AttrKind::IpAddr),
114        ATTR_NAS_PORT => ("NAS-Port", AttrKind::Integer),
115        ATTR_SERVICE_TYPE => ("Service-Type", AttrKind::Integer),
116        ATTR_FRAMED_PROTOCOL => ("Framed-Protocol", AttrKind::Integer),
117        ATTR_FRAMED_IP_ADDRESS => ("Framed-IP-Address", AttrKind::IpAddr),
118        ATTR_FRAMED_IP_NETMASK => ("Framed-IP-Netmask", AttrKind::IpAddr),
119        ATTR_FILTER_ID => ("Filter-Id", AttrKind::Text),
120        ATTR_FRAMED_MTU => ("Framed-MTU", AttrKind::Integer),
121        ATTR_REPLY_MESSAGE => ("Reply-Message", AttrKind::Text),
122        ATTR_STATE => ("State", AttrKind::Octets),
123        ATTR_CLASS => ("Class", AttrKind::Octets),
124        ATTR_SESSION_TIMEOUT => ("Session-Timeout", AttrKind::Integer),
125        ATTR_IDLE_TIMEOUT => ("Idle-Timeout", AttrKind::Integer),
126        ATTR_TERMINATION_ACTION => ("Termination-Action", AttrKind::Integer),
127        ATTR_CALLED_STATION_ID => ("Called-Station-Id", AttrKind::Text),
128        ATTR_CALLING_STATION_ID => ("Calling-Station-Id", AttrKind::Text),
129        ATTR_NAS_IDENTIFIER => ("NAS-Identifier", AttrKind::Text),
130        ATTR_PROXY_STATE => ("Proxy-State", AttrKind::Octets),
131        ATTR_ACCT_STATUS_TYPE => ("Acct-Status-Type", AttrKind::Integer),
132        ATTR_ACCT_DELAY_TIME => ("Acct-Delay-Time", AttrKind::Integer),
133        ATTR_ACCT_INPUT_OCTETS => ("Acct-Input-Octets", AttrKind::Integer),
134        ATTR_ACCT_OUTPUT_OCTETS => ("Acct-Output-Octets", AttrKind::Integer),
135        ATTR_ACCT_SESSION_ID => ("Acct-Session-Id", AttrKind::Text),
136        ATTR_ACCT_AUTHENTIC => ("Acct-Authentic", AttrKind::Integer),
137        ATTR_ACCT_SESSION_TIME => ("Acct-Session-Time", AttrKind::Integer),
138        ATTR_ACCT_TERMINATE_CAUSE => ("Acct-Terminate-Cause", AttrKind::Integer),
139        ATTR_CHAP_CHALLENGE => ("CHAP-Challenge", AttrKind::Octets),
140        ATTR_NAS_PORT_TYPE => ("NAS-Port-Type", AttrKind::Integer),
141        ATTR_PORT_LIMIT => ("Port-Limit", AttrKind::Integer),
142        ATTR_EAP_MESSAGE => ("EAP-Message", AttrKind::Octets),
143        ATTR_MESSAGE_AUTHENTICATOR => ("Message-Authenticator", AttrKind::Octets),
144        _ => ("Unknown", AttrKind::Octets),
145    }
146}
147
148/// True for the codes whose safe default is *denial* rather than silence.
149///
150/// Access-Request and Status-Server both ask a yes/no question, so a missing answer must
151/// become Access-Reject. Accounting-Request asks nothing — it reports — so its safe default
152/// is to send nothing and let the NAS retransmit.
153pub fn is_authorization_request(code: u8) -> bool {
154    matches!(code, CODE_ACCESS_REQUEST | CODE_STATUS_SERVER)
155}
156
157/// Human-readable name for a packet code.
158pub fn code_name(code: u8) -> &'static str {
159    match code {
160        CODE_ACCESS_REQUEST => "Access-Request",
161        CODE_ACCESS_ACCEPT => "Access-Accept",
162        CODE_ACCESS_REJECT => "Access-Reject",
163        CODE_ACCOUNTING_REQUEST => "Accounting-Request",
164        CODE_ACCOUNTING_RESPONSE => "Accounting-Response",
165        CODE_ACCESS_CHALLENGE => "Access-Challenge",
166        CODE_STATUS_SERVER => "Status-Server",
167        _ => "Unknown",
168    }
169}
170
171/// One decoded attribute, still in wire form.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct Attribute {
174    pub attr_type: u8,
175    pub value: Vec<u8>,
176}
177
178impl Attribute {
179    pub fn new(attr_type: u8, value: Vec<u8>) -> Self {
180        Self { attr_type, value }
181    }
182
183    pub fn text(attr_type: u8, value: &str) -> Self {
184        Self::new(attr_type, value.as_bytes().to_vec())
185    }
186
187    pub fn integer(attr_type: u8, value: u32) -> Self {
188        Self::new(attr_type, value.to_be_bytes().to_vec())
189    }
190
191    pub fn ipv4(attr_type: u8, value: Ipv4Addr) -> Self {
192        Self::new(attr_type, value.octets().to_vec())
193    }
194
195    /// Wire encoding: `Type(1) | Length(1, including this header) | Value`.
196    ///
197    /// A value longer than 253 bytes cannot be expressed and is rejected rather than
198    /// silently truncated.
199    pub fn encode(&self) -> Result<Vec<u8>, RadiusError> {
200        if self.value.len() > 253 {
201            return Err(RadiusError::AttributeTooLong {
202                attr_type: self.attr_type,
203                len: self.value.len(),
204            });
205        }
206        let mut out = Vec::with_capacity(2 + self.value.len());
207        out.push(self.attr_type);
208        out.push((self.value.len() + 2) as u8);
209        out.extend_from_slice(&self.value);
210        Ok(out)
211    }
212}
213
214/// A decoded RADIUS packet.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct RadiusPacket {
217    pub code: u8,
218    pub identifier: u8,
219    pub authenticator: [u8; 16],
220    pub attributes: Vec<Attribute>,
221}
222
223/// Anything that makes a datagram unusable. Every variant means "drop the packet",
224/// which is what RFC 2865 §3 requires of a silently-discarded request.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum RadiusError {
227    TooShort(usize),
228    TooLong(usize),
229    /// Header length field disagrees with the datagram
230    LengthMismatch {
231        declared: usize,
232        actual: usize,
233    },
234    /// Attribute length byte is < 2 or runs past the end of the packet
235    MalformedAttribute {
236        offset: usize,
237    },
238    AttributeTooLong {
239        attr_type: u8,
240        len: usize,
241    },
242    /// User-Password ciphertext is not 16..=128 bytes, or not a multiple of 16
243    BadPasswordLength(usize),
244    /// Accounting-Request Authenticator did not verify against the shared secret
245    BadAccountingAuthenticator,
246    /// Encoded packet would exceed 4096 bytes
247    ResponseTooLong(usize),
248}
249
250impl std::fmt::Display for RadiusError {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match self {
253            RadiusError::TooShort(n) => write!(f, "packet is {} bytes, minimum is 20", n),
254            RadiusError::TooLong(n) => write!(f, "packet is {} bytes, maximum is 4096", n),
255            RadiusError::LengthMismatch { declared, actual } => write!(
256                f,
257                "header declares {} bytes but datagram carries {}",
258                declared, actual
259            ),
260            RadiusError::MalformedAttribute { offset } => {
261                write!(f, "malformed attribute at offset {}", offset)
262            }
263            RadiusError::AttributeTooLong { attr_type, len } => write!(
264                f,
265                "attribute {} value is {} bytes, maximum is 253",
266                attr_type, len
267            ),
268            RadiusError::BadPasswordLength(n) => write!(
269                f,
270                "User-Password is {} bytes; must be 16..=128 and a multiple of 16",
271                n
272            ),
273            RadiusError::BadAccountingAuthenticator => {
274                write!(f, "Accounting-Request Authenticator did not verify")
275            }
276            RadiusError::ResponseTooLong(n) => {
277                write!(f, "response would be {} bytes, maximum is 4096", n)
278            }
279        }
280    }
281}
282
283impl std::error::Error for RadiusError {}
284
285impl RadiusPacket {
286    /// Decode a datagram.
287    ///
288    /// RFC 2865 §3: octets outside the range of the Length field MUST be treated as padding
289    /// and ignored on receipt, so a datagram longer than `length` is accepted and truncated.
290    /// A datagram *shorter* than `length` is discarded.
291    pub fn decode(data: &[u8]) -> Result<Self, RadiusError> {
292        if data.len() < MIN_PACKET_LEN {
293            return Err(RadiusError::TooShort(data.len()));
294        }
295        if data.len() > MAX_PACKET_LEN {
296            return Err(RadiusError::TooLong(data.len()));
297        }
298
299        let declared = u16::from_be_bytes([data[2], data[3]]) as usize;
300        if declared < MIN_PACKET_LEN || declared > data.len() {
301            return Err(RadiusError::LengthMismatch {
302                declared,
303                actual: data.len(),
304            });
305        }
306
307        let mut authenticator = [0u8; 16];
308        authenticator.copy_from_slice(&data[4..20]);
309
310        let attributes = decode_attributes(&data[HEADER_LEN..declared])?;
311
312        Ok(Self {
313            code: data[0],
314            identifier: data[1],
315            authenticator,
316            attributes,
317        })
318    }
319
320    /// First attribute of the given type, if any.
321    pub fn first(&self, attr_type: u8) -> Option<&[u8]> {
322        self.attributes
323            .iter()
324            .find(|a| a.attr_type == attr_type)
325            .map(|a| a.value.as_slice())
326    }
327
328    /// All attributes of the given type, in order (EAP-Message and Proxy-State repeat).
329    pub fn all(&self, attr_type: u8) -> Vec<&[u8]> {
330        self.attributes
331            .iter()
332            .filter(|a| a.attr_type == attr_type)
333            .map(|a| a.value.as_slice())
334            .collect()
335    }
336
337    /// The wire bytes of just the attribute section.
338    pub fn encoded_attributes(&self) -> Result<Vec<u8>, RadiusError> {
339        let mut out = Vec::new();
340        for attr in &self.attributes {
341            out.extend_from_slice(&attr.encode()?);
342        }
343        Ok(out)
344    }
345}
346
347/// Parse the attribute section: repeated `Type(1) | Length(1) | Value(Length-2)`.
348pub fn decode_attributes(mut body: &[u8]) -> Result<Vec<Attribute>, RadiusError> {
349    let total = body.len();
350    let mut attributes = Vec::new();
351    while !body.is_empty() {
352        let offset = total - body.len();
353        if body.len() < 2 {
354            return Err(RadiusError::MalformedAttribute { offset });
355        }
356        let attr_type = body[0];
357        let len = body[1] as usize;
358        if len < 2 || len > body.len() {
359            return Err(RadiusError::MalformedAttribute { offset });
360        }
361        attributes.push(Attribute::new(attr_type, body[2..len].to_vec()));
362        body = &body[len..];
363    }
364    Ok(attributes)
365}
366
367/// Response Authenticator, RFC 2865 §3.
368///
369/// `MD5(Code | Identifier | Length | RequestAuthenticator | Attributes | Secret)`
370///
371/// Length is the *response's* total length (20 + attributes), not the request's. Getting
372/// that wrong produces a reply every real client silently discards, which is
373/// indistinguishable from the server being down — so it is worth stating explicitly.
374pub fn response_authenticator(
375    code: u8,
376    identifier: u8,
377    attributes: &[u8],
378    request_authenticator: &[u8; 16],
379    secret: &[u8],
380) -> [u8; 16] {
381    let length = (HEADER_LEN + attributes.len()) as u16;
382    let mut hasher = Md5::new();
383    hasher.update([code, identifier]);
384    hasher.update(length.to_be_bytes());
385    hasher.update(request_authenticator);
386    hasher.update(attributes);
387    hasher.update(secret);
388    hasher.finalize().into()
389}
390
391/// Encode a reply, computing and inserting its Response Authenticator.
392pub fn encode_response(
393    code: u8,
394    identifier: u8,
395    attributes: &[Attribute],
396    request_authenticator: &[u8; 16],
397    secret: &[u8],
398) -> Result<Vec<u8>, RadiusError> {
399    let mut attr_bytes = Vec::new();
400    for attr in attributes {
401        attr_bytes.extend_from_slice(&attr.encode()?);
402    }
403
404    let total = HEADER_LEN + attr_bytes.len();
405    if total > MAX_PACKET_LEN {
406        return Err(RadiusError::ResponseTooLong(total));
407    }
408
409    let auth = response_authenticator(code, identifier, &attr_bytes, request_authenticator, secret);
410
411    let mut out = Vec::with_capacity(total);
412    out.push(code);
413    out.push(identifier);
414    out.extend_from_slice(&(total as u16).to_be_bytes());
415    out.extend_from_slice(&auth);
416    out.extend_from_slice(&attr_bytes);
417    Ok(out)
418}
419
420/// HMAC-MD5 (RFC 2104), which RFC 3579's Message-Authenticator is.
421pub fn hmac_md5(key: &[u8], message: &[u8]) -> [u8; 16] {
422    let mut block = [0u8; 64];
423    if key.len() > 64 {
424        block[..16].copy_from_slice(&Md5::digest(key));
425    } else {
426        block[..key.len()].copy_from_slice(key);
427    }
428    let mut inner = Md5::new();
429    inner.update(block.map(|b| b ^ 0x36));
430    inner.update(message);
431    let inner = inner.finalize();
432    let mut outer = Md5::new();
433    outer.update(block.map(|b| b ^ 0x5c));
434    outer.update(inner);
435    outer.finalize().into()
436}
437
438/// [`encode_response`] with a Message-Authenticator (RFC 3579 §3.2) as the first attribute,
439/// for Access-Accept, -Reject and -Challenge. It is the HMAC-MD5 of the reply with the
440/// *Request* Authenticator in the authenticator field and itself zeroed, computed before the
441/// Response Authenticator, which then covers it. Clients patched for BlastRADIUS
442/// (CVE-2024-3596) refuse an Access reply without one; NetGet's own RADIUS client does.
443pub fn encode_signed_response(
444    code: u8,
445    identifier: u8,
446    attributes: &[Attribute],
447    request_authenticator: &[u8; 16],
448    secret: &[u8],
449) -> Result<Vec<u8>, RadiusError> {
450    let mut attr_bytes = Attribute::new(ATTR_MESSAGE_AUTHENTICATOR, vec![0; 16]).encode()?;
451    for attr in attributes
452        .iter()
453        .filter(|a| a.attr_type != ATTR_MESSAGE_AUTHENTICATOR)
454    {
455        attr_bytes.extend_from_slice(&attr.encode()?);
456    }
457    let total = HEADER_LEN + attr_bytes.len();
458    if total > MAX_PACKET_LEN {
459        return Err(RadiusError::ResponseTooLong(total));
460    }
461    let mut out = Vec::with_capacity(total);
462    out.push(code);
463    out.push(identifier);
464    out.extend_from_slice(&(total as u16).to_be_bytes());
465    out.extend_from_slice(request_authenticator);
466    out.extend_from_slice(&attr_bytes);
467    let mac = hmac_md5(secret, &out);
468    out[HEADER_LEN + 2..HEADER_LEN + 18].copy_from_slice(&mac);
469    let auth = response_authenticator(
470        code,
471        identifier,
472        &out[HEADER_LEN..],
473        request_authenticator,
474        secret,
475    );
476    out[4..20].copy_from_slice(&auth);
477    Ok(out)
478}
479
480/// Check the Message-Authenticator of an Access-Request or Status-Server as received (RFC 3579
481/// §3.2: the HMAC-MD5 of the packet with the attribute zeroed). `Ok(false)` when the packet
482/// carries none; `Err` when it carries one that does not verify, which means the sender does
483/// not hold the shared secret — the packet is to be discarded.
484pub fn verify_request_message_authenticator(raw: &[u8], secret: &[u8]) -> Result<bool, String> {
485    if raw.len() < HEADER_LEN {
486        return Err("shorter than a RADIUS header".into());
487    }
488    let declared = usize::from(u16::from_be_bytes([raw[2], raw[3]])).min(raw.len());
489    let mut offset = HEADER_LEN;
490    let mut found = None;
491    while offset + 2 <= declared {
492        let len = usize::from(raw[offset + 1]);
493        if len < 2 {
494            break;
495        }
496        if raw[offset] == ATTR_MESSAGE_AUTHENTICATOR {
497            if len != 18 {
498                return Err("a Message-Authenticator that is not 16 octets".into());
499            }
500            found = Some(offset);
501        }
502        offset += len;
503    }
504    let Some(at) = found else { return Ok(false) };
505    let mut copy = raw[..declared].to_vec();
506    copy[at + 2..at + 18].fill(0);
507    let mac = hmac_md5(secret, &copy);
508    if mac
509        .iter()
510        .zip(&raw[at + 2..at + 18])
511        .fold(0u8, |d, (x, y)| d | (x ^ y))
512        == 0
513    {
514        Ok(true)
515    } else {
516        Err(
517            "the Message-Authenticator does not verify: the sender does not hold the shared secret"
518                .into(),
519        )
520    }
521}
522
523/// Accounting-Request Authenticator, RFC 2866 §3.
524///
525/// `MD5(Code | Identifier | Length | 16 zero octets | Attributes | Secret)`
526///
527/// Unlike an Access-Request's authenticator (a random nonce, unverifiable), this one is a
528/// keyed digest over the whole packet, so it *can* be checked — and is.
529pub fn accounting_request_authenticator(
530    identifier: u8,
531    length: u16,
532    attributes: &[u8],
533    secret: &[u8],
534) -> [u8; 16] {
535    let mut hasher = Md5::new();
536    hasher.update([CODE_ACCOUNTING_REQUEST, identifier]);
537    hasher.update(length.to_be_bytes());
538    hasher.update([0u8; 16]);
539    hasher.update(attributes);
540    hasher.update(secret);
541    hasher.finalize().into()
542}
543
544/// Verify an Accounting-Request's authenticator against the shared secret.
545///
546/// A mismatch means the sender does not hold the secret; the packet must be dropped.
547pub fn verify_accounting_request(packet: &RadiusPacket, secret: &[u8]) -> Result<(), RadiusError> {
548    let attrs = packet.encoded_attributes()?;
549    let length = (HEADER_LEN + attrs.len()) as u16;
550    let expected = accounting_request_authenticator(packet.identifier, length, &attrs, secret);
551    if constant_time_eq(&expected, &packet.authenticator) {
552        Ok(())
553    } else {
554        Err(RadiusError::BadAccountingAuthenticator)
555    }
556}
557
558/// Fixed-time 16-byte comparison, so a wrong secret cannot be recovered byte by byte.
559fn constant_time_eq(a: &[u8; 16], b: &[u8; 16]) -> bool {
560    let mut diff = 0u8;
561    for i in 0..16 {
562        diff |= a[i] ^ b[i];
563    }
564    diff == 0
565}
566
567/// Reverse the User-Password hiding of RFC 2865 §5.2.
568///
569/// ```text
570/// b1 = MD5(S + RA)          p1 = c(1) xor b1
571/// b2 = MD5(S + c(1))        p2 = c(2) xor b2
572/// ...
573/// ```
574///
575/// Trailing NULs are the pad the NAS added to reach a 16-octet boundary and are stripped.
576/// The result is returned as bytes: a password is not guaranteed to be UTF-8, and callers
577/// that need a string should say what they do with invalid sequences.
578pub fn decode_user_password(
579    ciphertext: &[u8],
580    request_authenticator: &[u8; 16],
581    secret: &[u8],
582) -> Result<Vec<u8>, RadiusError> {
583    if ciphertext.is_empty()
584        || ciphertext.len() > MAX_USER_PASSWORD_LEN
585        || !ciphertext.len().is_multiple_of(16)
586    {
587        return Err(RadiusError::BadPasswordLength(ciphertext.len()));
588    }
589
590    let mut plaintext = Vec::with_capacity(ciphertext.len());
591    let mut previous: [u8; 16] = *request_authenticator;
592
593    for chunk in ciphertext.chunks(16) {
594        let mut hasher = Md5::new();
595        hasher.update(secret);
596        hasher.update(previous);
597        let b: [u8; 16] = hasher.finalize().into();
598
599        for (i, byte) in chunk.iter().enumerate() {
600            plaintext.push(byte ^ b[i]);
601        }
602        previous.copy_from_slice(chunk);
603    }
604
605    // Strip the NUL padding the NAS added, per §5.2.
606    while plaintext.last() == Some(&0) {
607        plaintext.pop();
608    }
609    Ok(plaintext)
610}
611
612/// Apply the §5.2 hiding. Used only by tests and by anything that needs to *build* an
613/// Access-Request; the server itself never encrypts a password.
614///
615/// Refuses a plaintext past [`MAX_USER_PASSWORD_LEN`], which is the same ceiling
616/// [`decode_user_password`] enforces against the wire. Without the check the two directions
617/// disagreed: a 129-octet plaintext encrypted happily into 144 octets of ciphertext that this
618/// module's own decoder — and every RFC 2865 implementation — rejects.
619pub fn encode_user_password(
620    plaintext: &[u8],
621    request_authenticator: &[u8; 16],
622    secret: &[u8],
623) -> Result<Vec<u8>, RadiusError> {
624    if plaintext.len() > MAX_USER_PASSWORD_LEN {
625        return Err(RadiusError::BadPasswordLength(plaintext.len()));
626    }
627
628    let mut padded = plaintext.to_vec();
629    if padded.is_empty() {
630        padded.resize(16, 0);
631    } else if !padded.len().is_multiple_of(16) {
632        let pad = 16 - (padded.len() % 16);
633        padded.resize(padded.len() + pad, 0);
634    }
635
636    let mut out = Vec::with_capacity(padded.len());
637    let mut previous: [u8; 16] = *request_authenticator;
638
639    for chunk in padded.chunks(16) {
640        let mut hasher = Md5::new();
641        hasher.update(secret);
642        hasher.update(previous);
643        let b: [u8; 16] = hasher.finalize().into();
644
645        let mut cipher = [0u8; 16];
646        for i in 0..16 {
647            cipher[i] = chunk[i] ^ b[i];
648        }
649        out.extend_from_slice(&cipher);
650        previous = cipher;
651    }
652    Ok(out)
653}
654
655/// Render one attribute's value the way the model should see it: never raw bytes,
656/// always a typed JSON value, with hex reserved for genuinely opaque fields.
657pub fn attribute_value_json(attr_type: u8, value: &[u8]) -> serde_json::Value {
658    let (_, kind) = attribute_info(attr_type);
659    match kind {
660        AttrKind::Text => serde_json::Value::String(String::from_utf8_lossy(value).into_owned()),
661        AttrKind::Integer => {
662            if value.len() == 4 {
663                serde_json::Value::from(u32::from_be_bytes([
664                    value[0], value[1], value[2], value[3],
665                ]))
666            } else {
667                serde_json::Value::String(hex::encode(value))
668            }
669        }
670        AttrKind::IpAddr => {
671            if value.len() == 4 {
672                serde_json::Value::String(
673                    Ipv4Addr::new(value[0], value[1], value[2], value[3]).to_string(),
674                )
675            } else {
676                serde_json::Value::String(hex::encode(value))
677            }
678        }
679        AttrKind::Octets => serde_json::Value::String(hex::encode(value)),
680    }
681}
682
683/// Read whole RADIUS packets from a stream: the 4-byte header's Length says how much follows.
684/// A length outside 20..=4096 ends the connection (RFC 6614 §2.5: the stream cannot be
685/// resynchronised), as does EOF.
686pub async fn read_frames<R: tokio::io::AsyncRead + Unpin>(
687    mut reader: R,
688    frames: tokio::sync::mpsc::Sender<std::io::Result<Vec<u8>>>,
689) {
690    use tokio::io::AsyncReadExt;
691    loop {
692        let mut header = [0u8; 4];
693        let result = match reader.read_exact(&mut header).await {
694            Ok(_) => {
695                let len = u16::from_be_bytes([header[2], header[3]]) as usize;
696                if !(MIN_PACKET_LEN..=MAX_PACKET_LEN).contains(&len) {
697                    Err(std::io::Error::new(
698                        std::io::ErrorKind::InvalidData,
699                        format!("a packet length of {len} is outside 20..=4096"),
700                    ))
701                } else {
702                    let mut packet = header.to_vec();
703                    packet.resize(len, 0);
704                    reader.read_exact(&mut packet[4..]).await.map(|_| packet)
705                }
706            }
707            Err(e) => Err(e),
708        };
709        let done = result.is_err();
710        if frames.send(result).await.is_err() || done {
711            return;
712        }
713    }
714}