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:
426
ts_server/classes.radiuspacket.ts
Normal file
426
ts_server/classes.radiuspacket.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { IRadiusPacket, IParsedRadiusPacket, IRadiusAttribute, IParsedAttribute } from './interfaces.js';
|
||||
import { ERadiusCode, ERadiusAttributeType } from './interfaces.js';
|
||||
import { RadiusAttributes } from './classes.radiusattributes.js';
|
||||
import { RadiusAuthenticator } from './classes.radiusauthenticator.js';
|
||||
|
||||
/**
|
||||
* RADIUS Packet encoder/decoder
|
||||
* Implements RFC 2865 Section 3 packet format
|
||||
*/
|
||||
export class RadiusPacket {
|
||||
/**
|
||||
* Minimum packet size (RFC 2865: 20 bytes)
|
||||
*/
|
||||
public static readonly MIN_PACKET_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Maximum packet size (RFC 2865: 4096 bytes)
|
||||
*/
|
||||
public static readonly MAX_PACKET_SIZE = 4096;
|
||||
|
||||
/**
|
||||
* Header size (Code + Identifier + Length + Authenticator)
|
||||
*/
|
||||
public static readonly HEADER_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Authenticator size
|
||||
*/
|
||||
public static readonly AUTHENTICATOR_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Decode a RADIUS packet from a buffer
|
||||
*/
|
||||
public static decode(buffer: Buffer): IRadiusPacket {
|
||||
if (buffer.length < this.MIN_PACKET_SIZE) {
|
||||
throw new Error(`Packet too short: ${buffer.length} bytes (minimum ${this.MIN_PACKET_SIZE})`);
|
||||
}
|
||||
|
||||
const code = buffer.readUInt8(0);
|
||||
const identifier = buffer.readUInt8(1);
|
||||
const length = buffer.readUInt16BE(2);
|
||||
|
||||
// Validate code
|
||||
if (!this.isValidCode(code)) {
|
||||
throw new Error(`Invalid packet code: ${code}`);
|
||||
}
|
||||
|
||||
// Validate length
|
||||
if (length < this.MIN_PACKET_SIZE) {
|
||||
throw new Error(`Invalid packet length in header: ${length}`);
|
||||
}
|
||||
if (length > this.MAX_PACKET_SIZE) {
|
||||
throw new Error(`Packet too large: ${length} bytes (maximum ${this.MAX_PACKET_SIZE})`);
|
||||
}
|
||||
if (buffer.length < length) {
|
||||
throw new Error(`Buffer too short for declared length: ${buffer.length} < ${length}`);
|
||||
}
|
||||
|
||||
const authenticator = Buffer.allocUnsafe(this.AUTHENTICATOR_SIZE);
|
||||
buffer.copy(authenticator, 0, 4, 20);
|
||||
|
||||
const attributes = this.decodeAttributes(buffer.subarray(20, length));
|
||||
|
||||
return {
|
||||
code,
|
||||
identifier,
|
||||
authenticator,
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode packet and parse attributes
|
||||
*/
|
||||
public static decodeAndParse(buffer: Buffer): IParsedRadiusPacket {
|
||||
const packet = this.decode(buffer);
|
||||
const parsedAttributes = packet.attributes.map((attr) => RadiusAttributes.parseAttribute(attr));
|
||||
|
||||
return {
|
||||
...packet,
|
||||
parsedAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode attributes from buffer
|
||||
*/
|
||||
private static decodeAttributes(buffer: Buffer): IRadiusAttribute[] {
|
||||
const attributes: IRadiusAttribute[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 2 > buffer.length) {
|
||||
throw new Error('Malformed attribute: truncated header');
|
||||
}
|
||||
|
||||
const type = buffer.readUInt8(offset);
|
||||
const length = buffer.readUInt8(offset + 1);
|
||||
|
||||
if (length < 2) {
|
||||
throw new Error(`Invalid attribute length: ${length}`);
|
||||
}
|
||||
if (offset + length > buffer.length) {
|
||||
throw new Error('Malformed attribute: truncated value');
|
||||
}
|
||||
|
||||
const value = Buffer.allocUnsafe(length - 2);
|
||||
buffer.copy(value, 0, offset + 2, offset + length);
|
||||
|
||||
attributes.push({ type, value });
|
||||
offset += length;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a RADIUS packet to a buffer
|
||||
*/
|
||||
public static encode(packet: IRadiusPacket): Buffer {
|
||||
const attributesBuffer = this.encodeAttributes(packet.attributes);
|
||||
const length = this.HEADER_SIZE + attributesBuffer.length;
|
||||
|
||||
if (length > this.MAX_PACKET_SIZE) {
|
||||
throw new Error(`Packet too large: ${length} bytes (maximum ${this.MAX_PACKET_SIZE})`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
buffer.writeUInt8(packet.code, 0);
|
||||
buffer.writeUInt8(packet.identifier, 1);
|
||||
buffer.writeUInt16BE(length, 2);
|
||||
packet.authenticator.copy(buffer, 4);
|
||||
attributesBuffer.copy(buffer, 20);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode attributes to buffer
|
||||
*/
|
||||
private static encodeAttributes(attributes: IRadiusAttribute[]): Buffer {
|
||||
const buffers = attributes.map((attr) => {
|
||||
const length = 2 + attr.value.length;
|
||||
if (length > 255) {
|
||||
throw new Error(`Attribute value too long: ${attr.value.length} bytes (max 253)`);
|
||||
}
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
buffer.writeUInt8(attr.type, 0);
|
||||
buffer.writeUInt8(length, 1);
|
||||
attr.value.copy(buffer, 2);
|
||||
return buffer;
|
||||
});
|
||||
|
||||
return Buffer.concat(buffers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Access-Request packet
|
||||
*/
|
||||
public static createAccessRequest(
|
||||
identifier: number,
|
||||
secret: string,
|
||||
attributes: Array<{ type: number | string; value: string | number | Buffer }>
|
||||
): Buffer {
|
||||
const requestAuthenticator = RadiusAuthenticator.generateRequestAuthenticator();
|
||||
const rawAttributes: IRadiusAttribute[] = [];
|
||||
|
||||
for (const attr of attributes) {
|
||||
const attrType = typeof attr.type === 'string'
|
||||
? RadiusAttributes.getTypeByName(attr.type)
|
||||
: attr.type;
|
||||
|
||||
if (attrType === undefined) {
|
||||
throw new Error(`Unknown attribute type: ${attr.type}`);
|
||||
}
|
||||
|
||||
let value: Buffer;
|
||||
if (attrType === ERadiusAttributeType.UserPassword && typeof attr.value === 'string') {
|
||||
// Encrypt password
|
||||
value = RadiusAuthenticator.encryptPassword(attr.value, requestAuthenticator, secret);
|
||||
} else {
|
||||
value = RadiusAttributes.encodeValue(attrType, attr.value);
|
||||
}
|
||||
|
||||
rawAttributes.push({ type: attrType, value });
|
||||
}
|
||||
|
||||
const packet: IRadiusPacket = {
|
||||
code: ERadiusCode.AccessRequest,
|
||||
identifier,
|
||||
authenticator: requestAuthenticator,
|
||||
attributes: rawAttributes,
|
||||
};
|
||||
|
||||
return this.encode(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Access-Accept packet
|
||||
*/
|
||||
public static createAccessAccept(
|
||||
identifier: number,
|
||||
requestAuthenticator: Buffer,
|
||||
secret: string,
|
||||
attributes: Array<{ type: number | string; value: string | number | Buffer }> = []
|
||||
): Buffer {
|
||||
return this.createResponse(
|
||||
ERadiusCode.AccessAccept,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Access-Reject packet
|
||||
*/
|
||||
public static createAccessReject(
|
||||
identifier: number,
|
||||
requestAuthenticator: Buffer,
|
||||
secret: string,
|
||||
attributes: Array<{ type: number | string; value: string | number | Buffer }> = []
|
||||
): Buffer {
|
||||
return this.createResponse(
|
||||
ERadiusCode.AccessReject,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Access-Challenge packet
|
||||
*/
|
||||
public static createAccessChallenge(
|
||||
identifier: number,
|
||||
requestAuthenticator: Buffer,
|
||||
secret: string,
|
||||
attributes: Array<{ type: number | string; value: string | number | Buffer }> = []
|
||||
): Buffer {
|
||||
return this.createResponse(
|
||||
ERadiusCode.AccessChallenge,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Accounting-Request packet
|
||||
*/
|
||||
public static createAccountingRequest(
|
||||
identifier: number,
|
||||
secret: string,
|
||||
attributes: Array<{ type: number | string; value: string | number | Buffer }>
|
||||
): Buffer {
|
||||
const rawAttributes: IRadiusAttribute[] = [];
|
||||
|
||||
for (const attr of attributes) {
|
||||
const attrType = typeof attr.type === 'string'
|
||||
? RadiusAttributes.getTypeByName(attr.type)
|
||||
: attr.type;
|
||||
|
||||
if (attrType === undefined) {
|
||||
throw new Error(`Unknown attribute type: ${attr.type}`);
|
||||
}
|
||||
|
||||
const value = RadiusAttributes.encodeValue(attrType, attr.value);
|
||||
rawAttributes.push({ type: attrType, value });
|
||||
}
|
||||
|
||||
// Calculate authenticator with zero placeholder
|
||||
const attributesBuffer = this.encodeAttributes(rawAttributes);
|
||||
const authenticator = RadiusAuthenticator.calculateAccountingRequestAuthenticator(
|
||||
ERadiusCode.AccountingRequest,
|
||||
identifier,
|
||||
attributesBuffer,
|
||||
secret
|
||||
);
|
||||
|
||||
const packet: IRadiusPacket = {
|
||||
code: ERadiusCode.AccountingRequest,
|
||||
identifier,
|
||||
authenticator,
|
||||
attributes: rawAttributes,
|
||||
};
|
||||
|
||||
return this.encode(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Accounting-Response packet
|
||||
*/
|
||||
public static createAccountingResponse(
|
||||
identifier: number,
|
||||
requestAuthenticator: Buffer,
|
||||
secret: string,
|
||||
attributes: Array<{ type: number | string; value: string | number | Buffer }> = []
|
||||
): Buffer {
|
||||
return this.createResponse(
|
||||
ERadiusCode.AccountingResponse,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a response packet (Accept, Reject, Challenge, Accounting-Response)
|
||||
*/
|
||||
private static createResponse(
|
||||
code: ERadiusCode,
|
||||
identifier: number,
|
||||
requestAuthenticator: Buffer,
|
||||
secret: string,
|
||||
attributes: Array<{ type: number | string; value: string | number | Buffer }>
|
||||
): Buffer {
|
||||
const rawAttributes: IRadiusAttribute[] = [];
|
||||
|
||||
for (const attr of attributes) {
|
||||
const attrType = typeof attr.type === 'string'
|
||||
? RadiusAttributes.getTypeByName(attr.type)
|
||||
: attr.type;
|
||||
|
||||
if (attrType === undefined) {
|
||||
throw new Error(`Unknown attribute type: ${attr.type}`);
|
||||
}
|
||||
|
||||
const value = RadiusAttributes.encodeValue(attrType, attr.value);
|
||||
rawAttributes.push({ type: attrType, value });
|
||||
}
|
||||
|
||||
const attributesBuffer = this.encodeAttributes(rawAttributes);
|
||||
|
||||
// Calculate response authenticator
|
||||
const responseAuthenticator = RadiusAuthenticator.calculateResponseAuthenticator(
|
||||
code,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
attributesBuffer,
|
||||
secret
|
||||
);
|
||||
|
||||
const packet: IRadiusPacket = {
|
||||
code,
|
||||
identifier,
|
||||
authenticator: responseAuthenticator,
|
||||
attributes: rawAttributes,
|
||||
};
|
||||
|
||||
return this.encode(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attribute value from a packet
|
||||
*/
|
||||
public static getAttribute(packet: IRadiusPacket, type: number | string): Buffer | undefined {
|
||||
const attrType = typeof type === 'string' ? RadiusAttributes.getTypeByName(type) : type;
|
||||
if (attrType === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const attr = packet.attributes.find((a) => a.type === attrType);
|
||||
return attr?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all attribute values from a packet
|
||||
*/
|
||||
public static getAttributes(packet: IRadiusPacket, type: number | string): Buffer[] {
|
||||
const attrType = typeof type === 'string' ? RadiusAttributes.getTypeByName(type) : type;
|
||||
if (attrType === undefined) {
|
||||
return [];
|
||||
}
|
||||
return packet.attributes.filter((a) => a.type === attrType).map((a) => a.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parsed attribute value
|
||||
*/
|
||||
public static getParsedAttribute(packet: IRadiusPacket, type: number | string): string | number | Buffer | undefined {
|
||||
const value = this.getAttribute(packet, type);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const attrType = typeof type === 'string' ? RadiusAttributes.getTypeByName(type)! : type;
|
||||
return RadiusAttributes.parseValue(attrType, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a packet code is valid
|
||||
*/
|
||||
private static isValidCode(code: number): boolean {
|
||||
return code === ERadiusCode.AccessRequest ||
|
||||
code === ERadiusCode.AccessAccept ||
|
||||
code === ERadiusCode.AccessReject ||
|
||||
code === ERadiusCode.AccountingRequest ||
|
||||
code === ERadiusCode.AccountingResponse ||
|
||||
code === ERadiusCode.AccessChallenge ||
|
||||
code === ERadiusCode.StatusServer ||
|
||||
code === ERadiusCode.StatusClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get code name
|
||||
*/
|
||||
public static getCodeName(code: ERadiusCode): string {
|
||||
const names: Record<number, string> = {
|
||||
[ERadiusCode.AccessRequest]: 'Access-Request',
|
||||
[ERadiusCode.AccessAccept]: 'Access-Accept',
|
||||
[ERadiusCode.AccessReject]: 'Access-Reject',
|
||||
[ERadiusCode.AccountingRequest]: 'Accounting-Request',
|
||||
[ERadiusCode.AccountingResponse]: 'Accounting-Response',
|
||||
[ERadiusCode.AccessChallenge]: 'Access-Challenge',
|
||||
[ERadiusCode.StatusServer]: 'Status-Server',
|
||||
[ERadiusCode.StatusClient]: 'Status-Client',
|
||||
};
|
||||
return names[code] || `Unknown-Code-${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default RadiusPacket;
|
||||
Reference in New Issue
Block a user