A TypeScript library for the RADIUS protocol (RFC 2865/RFC 2866) with support for packet parsing, construction, and serialization. Includes a UDP server and client, FreeRADIUS dictionary-based attribute resolution, vendor-specific attributes (VSAs), extended attributes, TLV nesting, struct decoding, and three password encryption modes.
- Node.js >= 22
yarn add @gibme/radius
# or
npm install @gibme/radiusimport { Packet } from '@gibme/radius';
// Decode an incoming UDP payload
const packet = Packet.from({
bytes: udpPayload,
secret: 'shared-secret'
});
console.log(packet.type); // Packet.Type.ACCESS_REQUEST
console.log(packet.authenticated); // true if Message-Authenticator verified
console.log(packet.attributes); // { 'User-Name': 'alice', 'NAS-IP-Address': '10.0.0.1', ... }Packet.from() decodes a RADIUS packet from raw bytes. Pass the shared secret to verify the Message-Authenticator (HMAC-MD5 with timing-safe comparison) and decrypt encrypted attributes.
const packet = Packet.from({
bytes: udpPayload, // Buffer, Uint8Array, or hex string
secret: 'shared-secret'
});For response packets, pass the original request's authenticator so the response authenticator and Message-Authenticator HMAC can be verified:
const response = Packet.from({
bytes: responseBytes,
secret: 'shared-secret',
requestor: request.authenticator
});Decoding without a secret is supported for inspecting unencrypted attributes, though authenticated will be false:
const packet = Packet.from({ bytes: udpPayload });
console.log(packet.authenticated); // false
console.log(packet.attributes['NAS-IP-Address']); // still accessibleauthenticated is a convenience boolean (true only when the HMAC verifies). To tell why a packet did not verify, read packet.messageAuthenticator, which reports one of four states:
messageAuthenticator |
Meaning |
|---|---|
'verified' |
Attribute 80 present and the HMAC-MD5 matched (authenticated === true). |
'mismatch' |
Attribute 80 present but the HMAC-MD5 did not match — wrong shared secret or tampering. |
'absent' |
No Message-Authenticator attribute in the packet (common for legacy CHAP/PAP requests). |
'unchecked' |
No shared secret was supplied, so it could not be verified. |
const packet = Packet.from({ bytes: udpPayload, secret: 'shared-secret' });
if (packet.messageAuthenticator === 'absent') {
// the client never sent attribute 80 — not a wrong-secret problem
}This library enforces Message-Authenticator (RFC 2869 Section 5.14, HMAC-MD5) for all authenticated packets. Responses without a Message-Authenticator attribute decode with authenticated = false and are dropped by Server and Client. The library does not validate the RFC 2865 Section 3 Response Authenticator separately because Message-Authenticator's HMAC-MD5 is cryptographically stronger than plain MD5 and mitigates the Response-Authenticator collision attack class (BlastRADIUS / CVE-2024-3596). Consumers integrating with RADIUS servers that do not emit Message-Authenticator will need to enable it server-side or add their own Response Authenticator check.
Both Server and Client accept two opt-in flags for interoperating with equipment that does not send a Message-Authenticator (e.g. legacy NAS gear doing CHAP/PAP). Both default to false, preserving the strict enforcement above.
| Option | Effect when true |
|---|---|
allowMissingMessageAuthenticator |
A packet with no attribute 80 is emitted on packet instead of discard. |
allowMismatchedMessageAuthenticator |
A packet whose attribute 80 fails HMAC verification is emitted on packet instead of discard. |
When either flag lets a non-verified packet through, it arrives on the normal packet event; inspect packet.messageAuthenticator to decide how to handle it. Packets that are still rejected fire discard with a specific error (Message-Authenticator missing or Message-Authenticator mismatch).
Security note: enabling these flags weakens the CVE-2024-3596 (BlastRADIUS) mitigation. Only enable them when you must interoperate with peers that cannot send a Message-Authenticator, and treat the resulting packets accordingly.
const server = await Server.create({
secret: 'shared-secret',
allowMissingMessageAuthenticator: true // accept legacy CHAP/PAP requests lacking attr 80
});
server.on('packet', (packet, remoteInfo) => {
if (packet.messageAuthenticator !== 'verified') {
// arrived only because an allow* flag is set — apply extra scrutiny
}
});Packet.construct() builds a new RADIUS packet. When a secret is provided, a Message-Authenticator attribute is automatically added and computed during serialization.
const packet = Packet.construct({
type: Packet.Type.ACCESS_REQUEST,
secret: 'shared-secret',
attributes: [
['User-Name', 'alice'],
['User-Password', 'hunter2'],
['NAS-IP-Address', '192.168.1.1'],
['Service-Type', 'Framed-User']
]
});
const wireBytes = packet.toBuffer();Tagged attributes (e.g., tunnel attributes per RFC 2868) use a 3-element tuple [name, value, tag]:
const reply = Packet.construct({
type: Packet.Type.ACCESS_ACCEPT,
secret: 'shared-secret',
requestor: request.authenticator,
attributes: [
['Tunnel-Type', 'VLAN', 0],
['Tunnel-Medium-Type', 'IEEE-802', 0],
['Tunnel-Private-Group-Id', '100', 0]
]
});Attribute names are case-insensitive and resolved against the loaded FreeRADIUS dictionary. Enum values can be passed by name (e.g., 'Framed-User') or numeric code.
The reply() convenience method on a decoded packet automatically sets the identifier, secret, and request authenticator:
const response = request.reply({
type: Packet.Type.ACCESS_ACCEPT,
attributes: [
['Reply-Message', 'Welcome!'],
['Session-Timeout', 3600]
]
});
const responseBytes = response.toBuffer();Two accessors are available for decoded attribute values:
packet.attributesreturns aRecord<string, ParsedAttribute>where each key is the attribute name. When duplicate attributes exist, the last value wins.packet.attributeListreturns an orderedArray<{ name, value }>preserving duplicates and wire order, useful for multi-valued attributes like multiple Tunnel-Type entries.
// Last-one-wins record
const userName = packet.attributes['User-Name'];
// Ordered list preserving duplicates
const allTunnelTypes = packet.attributeList
.filter(a => a.name === 'Tunnel-Type');The Server class listens for incoming RADIUS requests over UDP. Incoming packets are verified for authenticity before being emitted. Malformed or unauthenticated packets are discarded with a 'discard' event for observability, unless the allowMissingMessageAuthenticator / allowMismatchedMessageAuthenticator options are set (see Relaxing enforcement for legacy peers).
import { Server, Packet } from '@gibme/radius';
const server = await Server.create({
secret: 'shared-secret',
port: 1812 // default
});
server.on('packet', (packet, remoteInfo) => {
console.log(`${remoteInfo.address}: ${packet.attributes['User-Name']}`);
const reply = packet.reply({
type: Packet.Type.ACCESS_ACCEPT,
attributes: [
['Reply-Message', 'Authenticated']
]
});
server.send(reply, { address: remoteInfo.address, port: remoteInfo.port });
});
server.on('discard', (error, message, remoteInfo) => {
console.error(`Discarded packet from ${remoteInfo.address}: ${error.message}`);
});The Client class sends RADIUS requests and correlates responses by destination, port, and identifier. Unanswered requests emit a 'timeout' event (default 10 seconds).
import { Client, Packet } from '@gibme/radius';
const client = await Client.create({
secret: 'shared-secret',
timeout: 5000 // ms before timeout event
});
client.on('packet', (packet, remoteInfo) => {
console.log(`Response: ${packet.type}`);
console.log(packet.attributes);
});
client.on('timeout', (info) => {
console.log(`No response from ${info.address}:${info.port}`);
});
await client.send(
{
type: Packet.Type.ACCESS_REQUEST,
attributes: [
['User-Name', 'alice'],
['User-Password', 'secret123'],
['NAS-IP-Address', '192.168.1.1']
]
},
{ address: '10.0.0.1', port: 1812 }
);
// Clean up when done
await client.destroy();Additional FreeRADIUS-compatible dictionary files can be loaded at runtime. The bundled dictionaries are loaded automatically at module initialization.
import { load_dictionary } from '@gibme/radius';
load_dictionary('/path/to/custom/dictionary');| Type | Description |
|---|---|
string |
UTF-8 text |
octets |
Raw byte sequence |
ipaddr |
IPv4 address |
ipv6addr |
IPv6 address |
ipv4prefix |
IPv4 prefix with mask length |
ipv6prefix |
IPv6 prefix with mask length |
integer / uint32 |
32-bit unsigned integer |
uint64 |
64-bit unsigned integer (decoded as bigint) |
short |
16-bit unsigned integer |
byte |
8-bit unsigned integer |
signed |
32-bit signed integer |
date |
Unix timestamp (decoded as Date) |
ifid |
8-byte interface identifier |
combo-ip |
IPv4 (4 bytes) or IPv6 (16 bytes) address |
ether |
6-byte MAC address |
struct |
Composite type with named fields |
tlv |
Nested type-length-value container |
Three modes are supported, matching the FreeRADIUS encrypt= flag:
| Mode | Attribute Example | Reference |
|---|---|---|
| Type 1 (User-Password) | User-Password |
RFC 2865 |
| Type 2 (Tunnel-Password) | Tunnel-Password, Microsoft-MPPE-Send-Key |
RFC 2868 |
| Type 3 (Ascend-Secret) | Ascend-Send-Secret |
Ascend proprietary |
| Code | Type | Direction |
|---|---|---|
| 1 | Access-Request | Client -> Server |
| 2 | Access-Accept | Server -> Client |
| 3 | Access-Reject | Server -> Client |
| 4 | Accounting-Request | Client -> Server |
| 5 | Accounting-Response | Server -> Client |
| 11 | Access-Challenge | Server -> Client |
| 12 | Status-Server | Client -> Server |
| 13 | Status-Client | Server -> Client |
| 52 | Protocol-Error | Server -> Client |
The bundled dictionary/ directory contains 100+ FreeRADIUS-compatible dictionary files covering standard RFC attributes and vendor-specific attributes from manufacturers including Cisco, Juniper, Ruckus, Fortinet, Microsoft, Nokia, and many more. Dictionaries are loaded automatically at module initialization.
The bundled dictionaries are loaded recursively by directory scan at module initialization. Lines beginning with $ (including $INCLUDE) are not interpreted by the parser; to load additional dictionary files at runtime, pass a directory path to load_dictionary and every file matching dictionary* under that path is loaded. Nested TLV hierarchies, extended attributes (types 241-246), struct members, and per-vendor wire format specifications (non-standard type/length field sizes, continuation bytes) are fully supported.
MIT