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,246 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import {
RadiusPacket,
RadiusAuthenticator,
ERadiusCode,
ERadiusAttributeType,
EAcctStatusType,
EAcctTerminateCause,
EAcctAuthentic,
} from '../../ts_server/index.js';
tap.test('should create Accounting-Request packet with Start status', async () => {
const identifier = 1;
const secret = 'testing123';
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Start },
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
]);
const decoded = RadiusPacket.decode(packet);
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
expect(decoded.identifier).toEqual(identifier);
});
tap.test('should create Accounting-Request packet with Stop status', async () => {
const identifier = 2;
const secret = 'testing123';
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
{ type: ERadiusAttributeType.AcctSessionTime, value: 3600 }, // 1 hour
{ type: ERadiusAttributeType.AcctInputOctets, value: 1024000 },
{ type: ERadiusAttributeType.AcctOutputOctets, value: 2048000 },
{ type: ERadiusAttributeType.AcctInputPackets, value: 1000 },
{ type: ERadiusAttributeType.AcctOutputPackets, value: 2000 },
{ type: ERadiusAttributeType.AcctTerminateCause, value: EAcctTerminateCause.UserRequest },
]);
const decoded = RadiusPacket.decodeAndParse(packet);
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
const statusType = decoded.parsedAttributes.find(
(a) => a.type === ERadiusAttributeType.AcctStatusType
);
expect(statusType?.value).toEqual(EAcctStatusType.Stop);
});
tap.test('should create Accounting-Request packet with Interim-Update status', async () => {
const identifier = 3;
const secret = 'testing123';
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.InterimUpdate },
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
{ type: ERadiusAttributeType.AcctSessionTime, value: 1800 }, // 30 min
{ type: ERadiusAttributeType.AcctInputOctets, value: 512000 },
{ type: ERadiusAttributeType.AcctOutputOctets, value: 1024000 },
]);
const decoded = RadiusPacket.decodeAndParse(packet);
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
const statusType = decoded.parsedAttributes.find(
(a) => a.type === ERadiusAttributeType.AcctStatusType
);
expect(statusType?.value).toEqual(EAcctStatusType.InterimUpdate);
});
tap.test('should verify Accounting-Request authenticator', async () => {
const identifier = 1;
const secret = 'testing123';
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Start },
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
]);
// Verify the authenticator
const isValid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, secret);
expect(isValid).toBeTruthy();
// Should fail with wrong secret
const isInvalid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, 'wrongsecret');
expect(isInvalid).toBeFalsy();
});
tap.test('should create Accounting-Response packet', async () => {
const identifier = 1;
const requestAuthenticator = Buffer.alloc(16, 0x42);
const secret = 'testing123';
const packet = RadiusPacket.createAccountingResponse(
identifier,
requestAuthenticator,
secret,
[] // Usually no attributes in response
);
const decoded = RadiusPacket.decode(packet);
expect(decoded.code).toEqual(ERadiusCode.AccountingResponse);
expect(decoded.identifier).toEqual(identifier);
});
tap.test('should verify Accounting-Response authenticator', async () => {
const identifier = 1;
const requestAuthenticator = Buffer.alloc(16, 0x42);
const secret = 'testing123';
const response = RadiusPacket.createAccountingResponse(
identifier,
requestAuthenticator,
secret,
[]
);
// Verify response authenticator
const isValid = RadiusAuthenticator.verifyResponseAuthenticator(
response,
requestAuthenticator,
secret
);
expect(isValid).toBeTruthy();
});
tap.test('should parse all accounting attributes correctly', async () => {
const identifier = 1;
const secret = 'testing123';
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
{ type: ERadiusAttributeType.AcctDelayTime, value: 5 },
{ type: ERadiusAttributeType.AcctInputOctets, value: 1000000 },
{ type: ERadiusAttributeType.AcctOutputOctets, value: 2000000 },
{ type: ERadiusAttributeType.AcctSessionId, value: 'sess-123' },
{ type: ERadiusAttributeType.AcctAuthentic, value: EAcctAuthentic.Radius },
{ type: ERadiusAttributeType.AcctSessionTime, value: 7200 },
{ type: ERadiusAttributeType.AcctInputPackets, value: 5000 },
{ type: ERadiusAttributeType.AcctOutputPackets, value: 10000 },
{ type: ERadiusAttributeType.AcctTerminateCause, value: EAcctTerminateCause.SessionTimeout },
{ type: ERadiusAttributeType.AcctMultiSessionId, value: 'multi-sess-456' },
{ type: ERadiusAttributeType.AcctLinkCount, value: 2 },
]);
const decoded = RadiusPacket.decodeAndParse(packet);
// Check each attribute
const attrs = decoded.parsedAttributes;
const statusType = attrs.find((a) => a.type === ERadiusAttributeType.AcctStatusType);
expect(statusType?.value).toEqual(EAcctStatusType.Stop);
const delayTime = attrs.find((a) => a.type === ERadiusAttributeType.AcctDelayTime);
expect(delayTime?.value).toEqual(5);
const inputOctets = attrs.find((a) => a.type === ERadiusAttributeType.AcctInputOctets);
expect(inputOctets?.value).toEqual(1000000);
const outputOctets = attrs.find((a) => a.type === ERadiusAttributeType.AcctOutputOctets);
expect(outputOctets?.value).toEqual(2000000);
const sessionId = attrs.find((a) => a.type === ERadiusAttributeType.AcctSessionId);
expect(sessionId?.value).toEqual('sess-123');
const authentic = attrs.find((a) => a.type === ERadiusAttributeType.AcctAuthentic);
expect(authentic?.value).toEqual(EAcctAuthentic.Radius);
const sessionTime = attrs.find((a) => a.type === ERadiusAttributeType.AcctSessionTime);
expect(sessionTime?.value).toEqual(7200);
const terminateCause = attrs.find((a) => a.type === ERadiusAttributeType.AcctTerminateCause);
expect(terminateCause?.value).toEqual(EAcctTerminateCause.SessionTimeout);
});
tap.test('should handle Accounting-On/Off status types', async () => {
const secret = 'testing123';
// Accounting-On (NAS restart/reboot notification)
const acctOnPacket = RadiusPacket.createAccountingRequest(1, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.AccountingOn },
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
]);
let decoded = RadiusPacket.decodeAndParse(acctOnPacket);
let statusType = decoded.parsedAttributes.find(
(a) => a.type === ERadiusAttributeType.AcctStatusType
);
expect(statusType?.value).toEqual(EAcctStatusType.AccountingOn);
// Accounting-Off
const acctOffPacket = RadiusPacket.createAccountingRequest(2, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.AccountingOff },
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
]);
decoded = RadiusPacket.decodeAndParse(acctOffPacket);
statusType = decoded.parsedAttributes.find(
(a) => a.type === ERadiusAttributeType.AcctStatusType
);
expect(statusType?.value).toEqual(EAcctStatusType.AccountingOff);
});
tap.test('should handle all termination causes', async () => {
const secret = 'testing123';
const terminationCauses = [
EAcctTerminateCause.UserRequest,
EAcctTerminateCause.LostCarrier,
EAcctTerminateCause.LostService,
EAcctTerminateCause.IdleTimeout,
EAcctTerminateCause.SessionTimeout,
EAcctTerminateCause.AdminReset,
EAcctTerminateCause.AdminReboot,
EAcctTerminateCause.PortError,
EAcctTerminateCause.NasError,
EAcctTerminateCause.NasRequest,
EAcctTerminateCause.NasReboot,
EAcctTerminateCause.PortUnneeded,
EAcctTerminateCause.PortPreempted,
EAcctTerminateCause.PortSuspended,
EAcctTerminateCause.ServiceUnavailable,
EAcctTerminateCause.Callback,
EAcctTerminateCause.UserError,
EAcctTerminateCause.HostRequest,
];
for (const cause of terminationCauses) {
const packet = RadiusPacket.createAccountingRequest(1, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
{ type: ERadiusAttributeType.AcctTerminateCause, value: cause },
]);
const decoded = RadiusPacket.decodeAndParse(packet);
const termCause = decoded.parsedAttributes.find(
(a) => a.type === ERadiusAttributeType.AcctTerminateCause
);
expect(termCause?.value).toEqual(cause);
}
});
export default tap.start();

