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:
302
ts_server/classes.radiusauthenticator.ts
Normal file
302
ts_server/classes.radiusauthenticator.ts
Normal 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;
|
||||
Reference in New Issue
Block a user