303 lines
8.2 KiB
TypeScript
303 lines
8.2 KiB
TypeScript
|
|
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;
|