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;