View File

@@ -0,0 +1,211 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import {
RadiusAttributes,
ERadiusAttributeType,
} from '../../ts_server/index.js';
tap.test('should get attribute definitions by type', async () => {
const userNameDef = RadiusAttributes.getDefinition(ERadiusAttributeType.UserName);
expect(userNameDef).toBeDefined();
expect(userNameDef!.name).toEqual('User-Name');
expect(userNameDef!.valueType).toEqual('text');
const nasIpDef = RadiusAttributes.getDefinition(ERadiusAttributeType.NasIpAddress);
expect(nasIpDef).toBeDefined();
expect(nasIpDef!.name).toEqual('NAS-IP-Address');
expect(nasIpDef!.valueType).toEqual('address');
const nasPortDef = RadiusAttributes.getDefinition(ERadiusAttributeType.NasPort);
expect(nasPortDef).toBeDefined();
expect(nasPortDef!.name).toEqual('NAS-Port');
expect(nasPortDef!.valueType).toEqual('integer');
});
tap.test('should get attribute type by name', async () => {
expect(RadiusAttributes.getTypeByName('User-Name')).toEqual(ERadiusAttributeType.UserName);
expect(RadiusAttributes.getTypeByName('NAS-IP-Address')).toEqual(ERadiusAttributeType.NasIpAddress);
expect(RadiusAttributes.getTypeByName('NAS-Port')).toEqual(ERadiusAttributeType.NasPort);
expect(RadiusAttributes.getTypeByName('Unknown-Attribute')).toBeUndefined();
});
tap.test('should get attribute name by type', async () => {
expect(RadiusAttributes.getNameByType(ERadiusAttributeType.UserName)).toEqual('User-Name');
expect(RadiusAttributes.getNameByType(ERadiusAttributeType.UserPassword)).toEqual('User-Password');
expect(RadiusAttributes.getNameByType(255)).toInclude('Unknown-Attribute');
});
tap.test('should parse text attributes', async () => {
const textBuffer = Buffer.from('testuser', 'utf8');
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.UserName, textBuffer);
expect(parsed).toEqual('testuser');
});
tap.test('should parse address attributes', async () => {
const addressBuffer = Buffer.from([192, 168, 1, 100]);
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.NasIpAddress, addressBuffer);
expect(parsed).toEqual('192.168.1.100');
});
tap.test('should parse integer attributes', async () => {
const intBuffer = Buffer.allocUnsafe(4);
intBuffer.writeUInt32BE(12345, 0);
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.NasPort, intBuffer);
expect(parsed).toEqual(12345);
});
tap.test('should encode text attributes', async () => {
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.UserName, 'testuser');
expect(encoded.toString('utf8')).toEqual('testuser');
});
tap.test('should encode address attributes', async () => {
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.NasIpAddress, '10.20.30.40');
expect(encoded.length).toEqual(4);
expect(encoded[0]).toEqual(10);
expect(encoded[1]).toEqual(20);
expect(encoded[2]).toEqual(30);
expect(encoded[3]).toEqual(40);
});
tap.test('should encode integer attributes', async () => {
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.NasPort, 65535);
expect(encoded.length).toEqual(4);
expect(encoded.readUInt32BE(0)).toEqual(65535);
});
tap.test('should encode complete attribute with TLV format', async () => {
const encoded = RadiusAttributes.encodeAttribute(ERadiusAttributeType.UserName, 'test');
expect(encoded[0]).toEqual(ERadiusAttributeType.UserName); // Type
expect(encoded[1]).toEqual(6); // Length (2 + 4)
expect(encoded.subarray(2).toString('utf8')).toEqual('test');
});
tap.test('should handle attribute value too long', async () => {
const longValue = Buffer.alloc(254); // Max is 253 bytes
let error: Error | undefined;
try {
RadiusAttributes.encodeAttribute(ERadiusAttributeType.UserName, longValue);
} catch (e) {
error = e as Error;
}
expect(error).toBeDefined();
expect(error!.message).toInclude('too long');
});
tap.test('should parse raw attribute', async () => {
const rawAttr = {
type: ERadiusAttributeType.UserName,
value: Buffer.from('john.doe', 'utf8'),
};
const parsed = RadiusAttributes.parseAttribute(rawAttr);
expect(parsed.type).toEqual(ERadiusAttributeType.UserName);
expect(parsed.name).toEqual('User-Name');
expect(parsed.value).toEqual('john.doe');
});
tap.test('should handle Vendor-Specific Attributes', async () => {
// Vendor-Id: 9 (Cisco), Vendor-Type: 1, Value: 'test'
const vendorId = 9;
const vendorType = 1;
const vendorValue = Buffer.from('cisco-av-pair', 'utf8');
const vsaBuffer = RadiusAttributes.encodeVSA({ vendorId, vendorType, vendorValue });
// Parse it back
const parsed = RadiusAttributes.parseVSA(vsaBuffer);
expect(parsed).toBeDefined();
expect(parsed!.vendorId).toEqual(vendorId);
expect(parsed!.vendorType).toEqual(vendorType);
expect(parsed!.vendorValue.toString('utf8')).toEqual('cisco-av-pair');
});
tap.test('should create complete Vendor-Specific attribute', async () => {
const vendorId = 311; // Microsoft
const vendorType = 1;
const vendorValue = Buffer.from('test-value', 'utf8');
const attr = RadiusAttributes.createVendorAttribute(vendorId, vendorType, vendorValue);
// First byte should be type 26 (Vendor-Specific)
expect(attr[0]).toEqual(ERadiusAttributeType.VendorSpecific);
// Second byte is total length
expect(attr[1]).toBeGreaterThan(6);
});
tap.test('should check if attribute is encrypted', async () => {
expect(RadiusAttributes.isEncrypted(ERadiusAttributeType.UserPassword)).toBeTruthy();
expect(RadiusAttributes.isEncrypted(ERadiusAttributeType.UserName)).toBeFalsy();
});
tap.test('should handle all standard RFC 2865 attributes', async () => {
// Test that all standard attributes have definitions
const standardAttributes = [
ERadiusAttributeType.UserName,
ERadiusAttributeType.UserPassword,
ERadiusAttributeType.ChapPassword,
ERadiusAttributeType.NasIpAddress,
ERadiusAttributeType.NasPort,
ERadiusAttributeType.ServiceType,
ERadiusAttributeType.FramedProtocol,
ERadiusAttributeType.FramedIpAddress,
ERadiusAttributeType.FramedIpNetmask,
ERadiusAttributeType.FramedRouting,
ERadiusAttributeType.FilterId,
ERadiusAttributeType.FramedMtu,
ERadiusAttributeType.FramedCompression,
ERadiusAttributeType.LoginIpHost,
ERadiusAttributeType.LoginService,
ERadiusAttributeType.LoginTcpPort,
ERadiusAttributeType.ReplyMessage,
ERadiusAttributeType.CallbackNumber,
ERadiusAttributeType.CallbackId,
ERadiusAttributeType.FramedRoute,
ERadiusAttributeType.FramedIpxNetwork,
ERadiusAttributeType.State,
ERadiusAttributeType.Class,
ERadiusAttributeType.VendorSpecific,
ERadiusAttributeType.SessionTimeout,
ERadiusAttributeType.IdleTimeout,
ERadiusAttributeType.TerminationAction,
ERadiusAttributeType.CalledStationId,
ERadiusAttributeType.CallingStationId,
ERadiusAttributeType.NasIdentifier,
ERadiusAttributeType.ProxyState,
ERadiusAttributeType.ChapChallenge,
ERadiusAttributeType.NasPortType,
ERadiusAttributeType.PortLimit,
];
for (const attrType of standardAttributes) {
const def = RadiusAttributes.getDefinition(attrType);
expect(def).toBeDefined();
expect(def!.name).toBeDefined();
expect(def!.valueType).toBeDefined();
}
});
tap.test('should handle all RFC 2866 accounting attributes', async () => {
const accountingAttributes = [
ERadiusAttributeType.AcctStatusType,
ERadiusAttributeType.AcctDelayTime,
ERadiusAttributeType.AcctInputOctets,
ERadiusAttributeType.AcctOutputOctets,
ERadiusAttributeType.AcctSessionId,
ERadiusAttributeType.AcctAuthentic,
ERadiusAttributeType.AcctSessionTime,
ERadiusAttributeType.AcctInputPackets,
ERadiusAttributeType.AcctOutputPackets,
ERadiusAttributeType.AcctTerminateCause,
ERadiusAttributeType.AcctMultiSessionId,
ERadiusAttributeType.AcctLinkCount,
];
for (const attrType of accountingAttributes) {
const def = RadiusAttributes.getDefinition(attrType);
expect(def).toBeDefined();
expect(def!.name).toBeDefined();
}
});
export default tap.start();

