feat(smartradius): Implement full RADIUS server and client with RFC 2865/2866 compliance, including packet handling, authenticators, attributes, secrets manager, client APIs, and comprehensive tests and documentation

This commit is contained in:
2026-02-01 17:40:36 +00:00
parent 5a6a3cf66e
commit be9f49fff9
45 changed files with 11694 additions and 70 deletions

View File

@@ -0,0 +1,302 @@
import * as plugins from './plugins.js';
import { ERadiusCode } from './interfaces.js';
/**
* RADIUS Authenticator handling
* Implements RFC 2865 and RFC 2866 authenticator calculations
*/
export class RadiusAuthenticator {
private static readonly AUTHENTICATOR_LENGTH = 16;
/**
* Generate random Request Authenticator for Access-Request
* RFC 2865: The Request Authenticator value is a 16 octet random number
*/
public static generateRequestAuthenticator(): Buffer {
return plugins.crypto.randomBytes(this.AUTHENTICATOR_LENGTH);
}
/**
* Calculate Response Authenticator for Access-Accept, Access-Reject, Access-Challenge
* RFC 2865: ResponseAuth = MD5(Code+ID+Length+RequestAuth+Attributes+Secret)
*/
public static calculateResponseAuthenticator(
code: ERadiusCode,
identifier: number,
requestAuthenticator: Buffer,
attributes: Buffer,
secret: string
): Buffer {
const length = 20 + attributes.length; // 20 = header size
const md5 = plugins.crypto.createHash('md5');
const header = Buffer.allocUnsafe(4);
header.writeUInt8(code, 0);
header.writeUInt8(identifier, 1);
header.writeUInt16BE(length, 2);
md5.update(header);
md5.update(requestAuthenticator);
md5.update(attributes);
md5.update(Buffer.from(secret, 'utf8'));
return md5.digest();
}
/**
* Calculate Accounting Request Authenticator
* RFC 2866: MD5(Code+ID+Length+16×0x00+Attrs+Secret)
*/
public static calculateAccountingRequestAuthenticator(
code: ERadiusCode,
identifier: number,
attributes: Buffer,
secret: string
): Buffer {
const length = 20 + attributes.length;
const zeroAuth = Buffer.alloc(this.AUTHENTICATOR_LENGTH, 0);
const md5 = plugins.crypto.createHash('md5');
const header = Buffer.allocUnsafe(4);
header.writeUInt8(code, 0);
header.writeUInt8(identifier, 1);
header.writeUInt16BE(length, 2);
md5.update(header);
md5.update(zeroAuth);
md5.update(attributes);
md5.update(Buffer.from(secret, 'utf8'));
return md5.digest();
}
/**
* Verify Accounting Request Authenticator
*/
public static verifyAccountingRequestAuthenticator(
packet: Buffer,
secret: string
): boolean {
if (packet.length < 20) {
return false;
}
const code = packet.readUInt8(0);
const identifier = packet.readUInt8(1);
const length = packet.readUInt16BE(2);
const receivedAuth = packet.subarray(4, 20);
const attributes = packet.subarray(20, length);
const expectedAuth = this.calculateAccountingRequestAuthenticator(
code,
identifier,
attributes,
secret
);
return plugins.crypto.timingSafeEqual(receivedAuth, expectedAuth);
}
/**
* Verify Response Authenticator
*/
public static verifyResponseAuthenticator(
responsePacket: Buffer,
requestAuthenticator: Buffer,
secret: string
): boolean {
if (responsePacket.length < 20) {
return false;
}
const code = responsePacket.readUInt8(0);
const identifier = responsePacket.readUInt8(1);
const length = responsePacket.readUInt16BE(2);
const receivedAuth = responsePacket.subarray(4, 20);
const attributes = responsePacket.subarray(20, length);
const expectedAuth = this.calculateResponseAuthenticator(
code,
identifier,
requestAuthenticator,
attributes,
secret
);
return plugins.crypto.timingSafeEqual(receivedAuth, expectedAuth);
}
/**
* Encrypt User-Password (PAP)
* RFC 2865 Section 5.2:
* b1 = MD5(S + RA) c(1) = p1 xor b1
* b2 = MD5(S + c(1)) c(2) = p2 xor b2
* ...
* bi = MD5(S + c(i-1)) c(i) = pi xor bi
*/
public static encryptPassword(
password: string,
requestAuthenticator: Buffer,
secret: string
): Buffer {
const passwordBuffer = Buffer.from(password, 'utf8');
// Pad password to multiple of 16 bytes
const paddedLength = Math.max(16, Math.ceil(passwordBuffer.length / 16) * 16);
const padded = Buffer.alloc(paddedLength, 0);
passwordBuffer.copy(padded);
// Limit to 128 bytes max
const maxLength = Math.min(paddedLength, 128);
const result = Buffer.allocUnsafe(maxLength);
let previousCipher = requestAuthenticator;
for (let i = 0; i < maxLength; i += 16) {
const md5 = plugins.crypto.createHash('md5');
md5.update(Buffer.from(secret, 'utf8'));
md5.update(previousCipher);
const b = md5.digest();
for (let j = 0; j < 16 && i + j < maxLength; j++) {
result[i + j] = padded[i + j] ^ b[j];
}
previousCipher = result.subarray(i, i + 16);
}
return result;
}
/**
* Decrypt User-Password (PAP)
* Reverse of encryptPassword
*/
public static decryptPassword(
encryptedPassword: Buffer,
requestAuthenticator: Buffer,
secret: string
): string {
if (encryptedPassword.length === 0 || encryptedPassword.length % 16 !== 0) {
throw new Error('Invalid encrypted password length');
}
const result = Buffer.allocUnsafe(encryptedPassword.length);
let previousCipher = requestAuthenticator;
for (let i = 0; i < encryptedPassword.length; i += 16) {
const md5 = plugins.crypto.createHash('md5');
md5.update(Buffer.from(secret, 'utf8'));
md5.update(previousCipher);
const b = md5.digest();
for (let j = 0; j < 16; j++) {
result[i + j] = encryptedPassword[i + j] ^ b[j];
}
previousCipher = encryptedPassword.subarray(i, i + 16);
}
// Remove null padding
let end = result.length;
while (end > 0 && result[end - 1] === 0) {
end--;
}
return result.subarray(0, end).toString('utf8');
}
/**
* Calculate CHAP Response
* RFC 2865: CHAP-Response = MD5(CHAP-ID + Password + Challenge)
*/
public static calculateChapResponse(
chapId: number,
password: string,
challenge: Buffer
): Buffer {
const md5 = plugins.crypto.createHash('md5');
md5.update(Buffer.from([chapId]));
md5.update(Buffer.from(password, 'utf8'));
md5.update(challenge);
return md5.digest();
}
/**
* Verify CHAP Response
* CHAP-Password format: CHAP Ident (1 byte) + String (16 bytes MD5 hash)
*/
public static verifyChapResponse(
chapPassword: Buffer,
challenge: Buffer,
password: string
): boolean {
if (chapPassword.length !== 17) {
return false;
}
const chapId = chapPassword[0];
const receivedResponse = chapPassword.subarray(1);
const expectedResponse = this.calculateChapResponse(chapId, password, challenge);
return plugins.crypto.timingSafeEqual(receivedResponse, expectedResponse);
}
/**
* Calculate Message-Authenticator (HMAC-MD5)
* Used for EAP and other extended authentication methods
* RFC 3579: HMAC-MD5 over entire packet with Message-Authenticator set to 16 zero bytes
*/
public static calculateMessageAuthenticator(
packet: Buffer,
secret: string
): Buffer {
// The packet should have Message-Authenticator attribute with 16 zero bytes
const hmac = plugins.crypto.createHmac('md5', Buffer.from(secret, 'utf8'));
hmac.update(packet);
return hmac.digest();
}
/**
* Verify Message-Authenticator
*/
public static verifyMessageAuthenticator(
packet: Buffer,
messageAuthenticator: Buffer,
messageAuthenticatorOffset: number,
secret: string
): boolean {
if (messageAuthenticator.length !== 16) {
return false;
}
// Create a copy of the packet with Message-Authenticator set to 16 zero bytes
const packetCopy = Buffer.from(packet);
Buffer.alloc(16, 0).copy(packetCopy, messageAuthenticatorOffset);
const expectedAuth = this.calculateMessageAuthenticator(packetCopy, secret);
return plugins.crypto.timingSafeEqual(messageAuthenticator, expectedAuth);
}
/**
* Create packet header
*/
public static createPacketHeader(
code: ERadiusCode,
identifier: number,
authenticator: Buffer,
attributesLength: number
): Buffer {
const header = Buffer.allocUnsafe(20);
const length = 20 + attributesLength;
header.writeUInt8(code, 0);
header.writeUInt8(identifier, 1);
header.writeUInt16BE(length, 2);
authenticator.copy(header, 4);
return header;
}
}
export default RadiusAuthenticator;