Skip to main content

netget/client/radius/
wire.rs

1//! RADIUS client packets: what the server's `packet.rs` leaves out because a server never needs
2//! it — building requests, and checking a reply's two authenticators against the request.
3//!
4//! The header, attribute TLVs, User-Password hiding (RFC 2865 §5.2), the Response
5//! Authenticator (§3) and the Accounting-Request Authenticator (RFC 2866 §3) are
6//! `src/server/radius/packet.rs`, shared. This module adds the three constructions only a
7//! client makes: CHAP-Password (RFC 2865 §2.2), the Message-Authenticator HMAC-MD5 (RFC 3579
8//! §3.2) on both requests and replies, and the order they must be computed in.
9//!
10//! Pure functions; no I/O, no state. The shared secret is a parameter and is returned in
11//! nothing.
12
13use crate::server::radius::packet::{
14    accounting_request_authenticator, encode_user_password, response_authenticator, Attribute,
15    RadiusPacket, ATTR_CHAP_PASSWORD, ATTR_MESSAGE_AUTHENTICATOR, ATTR_USER_PASSWORD,
16    CODE_ACCESS_ACCEPT, CODE_ACCESS_CHALLENGE, CODE_ACCESS_REJECT, CODE_ACCESS_REQUEST,
17    CODE_ACCOUNTING_REQUEST, CODE_ACCOUNTING_RESPONSE, CODE_STATUS_SERVER, HEADER_LEN,
18    MAX_PACKET_LEN,
19};
20use md5::{Digest, Md5};
21
22pub use crate::server::radius::packet::hmac_md5;
23
24/// CHAP-Password's value: the CHAP identifier, then `MD5(id | password | challenge)`.
25///
26/// The Request Authenticator is used as the challenge and no CHAP-Challenge attribute is sent,
27/// which RFC 2865 §2.2 permits and every server implements.
28pub fn chap_password(chap_id: u8, password: &[u8], challenge: &[u8; 16]) -> Vec<u8> {
29    let mut hasher = Md5::new();
30    hasher.update([chap_id]);
31    hasher.update(password);
32    hasher.update(challenge);
33    let mut out = vec![chap_id];
34    out.extend_from_slice(&hasher.finalize());
35    out
36}
37
38/// How an Access-Request carries its password.
39pub enum Credential<'a> {
40    Pap(&'a [u8]),
41    Chap(&'a [u8]),
42    /// No password: a challenge response carrying only State, or a probe.
43    None,
44}
45
46/// Why a packet could not be built.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct BuildError(pub String);
49
50impl std::fmt::Display for BuildError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(&self.0)
53    }
54}
55
56fn header(
57    code: u8,
58    identifier: u8,
59    authenticator: &[u8; 16],
60    attrs: &[u8],
61) -> Result<Vec<u8>, BuildError> {
62    let total = HEADER_LEN + attrs.len();
63    if total > MAX_PACKET_LEN {
64        return Err(BuildError(format!(
65            "the packet would be {total} bytes; RADIUS allows at most {MAX_PACKET_LEN}"
66        )));
67    }
68    let mut out = Vec::with_capacity(total);
69    out.push(code);
70    out.push(identifier);
71    // Range-checked above.
72    out.extend_from_slice(&u16::try_from(total).unwrap_or(u16::MAX).to_be_bytes());
73    out.extend_from_slice(authenticator);
74    out.extend_from_slice(attrs);
75    Ok(out)
76}
77
78fn encode_all(attributes: &[Attribute]) -> Result<Vec<u8>, BuildError> {
79    let mut out = Vec::new();
80    for a in attributes {
81        out.extend_from_slice(&a.encode().map_err(|e| BuildError(e.to_string()))?);
82    }
83    Ok(out)
84}
85
86/// Append a Message-Authenticator to a finished packet and fill it in: HMAC-MD5 over the whole
87/// packet with the attribute's value zeroed (RFC 3579 §3.2). The packet's length field is
88/// updated first, because the HMAC covers it.
89fn sign_with_message_authenticator(
90    mut packet: Vec<u8>,
91    secret: &[u8],
92) -> Result<Vec<u8>, BuildError> {
93    let at = packet.len();
94    packet.extend_from_slice(&[ATTR_MESSAGE_AUTHENTICATOR, 18]);
95    packet.extend_from_slice(&[0u8; 16]);
96    if packet.len() > MAX_PACKET_LEN {
97        return Err(BuildError(format!(
98            "the packet would be {} bytes; RADIUS allows at most {MAX_PACKET_LEN}",
99            packet.len()
100        )));
101    }
102    let len = u16::try_from(packet.len()).unwrap_or(u16::MAX);
103    packet[2..4].copy_from_slice(&len.to_be_bytes());
104    let mac = hmac_md5(secret, &packet);
105    packet[at + 2..at + 18].copy_from_slice(&mac);
106    Ok(packet)
107}
108
109/// An Access-Request (RFC 2865 §4.1), always carrying a Message-Authenticator.
110///
111/// The Message-Authenticator is sent on every Access-Request, not only EAP ones: servers
112/// patched for BlastRADIUS (CVE-2024-3596) require it, and it is what lets a reply be
113/// authenticated by more than the MD5 Response Authenticator alone.
114pub fn access_request(
115    identifier: u8,
116    request_authenticator: &[u8; 16],
117    credential: Credential<'_>,
118    attributes: &[Attribute],
119    secret: &[u8],
120) -> Result<Vec<u8>, BuildError> {
121    let mut attrs = attributes.to_vec();
122    match credential {
123        Credential::Pap(password) => {
124            let hidden = encode_user_password(password, request_authenticator, secret)
125                .map_err(|e| BuildError(e.to_string()))?;
126            attrs.push(Attribute::new(ATTR_USER_PASSWORD, hidden));
127        }
128        Credential::Chap(password) => {
129            // The CHAP identifier only has to vary between challenges; the packet identifier
130            // does.
131            attrs.push(Attribute::new(
132                ATTR_CHAP_PASSWORD,
133                chap_password(identifier, password, request_authenticator),
134            ));
135        }
136        Credential::None => {}
137    }
138    let packet = header(
139        CODE_ACCESS_REQUEST,
140        identifier,
141        request_authenticator,
142        &encode_all(&attrs)?,
143    )?;
144    sign_with_message_authenticator(packet, secret)
145}
146
147/// A Status-Server (RFC 5997 §3), which must carry a Message-Authenticator.
148pub fn status_server(
149    identifier: u8,
150    request_authenticator: &[u8; 16],
151    secret: &[u8],
152) -> Result<Vec<u8>, BuildError> {
153    let packet = header(CODE_STATUS_SERVER, identifier, request_authenticator, &[])?;
154    sign_with_message_authenticator(packet, secret)
155}
156
157/// An Accounting-Request (RFC 2866 §4.1). Its authenticator is a keyed digest of the whole
158/// packet, so it is computed here, and returned so replies can be checked against it.
159pub fn accounting_request(
160    identifier: u8,
161    attributes: &[Attribute],
162    secret: &[u8],
163) -> Result<(Vec<u8>, [u8; 16]), BuildError> {
164    let attrs = encode_all(attributes)?;
165    let length = u16::try_from(HEADER_LEN + attrs.len())
166        .ok()
167        .filter(|l| usize::from(*l) <= MAX_PACKET_LEN)
168        .ok_or_else(|| BuildError("accounting attributes exceed 4096 bytes".to_string()))?;
169    let auth = accounting_request_authenticator(identifier, length, &attrs, secret);
170    Ok((
171        header(CODE_ACCOUNTING_REQUEST, identifier, &auth, &attrs)?,
172        auth,
173    ))
174}
175
176/// Why a reply was refused.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum ReplyError {
179    /// Not a RADIUS packet at all.
180    Malformed(String),
181    /// A code that does not answer the request (an Accounting-Response to an Access-Request).
182    UnexpectedCode(u8),
183    /// The Response Authenticator does not verify: the sender does not hold the secret, or the
184    /// packet was altered.
185    BadAuthenticator,
186    /// A Message-Authenticator is present and does not verify.
187    BadMessageAuthenticator,
188    /// An Access-Accept/Reject/Challenge, or the answer to a Status-Server, without a
189    /// Message-Authenticator. Refused rather than trusted on the MD5 authenticator alone
190    /// (BlastRADIUS).
191    MissingMessageAuthenticator,
192}
193
194impl ReplyError {
195    pub fn kind(&self) -> &'static str {
196        match self {
197            ReplyError::Malformed(_) => "malformed",
198            ReplyError::UnexpectedCode(_) => "unexpected_code",
199            ReplyError::BadAuthenticator => "bad_authenticator",
200            ReplyError::BadMessageAuthenticator => "bad_message_authenticator",
201            ReplyError::MissingMessageAuthenticator => "missing_message_authenticator",
202        }
203    }
204}
205
206impl std::fmt::Display for ReplyError {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match self {
209            ReplyError::Malformed(e) => write!(f, "not a RADIUS packet: {e}"),
210            ReplyError::UnexpectedCode(c) => write!(
211                f,
212                "reply code {c} ({}) does not answer the request",
213                crate::server::radius::packet::code_name(*c)
214            ),
215            ReplyError::BadAuthenticator => write!(
216                f,
217                "the Response Authenticator does not verify: the sender does not hold the \
218                 shared secret, or the packet was altered"
219            ),
220            ReplyError::BadMessageAuthenticator => {
221                write!(f, "the Message-Authenticator does not verify")
222            }
223            ReplyError::MissingMessageAuthenticator => write!(
224                f,
225                "the reply carries no Message-Authenticator, which this client requires on \
226                 Access-Accept/Reject/Challenge and Status-Server answers"
227            ),
228        }
229    }
230}
231
232/// The reply codes that answer a request code.
233fn answers(request_code: u8, reply_code: u8) -> bool {
234    match request_code {
235        CODE_ACCESS_REQUEST => matches!(
236            reply_code,
237            CODE_ACCESS_ACCEPT | CODE_ACCESS_REJECT | CODE_ACCESS_CHALLENGE
238        ),
239        CODE_ACCOUNTING_REQUEST => reply_code == CODE_ACCOUNTING_RESPONSE,
240        CODE_STATUS_SERVER => matches!(reply_code, CODE_ACCESS_ACCEPT | CODE_ACCOUNTING_RESPONSE),
241        _ => false,
242    }
243}
244
245/// Decode a reply and check it against the request it answers, in the order that matters:
246/// the code, then the Response Authenticator (RFC 2865 §3), then the Message-Authenticator
247/// (RFC 3579 §3.2, computed over the reply with the *Request* Authenticator in the
248/// authenticator field). Nothing in an unverified reply reaches the model.
249pub fn verify_reply(
250    datagram: &[u8],
251    request_code: u8,
252    request_authenticator: &[u8; 16],
253    secret: &[u8],
254) -> Result<RadiusPacket, ReplyError> {
255    let packet =
256        RadiusPacket::decode(datagram).map_err(|e| ReplyError::Malformed(e.to_string()))?;
257    if !answers(request_code, packet.code) {
258        return Err(ReplyError::UnexpectedCode(packet.code));
259    }
260    // Bytes past the Length field are padding (RFC 2865 §3) and covered by neither digest.
261    let declared = usize::from(u16::from_be_bytes([datagram[2], datagram[3]]));
262    let attrs = &datagram[HEADER_LEN..declared];
263    let expected = response_authenticator(
264        packet.code,
265        packet.identifier,
266        attrs,
267        request_authenticator,
268        secret,
269    );
270    if !constant_time_eq(&expected, &packet.authenticator) {
271        return Err(ReplyError::BadAuthenticator);
272    }
273
274    // Walk the attributes for the Message-Authenticator's offset.
275    let mut offset = HEADER_LEN;
276    let mut ma_at = None;
277    while offset + 2 <= declared {
278        let len = usize::from(datagram[offset + 1]);
279        if datagram[offset] == ATTR_MESSAGE_AUTHENTICATOR && len == 18 {
280            ma_at = Some(offset);
281        }
282        if len < 2 {
283            break;
284        }
285        offset += len;
286    }
287    let requires_ma = request_code == CODE_STATUS_SERVER || request_code == CODE_ACCESS_REQUEST;
288    match ma_at {
289        None if requires_ma => Err(ReplyError::MissingMessageAuthenticator),
290        None => Ok(packet),
291        Some(at) => {
292            let mut copy = datagram[..declared].to_vec();
293            copy[4..20].copy_from_slice(request_authenticator);
294            copy[at + 2..at + 18].fill(0);
295            let mac = hmac_md5(secret, &copy);
296            let mut got = [0u8; 16];
297            got.copy_from_slice(&datagram[at + 2..at + 18]);
298            if constant_time_eq(&mac, &got) {
299                Ok(packet)
300            } else {
301                Err(ReplyError::BadMessageAuthenticator)
302            }
303        }
304    }
305}
306
307fn constant_time_eq(a: &[u8; 16], b: &[u8; 16]) -> bool {
308    a.iter().zip(b).fold(0u8, |d, (x, y)| d | (x ^ y)) == 0
309}