View File

@@ -0,0 +1,205 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as crypto from 'crypto';
import {
RadiusAuthenticator,
RadiusPacket,
ERadiusCode,
ERadiusAttributeType,
} from '../../ts_server/index.js';
tap.test('should generate 16-byte random request authenticator', async () => {
const auth1 = RadiusAuthenticator.generateRequestAuthenticator();
const auth2 = RadiusAuthenticator.generateRequestAuthenticator();
expect(auth1.length).toEqual(16);
expect(auth2.length).toEqual(16);
// Should be random (different each time)
expect(auth1.equals(auth2)).toBeFalsy();
});
tap.test('should calculate response authenticator correctly', async () => {
const code = ERadiusCode.AccessAccept;
const identifier = 1;
const requestAuthenticator = crypto.randomBytes(16);
const attributes = Buffer.from([]);
const secret = 'testing123';
const responseAuth = RadiusAuthenticator.calculateResponseAuthenticator(
code,
identifier,
requestAuthenticator,
attributes,
secret
);
expect(responseAuth.length).toEqual(16);
// Verify by recalculating
const verified = RadiusAuthenticator.calculateResponseAuthenticator(
code,
identifier,
requestAuthenticator,
attributes,
secret
);
expect(responseAuth.equals(verified)).toBeTruthy();
});
tap.test('should calculate accounting request authenticator', async () => {
const code = ERadiusCode.AccountingRequest;
const identifier = 1;
const attributes = Buffer.from([]);
const secret = 'testing123';
const acctAuth = RadiusAuthenticator.calculateAccountingRequestAuthenticator(
code,
identifier,
attributes,
secret
);
expect(acctAuth.length).toEqual(16);
});
tap.test('should verify accounting request authenticator', async () => {
const secret = 'testing123';
// Create an accounting request packet
const packet = RadiusPacket.createAccountingRequest(1, secret, [
{ type: ERadiusAttributeType.AcctStatusType, value: 1 }, // Start
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-123' },
]);
// Verify the authenticator
const isValid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, secret);
expect(isValid).toBeTruthy();
// Should fail with wrong secret
const isInvalid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, 'wrongsecret');
expect(isInvalid).toBeFalsy();
});
tap.test('should verify response authenticator', async () => {
const identifier = 1;
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
// Create a response packet
const responsePacket = RadiusPacket.createAccessAccept(
identifier,
requestAuthenticator,
secret,
[{ type: ERadiusAttributeType.ReplyMessage, value: 'Welcome' }]
);
// Verify the authenticator
const isValid = RadiusAuthenticator.verifyResponseAuthenticator(
responsePacket,
requestAuthenticator,
secret
);
expect(isValid).toBeTruthy();
// Should fail with wrong request authenticator
const wrongRequestAuth = crypto.randomBytes(16);
const isInvalid = RadiusAuthenticator.verifyResponseAuthenticator(
responsePacket,
wrongRequestAuth,
secret
);
expect(isInvalid).toBeFalsy();
// Should fail with wrong secret
const isInvalid2 = RadiusAuthenticator.verifyResponseAuthenticator(
responsePacket,
requestAuthenticator,
'wrongsecret'
);
expect(isInvalid2).toBeFalsy();
});
tap.test('should create packet header', async () => {
const code = ERadiusCode.AccessRequest;
const identifier = 42;
const authenticator = crypto.randomBytes(16);
const attributesLength = 50;
const header = RadiusAuthenticator.createPacketHeader(
code,
identifier,
authenticator,
attributesLength
);
expect(header.length).toEqual(20);
expect(header[0]).toEqual(code);
expect(header[1]).toEqual(identifier);
expect(header.readUInt16BE(2)).toEqual(20 + attributesLength);
expect(header.subarray(4, 20).equals(authenticator)).toBeTruthy();
});
tap.test('should calculate CHAP response', async () => {
const chapId = 1;
const password = 'testpassword';
const challenge = crypto.randomBytes(16);
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
expect(response.length).toEqual(16);
// Verify the calculation: MD5(CHAP-ID + Password + Challenge)
const md5 = crypto.createHash('md5');
md5.update(Buffer.from([chapId]));
md5.update(Buffer.from(password, 'utf8'));
md5.update(challenge);
const expected = md5.digest();
expect(response.equals(expected)).toBeTruthy();
});
tap.test('should verify CHAP response', async () => {
const chapId = 1;
const password = 'testpassword';
const challenge = crypto.randomBytes(16);
// Calculate the response
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
// Create CHAP-Password attribute format: CHAP-ID (1 byte) + Response (16 bytes)
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
// Verify with correct password
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeTruthy();
// Verify with wrong password
const isInvalid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, 'wrongpassword');
expect(isInvalid).toBeFalsy();
});
tap.test('should calculate Message-Authenticator (HMAC-MD5)', async () => {
const packet = Buffer.alloc(50);
packet[0] = ERadiusCode.AccessRequest;
packet[1] = 1;
packet.writeUInt16BE(50, 2);
crypto.randomBytes(16).copy(packet, 4);
const secret = 'testing123';
const msgAuth = RadiusAuthenticator.calculateMessageAuthenticator(packet, secret);
expect(msgAuth.length).toEqual(16);
// Verify it's HMAC-MD5
const hmac = crypto.createHmac('md5', Buffer.from(secret, 'utf8'));
hmac.update(packet);
const expected = hmac.digest();
expect(msgAuth.equals(expected)).toBeTruthy();
});
export default tap.start();

209
test/server/test.chap.ts Normal file
View File

@@ -0,0 +1,209 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as crypto from 'crypto';
import { RadiusAuthenticator } from '../../ts_server/index.js';
tap.test('should calculate CHAP response per RFC', async () => {
// CHAP-Response = MD5(CHAP-ID + Password + Challenge)
const chapId = 42;
const password = 'mypassword';
const challenge = crypto.randomBytes(16);
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
// Verify manually
const md5 = crypto.createHash('md5');
md5.update(Buffer.from([chapId]));
md5.update(Buffer.from(password, 'utf8'));
md5.update(challenge);
const expected = md5.digest();
expect(response.length).toEqual(16);
expect(response.equals(expected)).toBeTruthy();
});
tap.test('should verify valid CHAP response', async () => {
const chapId = 1;
const password = 'correctpassword';
const challenge = crypto.randomBytes(16);
// Calculate the expected response
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
// Build CHAP-Password attribute: CHAP Ident (1 byte) + Response (16 bytes)
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
// Should verify with correct password
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeTruthy();
});
tap.test('should reject CHAP response with wrong password', async () => {
const chapId = 1;
const correctPassword = 'correctpassword';
const wrongPassword = 'wrongpassword';
const challenge = crypto.randomBytes(16);
// Calculate response with correct password
const response = RadiusAuthenticator.calculateChapResponse(chapId, correctPassword, challenge);
// Build CHAP-Password attribute
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
// Should NOT verify with wrong password
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, wrongPassword);
expect(isValid).toBeFalsy();
});
tap.test('should reject CHAP response with wrong challenge', async () => {
const chapId = 1;
const password = 'mypassword';
const correctChallenge = crypto.randomBytes(16);
const wrongChallenge = crypto.randomBytes(16);
// Calculate response with correct challenge
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, correctChallenge);
// Build CHAP-Password attribute
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
// Should NOT verify with wrong challenge
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, wrongChallenge, password);
expect(isValid).toBeFalsy();
});
tap.test('should reject CHAP response with wrong identifier', async () => {
const correctChapId = 1;
const wrongChapId = 2;
const password = 'mypassword';
const challenge = crypto.randomBytes(16);
// Calculate response with correct CHAP ID
const response = RadiusAuthenticator.calculateChapResponse(correctChapId, password, challenge);
// Build CHAP-Password with WRONG CHAP ID
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(wrongChapId, 0); // Wrong ID
response.copy(chapPassword, 1);
// Should NOT verify
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeFalsy();
});
tap.test('should reject invalid CHAP-Password length', async () => {
const challenge = crypto.randomBytes(16);
const password = 'mypassword';
// CHAP-Password must be exactly 17 bytes (1 + 16)
const invalidChapPassword = Buffer.alloc(16); // Too short
const isValid = RadiusAuthenticator.verifyChapResponse(invalidChapPassword, challenge, password);
expect(isValid).toBeFalsy();
const tooLongChapPassword = Buffer.alloc(18); // Too long
const isValid2 = RadiusAuthenticator.verifyChapResponse(tooLongChapPassword, challenge, password);
expect(isValid2).toBeFalsy();
});
tap.test('should handle all CHAP ID values (0-255)', async () => {
const password = 'testpassword';
const challenge = crypto.randomBytes(16);
for (const chapId of [0, 1, 127, 128, 254, 255]) {
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeTruthy();
}
});
tap.test('should handle special characters in password', async () => {
const chapId = 1;
const password = '!@#$%^&*()_+-=[]{}|;:,.<>?~`';
const challenge = crypto.randomBytes(16);
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeTruthy();
});
tap.test('should handle unicode password', async () => {
const chapId = 1;
const password = '密码パスワード'; // Chinese + Japanese
const challenge = crypto.randomBytes(16);
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeTruthy();
});
tap.test('should handle empty password', async () => {
const chapId = 1;
const password = '';
const challenge = crypto.randomBytes(16);
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeTruthy();
});
tap.test('should handle very long password', async () => {
const chapId = 1;
const password = 'a'.repeat(1000); // Very long password
const challenge = crypto.randomBytes(16);
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeTruthy();
});
tap.test('should handle different challenge lengths', async () => {
const chapId = 1;
const password = 'testpassword';
// CHAP challenge can be various lengths
for (const length of [8, 16, 24, 32]) {
const challenge = crypto.randomBytes(length);
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
response.copy(chapPassword, 1);
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
expect(isValid).toBeTruthy();
}
});
export default tap.start();

190
test/server/test.packet.ts Normal file
View File

@@ -0,0 +1,190 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import {
RadiusPacket,
ERadiusCode,
ERadiusAttributeType,
} from '../../ts_server/index.js';
tap.test('should encode and decode a basic Access-Request packet', async () => {
const identifier = 42;
const secret = 'testing123';
const attributes = [
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
{ type: ERadiusAttributeType.UserPassword, value: 'testpass' },
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
{ type: ERadiusAttributeType.NasPort, value: 1 },
];
const packet = RadiusPacket.createAccessRequest(identifier, secret, attributes);
// Verify minimum packet size (20 bytes header)
expect(packet.length).toBeGreaterThanOrEqual(RadiusPacket.MIN_PACKET_SIZE);
// Verify maximum packet size
expect(packet.length).toBeLessThanOrEqual(RadiusPacket.MAX_PACKET_SIZE);
// Verify header
expect(packet[0]).toEqual(ERadiusCode.AccessRequest);
expect(packet[1]).toEqual(identifier);
// Decode the packet
const decoded = RadiusPacket.decode(packet);
expect(decoded.code).toEqual(ERadiusCode.AccessRequest);
expect(decoded.identifier).toEqual(identifier);
expect(decoded.authenticator.length).toEqual(16);
expect(decoded.attributes.length).toBeGreaterThan(0);
});
tap.test('should handle packet length validation', async () => {
// Packet too short
const shortPacket = Buffer.alloc(19);
let error: Error | undefined;
try {
RadiusPacket.decode(shortPacket);
} catch (e) {
error = e as Error;
}
expect(error).toBeDefined();
expect(error!.message).toInclude('too short');
});
tap.test('should handle invalid length in header', async () => {
const packet = Buffer.alloc(20);
packet[0] = ERadiusCode.AccessRequest;
packet[1] = 1; // identifier
packet.writeUInt16BE(10, 2); // length too small
let error: Error | undefined;
try {
RadiusPacket.decode(packet);
} catch (e) {
error = e as Error;
}
expect(error).toBeDefined();
});
tap.test('should handle maximum packet size', async () => {
const secret = 'testing123';
const identifier = 1;
// Create a packet that would exceed max size
const hugeAttributes: Array<{ type: number; value: Buffer }> = [];
// Each attribute can be max 255 bytes. Create enough to exceed 4096
for (let i = 0; i < 20; i++) {
hugeAttributes.push({
type: ERadiusAttributeType.ReplyMessage,
value: Buffer.alloc(250, 65), // 250 bytes of 'A'
});
}
// This should throw because packet would be too large
let error: Error | undefined;
try {
// Manually build the packet to test size limit
const rawAttrs = hugeAttributes.map((a) => ({
type: a.type,
value: a.value,
}));
RadiusPacket.encode({
code: ERadiusCode.AccessRequest,
identifier,
authenticator: Buffer.alloc(16),
attributes: rawAttrs,
});
} catch (e) {
error = e as Error;
}
expect(error).toBeDefined();
expect(error!.message).toInclude('too large');
});
tap.test('should parse and encode attributes correctly', async () => {
const secret = 'testing123';
const identifier = 1;
// Create packet with various attribute types
const packet = RadiusPacket.createAccessRequest(identifier, secret, [
{ type: 'User-Name', value: 'john.doe' }, // text
{ type: 'NAS-IP-Address', value: '10.0.0.1' }, // address
{ type: 'NAS-Port', value: 5060 }, // integer
{ type: 'NAS-Identifier', value: 'nas01.example.com' }, // text
]);
const decoded = RadiusPacket.decodeAndParse(packet);
// Find username
const usernameAttr = decoded.parsedAttributes.find(
(a) => a.type === ERadiusAttributeType.UserName
);
expect(usernameAttr).toBeDefined();
expect(usernameAttr!.value).toEqual('john.doe');
// Find NAS-IP-Address
const nasIpAttr = decoded.parsedAttributes.find(
(a) => a.type === ERadiusAttributeType.NasIpAddress
);
expect(nasIpAttr).toBeDefined();
expect(nasIpAttr!.value).toEqual('10.0.0.1');
// Find NAS-Port
const nasPortAttr = decoded.parsedAttributes.find(
(a) => a.type === ERadiusAttributeType.NasPort
);
expect(nasPortAttr).toBeDefined();
expect(nasPortAttr!.value).toEqual(5060);
});
tap.test('should create Access-Accept packet', async () => {
const identifier = 1;
const requestAuth = Buffer.alloc(16);
const secret = 'testing123';
const packet = RadiusPacket.createAccessAccept(identifier, requestAuth, secret, [
{ type: ERadiusAttributeType.ReplyMessage, value: 'Welcome!' },
{ type: ERadiusAttributeType.SessionTimeout, value: 3600 },
]);
const decoded = RadiusPacket.decode(packet);
expect(decoded.code).toEqual(ERadiusCode.AccessAccept);
expect(decoded.identifier).toEqual(identifier);
});
tap.test('should create Access-Reject packet', async () => {
const identifier = 1;
const requestAuth = Buffer.alloc(16);
const secret = 'testing123';
const packet = RadiusPacket.createAccessReject(identifier, requestAuth, secret, [
{ type: ERadiusAttributeType.ReplyMessage, value: 'Invalid credentials' },
]);
const decoded = RadiusPacket.decode(packet);
expect(decoded.code).toEqual(ERadiusCode.AccessReject);
});
tap.test('should create Access-Challenge packet', async () => {
const identifier = 1;
const requestAuth = Buffer.alloc(16);
const secret = 'testing123';
const state = Buffer.from('challenge-state-123');
const packet = RadiusPacket.createAccessChallenge(identifier, requestAuth, secret, [
{ type: ERadiusAttributeType.ReplyMessage, value: 'Enter OTP' },
{ type: ERadiusAttributeType.State, value: state },
]);
const decoded = RadiusPacket.decode(packet);
expect(decoded.code).toEqual(ERadiusCode.AccessChallenge);
});
tap.test('should get code name', async () => {
expect(RadiusPacket.getCodeName(ERadiusCode.AccessRequest)).toEqual('Access-Request');
expect(RadiusPacket.getCodeName(ERadiusCode.AccessAccept)).toEqual('Access-Accept');
expect(RadiusPacket.getCodeName(ERadiusCode.AccessReject)).toEqual('Access-Reject');
expect(RadiusPacket.getCodeName(ERadiusCode.AccountingRequest)).toEqual('Accounting-Request');
expect(RadiusPacket.getCodeName(ERadiusCode.AccountingResponse)).toEqual('Accounting-Response');
expect(RadiusPacket.getCodeName(ERadiusCode.AccessChallenge)).toEqual('Access-Challenge');
});
export default tap.start();

282
test/server/test.pap.ts Normal file
View File

@@ -0,0 +1,282 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as crypto from 'crypto';
import { RadiusAuthenticator } from '../../ts_server/index.js';
tap.test('should encrypt and decrypt short password (< 16 chars)', async () => {
const password = 'secret';
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
// Encrypted length should be multiple of 16
expect(encrypted.length).toEqual(16);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
secret
);
expect(decrypted).toEqual(password);
});
tap.test('should encrypt and decrypt exactly 16-char password', async () => {
const password = '1234567890123456'; // Exactly 16 characters
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
expect(encrypted.length).toEqual(16);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
secret
);
expect(decrypted).toEqual(password);
});
tap.test('should encrypt and decrypt long password (> 16 chars)', async () => {
const password = 'thisisaverylongpasswordthatexceeds16characters';
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
// Should be padded to next multiple of 16
expect(encrypted.length % 16).toEqual(0);
expect(encrypted.length).toBeGreaterThan(16);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
secret
);
expect(decrypted).toEqual(password);
});
tap.test('should encrypt and decrypt password near max length (128 chars)', async () => {
const password = 'a'.repeat(120); // Near max of 128
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
// Should be limited to 128 bytes
expect(encrypted.length).toBeLessThanOrEqual(128);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
secret
);
expect(decrypted).toEqual(password);
});
tap.test('should truncate password exceeding 128 chars', async () => {
const password = 'a'.repeat(150); // Exceeds max
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
// Should be exactly 128 bytes (max)
expect(encrypted.length).toEqual(128);
// Decrypted will be truncated to 128 chars
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
secret
);
expect(decrypted.length).toBeLessThanOrEqual(128);
});
tap.test('should handle empty password', async () => {
const password = '';
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
// Even empty password is padded to 16 bytes
expect(encrypted.length).toEqual(16);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
secret
);
expect(decrypted).toEqual(password);
});
tap.test('should fail to decrypt with wrong secret', async () => {
const password = 'mypassword';
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const wrongSecret = 'wrongsecret';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
wrongSecret
);
// Should not match original password
expect(decrypted).not.toEqual(password);
});
tap.test('should fail to decrypt with wrong authenticator', async () => {
const password = 'mypassword';
const requestAuthenticator = crypto.randomBytes(16);
const wrongAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
wrongAuthenticator,
secret
);
// Should not match original password
expect(decrypted).not.toEqual(password);
});
tap.test('should handle special characters in password', async () => {
const password = '!@#$%^&*()_+-=[]{}|;:,.<>?';
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
secret
);
expect(decrypted).toEqual(password);
});
tap.test('should handle unicode characters in password', async () => {
const password = 'パスワード密码'; // Japanese + Chinese
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
const encrypted = RadiusAuthenticator.encryptPassword(
password,
requestAuthenticator,
secret
);
const decrypted = RadiusAuthenticator.decryptPassword(
encrypted,
requestAuthenticator,
secret
);
expect(decrypted).toEqual(password);
});
tap.test('should reject invalid encrypted password length', async () => {
const requestAuthenticator = crypto.randomBytes(16);
const secret = 'testing123';
// Not a multiple of 16
const invalidEncrypted = Buffer.alloc(15);
let error: Error | undefined;
try {
RadiusAuthenticator.decryptPassword(invalidEncrypted, requestAuthenticator, secret);
} catch (e) {
error = e as Error;
}
expect(error).toBeDefined();
expect(error!.message).toInclude('Invalid');
});
tap.test('PAP encryption matches RFC 2865 algorithm', async () => {
// Test that our implementation matches the RFC 2865 algorithm:
// b1 = MD5(S + RA) c(1) = p1 xor b1
// b2 = MD5(S + c(1)) c(2) = p2 xor b2
const password = 'testpassword12345678'; // Longer than 16 to test chaining
const requestAuth = Buffer.alloc(16, 0x42); // Fixed for deterministic test
const secret = 'sharedsecret';
// Our implementation
const encrypted = RadiusAuthenticator.encryptPassword(password, requestAuth, secret);
// Manual calculation per RFC
const paddedLength = Math.ceil(password.length / 16) * 16;
const padded = Buffer.alloc(paddedLength, 0);
Buffer.from(password, 'utf8').copy(padded);
const expected = Buffer.alloc(paddedLength);
let previousCipher = requestAuth;
for (let i = 0; i < paddedLength; i += 16) {
const md5 = crypto.createHash('md5');
md5.update(Buffer.from(secret, 'utf8'));
md5.update(previousCipher);
const b = md5.digest();
for (let j = 0; j < 16; j++) {
expected[i + j] = padded[i + j] ^ b[j];
}
previousCipher = expected.subarray(i, i + 16);
}
expect(encrypted.equals(expected)).toBeTruthy();
});
export default tap.start();