diff --git a/src/dictionary.ts b/src/dictionary.ts new file mode 100644 index 0000000..6f143f3 --- /dev/null +++ b/src/dictionary.ts @@ -0,0 +1,225 @@ +// Standard RADIUS Attributes from RFC 2865, 2866, and others. +// Format: ID: { name, type } + +export type AttributeType = + | 'string' + | 'integer' + | 'ipaddr' + | 'ipv6addr' + | 'date' + | 'ifid' + | 'integer64' + | 'ipv6prefix'; + +export interface AttributeDefinition { + name: string; + type: AttributeType; +} + +export const RADIUS_ATTRIBUTES: Record = { + 1: { name: 'User-Name', type: 'string' }, + 2: { name: 'User-Password', type: 'string' }, // Special handling in protocol usually, but string representation + 3: { name: 'CHAP-Password', type: 'string' }, + 4: { name: 'NAS-IP-Address', type: 'ipaddr' }, + 5: { name: 'NAS-Port', type: 'integer' }, + 6: { name: 'Service-Type', type: 'integer' }, + 7: { name: 'Framed-Protocol', type: 'integer' }, + 8: { name: 'Framed-IP-Address', type: 'ipaddr' }, + 9: { name: 'Framed-IP-Netmask', type: 'ipaddr' }, + 10: { name: 'Framed-Routing', type: 'integer' }, + 11: { name: 'Filter-Id', type: 'string' }, + 12: { name: 'Framed-MTU', type: 'integer' }, + 13: { name: 'Framed-Compression', type: 'integer' }, + 14: { name: 'Login-IP-Host', type: 'ipaddr' }, + 15: { name: 'Login-Service', type: 'integer' }, + 16: { name: 'Login-TCP-Port', type: 'integer' }, + // 17 (unassigned) + 18: { name: 'Reply-Message', type: 'string' }, + 19: { name: 'Callback-Number', type: 'string' }, + 20: { name: 'Callback-Id', type: 'string' }, + // 21 (unassigned) + 22: { name: 'Framed-Route', type: 'string' }, + 23: { name: 'Framed-IPX-Network', type: 'integer' }, + 24: { name: 'State', type: 'string' }, + 25: { name: 'Class', type: 'string' }, + 26: { name: 'Vendor-Specific', type: 'string' }, // Special handling + 27: { name: 'Session-Timeout', type: 'integer' }, + 28: { name: 'Idle-Timeout', type: 'integer' }, + 29: { name: 'Termination-Action', type: 'integer' }, + 30: { name: 'Called-Station-Id', type: 'string' }, + 31: { name: 'Calling-Station-Id', type: 'string' }, + 32: { name: 'NAS-Identifier', type: 'string' }, + 33: { name: 'Proxy-State', type: 'string' }, + 34: { name: 'Login-LAT-Service', type: 'string' }, + 35: { name: 'Login-LAT-Node', type: 'string' }, + 36: { name: 'Login-LAT-Group', type: 'string' }, + 37: { name: 'Framed-AppleTalk-Link', type: 'integer' }, + 38: { name: 'Framed-AppleTalk-Network', type: 'integer' }, + 39: { name: 'Framed-AppleTalk-Zone', type: 'string' }, + // 40-59 (Accounting) + 40: { name: 'Acct-Status-Type', type: 'integer' }, + 41: { name: 'Acct-Delay-Time', type: 'integer' }, + 42: { name: 'Acct-Input-Octets', type: 'integer' }, + 43: { name: 'Acct-Output-Octets', type: 'integer' }, + 44: { name: 'Acct-Session-Id', type: 'string' }, + 45: { name: 'Acct-Authentic', type: 'integer' }, + 46: { name: 'Acct-Session-Time', type: 'integer' }, + 47: { name: 'Acct-Input-Packets', type: 'integer' }, + 48: { name: 'Acct-Output-Packets', type: 'integer' }, + 49: { name: 'Acct-Terminate-Cause', type: 'integer' }, + 50: { name: 'Acct-Multi-Session-Id', type: 'string' }, + 51: { name: 'Acct-Link-Count', type: 'integer' }, + 52: { name: 'Acct-Input-Gigawords', type: 'integer' }, + 53: { name: 'Acct-Output-Gigawords', type: 'integer' }, + // 54 (unassigned) + 55: { name: 'Event-Timestamp', type: 'date' }, + // 56-59 (unassigned) + 60: { name: 'CHAP-Challenge', type: 'string' }, + 61: { name: 'NAS-Port-Type', type: 'integer' }, + 62: { name: 'Port-Limit', type: 'integer' }, + 63: { name: 'Login-LAT-Port', type: 'string' }, + // 64 (Tunneling) + 64: { name: 'Tunnel-Type', type: 'integer' }, + 65: { name: 'Tunnel-Medium-Type', type: 'integer' }, + 66: { name: 'Tunnel-Client-Endpoint', type: 'string' }, + 67: { name: 'Tunnel-Server-Endpoint', type: 'string' }, + 68: { name: 'Acct-Tunnel-Connection', type: 'string' }, + 69: { name: 'Tunnel-Password', type: 'string' }, + 70: { name: 'ARAP-Password', type: 'string' }, + 71: { name: 'ARAP-Features', type: 'string' }, + 72: { name: 'ARAP-Zone-Access', type: 'integer' }, + 73: { name: 'ARAP-Security', type: 'integer' }, + 74: { name: 'ARAP-Security-Data', type: 'string' }, + 75: { name: 'Password-Retry', type: 'integer' }, + 76: { name: 'Prompt', type: 'integer' }, + 77: { name: 'Connect-Info', type: 'string' }, + 78: { name: 'Configuration-Token', type: 'string' }, + 79: { name: 'EAP-Message', type: 'string' }, + 80: { name: 'Message-Authenticator', type: 'string' }, // 16 bytes + 81: { name: 'Tunnel-Private-Group-Id', type: 'string' }, + 82: { name: 'Tunnel-Assignment-Id', type: 'string' }, + 83: { name: 'Tunnel-Preference', type: 'integer' }, + 84: { name: 'ARAP-Challenge-Response', type: 'string' }, + 85: { name: 'Acct-Interim-Interval', type: 'integer' }, + 86: { name: 'Acct-Tunnel-Packets-Lost', type: 'integer' }, + 87: { name: 'NAS-Port-Id', type: 'string' }, + 88: { name: 'Framed-Pool', type: 'string' }, + 89: { name: 'CUI', type: 'string' }, + 90: { name: 'Tunnel-Client-Auth-Id', type: 'string' }, + 91: { name: 'Tunnel-Server-Auth-Id', type: 'string' }, + // 92-93 (unassigned) + 94: { name: 'Originating-Line-Info', type: 'string' }, + 95: { name: 'NAS-IPv6-Address', type: 'ipv6addr' }, + 96: { name: 'Framed-Interface-Id', type: 'ifid' }, + 97: { name: 'Framed-IPv6-Prefix', type: 'ipv6prefix' }, + 98: { name: 'Login-IPv6-Host', type: 'ipv6addr' }, + 99: { name: 'Framed-IPv6-Route', type: 'string' }, + 100: { name: 'Framed-IPv6-Pool', type: 'string' }, + 101: { name: 'Error-Cause', type: 'integer' }, + 102: { name: 'EAP-Key-Name', type: 'string' }, + 103: { name: 'Digest-Response', type: 'string' }, + 104: { name: 'Digest-Realm', type: 'string' }, + 105: { name: 'Digest-Nonce', type: 'string' }, + 106: { name: 'Digest-Response-Auth', type: 'string' }, + 107: { name: 'Digest-Nextnonce', type: 'string' }, + 108: { name: 'Digest-Method', type: 'string' }, + 109: { name: 'Digest-URI', type: 'string' }, + 110: { name: 'Digest-Qop', type: 'string' }, + 111: { name: 'Digest-Algorithm', type: 'string' }, + 112: { name: 'Digest-Entity-Body-Hash', type: 'string' }, + 113: { name: 'Digest-CNonce', type: 'string' }, + 114: { name: 'Digest-Nonce-Count', type: 'string' }, + 115: { name: 'Digest-Username', type: 'string' }, + 116: { name: 'Digest-Opaque', type: 'string' }, + 117: { name: 'Digest-Auth-Param', type: 'string' }, + 118: { name: 'Digest-AKA-Auts', type: 'string' }, + 119: { name: 'Digest-Domain', type: 'string' }, + 120: { name: 'Digest-Stale', type: 'string' }, + 121: { name: 'Digest-HA1', type: 'string' }, + 122: { name: 'SIP-AOR', type: 'string' }, + 123: { name: 'Delegated-IPv6-Prefix', type: 'ipv6prefix' }, + 124: { name: 'MIP6-Feature-Vector', type: 'integer' }, + 125: { name: 'MIP6-Home-Link-Prefix', type: 'ipv6prefix' }, + 126: { name: 'Operator-Name', type: 'string' }, + 127: { name: 'Location-Info', type: 'string' }, + 128: { name: 'Location-Data', type: 'string' }, + 129: { name: 'Basic-Location-Policy-Rules', type: 'string' }, + 130: { name: 'Extended-Location-Policy-Rules', type: 'string' }, + 131: { name: 'Location-Capable', type: 'integer' }, + 132: { name: 'Requested-Location-Info', type: 'string' }, + 133: { name: 'Framed-Management-Protocol', type: 'string' }, + 134: { name: 'Management-Transport-Protection', type: 'integer' }, + 135: { name: 'Management-Policy-Id', type: 'string' }, + 136: { name: 'Management-Privilege-Level', type: 'integer' }, + 137: { name: 'PKM-SS-Cert', type: 'string' }, + 138: { name: 'PKM-CA-Cert', type: 'string' }, + 139: { name: 'PKM-Config-Settings', type: 'string' }, + 140: { name: 'PKM-Cryptosuite-List', type: 'string' }, + 141: { name: 'PKM-SAID', type: 'string' }, + 142: { name: 'PKM-SA-Descriptor', type: 'string' }, + 143: { name: 'PKM-Auth-Key', type: 'string' }, + 144: { name: 'DS-Lite-Tunnel-Name', type: 'string' }, + 145: { name: 'Mobile-Node-Identifier', type: 'string' }, + 146: { name: 'Service-Selection', type: 'string' }, + 147: { name: 'PMIP6-Home-LMA-IPv6-Address', type: 'ipv6addr' }, + 148: { name: 'PMIP6-Visited-LMA-IPv6-Address', type: 'ipv6addr' }, + 149: { name: 'PMIP6-Home-LMA-IPv4-Address', type: 'ipaddr' }, + 150: { name: 'PMIP6-Visited-LMA-IPv4-Address', type: 'ipaddr' }, + 151: { name: 'PMIP6-Home-HN-Prefix', type: 'ipv6prefix' }, + 152: { name: 'PMIP6-Visited-HN-Prefix', type: 'ipv6prefix' }, + 153: { name: 'PMIP6-Home-Interface-ID', type: 'ifid' }, + 154: { name: 'PMIP6-Visited-Interface-ID', type: 'ifid' }, + 155: { name: 'PMIP6-Home-HNP-PMIP6', type: 'ipv6prefix' }, + 156: { name: 'PMIP6-Visited-HNP-PMIP6', type: 'ipv6prefix' }, + 157: { name: 'GGSN-Address', type: 'ipaddr' }, + 158: { name: 'GGSN-IPv6-Address', type: 'ipv6addr' }, + 159: { name: '3GPP-IMSI', type: 'string' }, + 160: { name: '3GPP-Charging-Id', type: 'integer' }, + 161: { name: '3GPP-PDP-Type', type: 'integer' }, + 162: { name: '3GPP-CG-Address', type: 'ipaddr' }, + 163: { name: '3GPP-GPRS-Negotiated-QoS-Profile', type: 'string' }, + 164: { name: '3GPP-SGSN-Address', type: 'ipaddr' }, + 165: { name: '3GPP-GGSN-Address', type: 'ipaddr' }, + 166: { name: '3GPP-IMSI-MCC-MNC', type: 'string' }, + 167: { name: '3GPP-GGSN-MCC-MNC', type: 'string' }, + 168: { name: '3GPP-NSAPI', type: 'integer' }, + 169: { name: '3GPP-Session-Stop-Indicator', type: 'string' }, // boolean flag really + 170: { name: '3GPP-Selection-Mode', type: 'integer' }, + 171: { name: '3GPP-Charging-Characteristics', type: 'string' }, + 172: { name: '3GPP-CG-IPv6-Address', type: 'ipv6addr' }, + 173: { name: '3GPP-SGSN-IPv6-Address', type: 'ipv6addr' }, + 174: { name: '3GPP-GGSN-IPv6-Address', type: 'ipv6addr' }, + 175: { name: '3GPP-IPv6-DNS-Servers', type: 'ipv6addr' }, + 176: { name: '3GPP-SGSN-MCC-MNC', type: 'string' }, + 177: { name: '3GPP-Teardown-Indicator', type: 'string' }, + 178: { name: '3GPP-IMEISV', type: 'string' }, + 179: { name: '3GPP-RAT-Type', type: 'integer' }, + 180: { name: '3GPP-User-Location-Info', type: 'string' }, + 181: { name: '3GPP-MS-TimeZone', type: 'string' }, + 182: { name: '3GPP-Camel-Charging-Info', type: 'string' }, + 183: { name: '3GPP-Packet-Filter', type: 'string' }, + 184: { name: '3GPP-Negotiated-DSCP', type: 'integer' }, + 185: { name: '3GPP-Allocate-IP-Type', type: 'integer' }, + 186: { name: '3GPP-Delivery-Order', type: 'integer' }, + 187: { name: '3GPP-Traffic-Class', type: 'integer' }, + 188: { name: '3GPP-Maximum-Bitrate-Uplink', type: 'integer' }, + 189: { name: '3GPP-Maximum-Bitrate-Downlink', type: 'integer' }, + 190: { name: '3GPP-Guaranteed-Bitrate-Uplink', type: 'integer' }, + 191: { name: '3GPP-Guaranteed-Bitrate-Downlink', type: 'integer' }, + 192: { name: 'EAP-Payload', type: 'string' }, + 193: { name: 'EAP-Reissued-Payload', type: 'string' }, + 194: { name: 'EAP-Master-Session-Key', type: 'string' }, + 195: { name: 'EAP-Keying-Material', type: 'string' }, + 196: { name: 'EAP-Authorization-Data', type: 'string' }, + 197: { name: 'EAP-Session-Id', type: 'string' }, + 198: { name: 'EAP-Session-Support', type: 'integer' }, + 199: { name: 'EAP-Peer-Id', type: 'string' }, + 200: { name: 'EAP-Peer-Id-Type', type: 'integer' }, + 201: { name: 'EAP-TLS-Method-Type', type: 'integer' }, + 202: { name: 'EAP-TLS-Start', type: 'integer' }, + 203: { name: 'EAP-TLS-Finish', type: 'integer' }, + 204: { name: 'EAP-TLS-Verify', type: 'integer' }, + 205: { name: 'EAP-TLS-Require-Client-Cert', type: 'integer' }, + 206: { name: 'EAP-TLS-Require-Client-Cert-Type', type: 'integer' }, + // ... and so on. This covers a large swath. +}; diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..f6a359e --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,170 @@ +import { RADIUS_ATTRIBUTES, type AttributeDefinition } from "./dictionary"; +import type { ParsedRadiusAttribute, ParsedAttribute, VendorSpecificAttribute } from "./types"; + +export function decodeString(buffer: Buffer): string { + return buffer.toString("utf8"); +} + +export function decodeInteger(buffer: Buffer): number { + if (buffer.length !== 4) return 0; + return buffer.readUInt32BE(0); +} + +export function decodeInteger64(buffer: Buffer): bigint { + if (buffer.length !== 8) return BigInt(0); + return buffer.readBigUInt64BE(0); +} + +export function decodeDate(buffer: Buffer): Date { + if (buffer.length !== 4) return new Date(0); + const seconds = buffer.readUInt32BE(0); + return new Date(seconds * 1000); +} + +export function decodeIpAddr(buffer: Buffer): string { + if (buffer.length !== 4) return "0.0.0.0"; + return `${buffer[0]}.${buffer[1]}.${buffer[2]}.${buffer[3]}`; +} + +export function decodeIpv6Addr(buffer: Buffer): string { + if (buffer.length !== 16) return "::"; + const parts: string[] = []; + for (let i = 0; i < 16; i += 2) { + parts.push(buffer.readUInt16BE(i).toString(16)); + } + return parts.join(":"); +} + +export function decodeIpv6Prefix(buffer: Buffer): string { + if (buffer.length < 2) return ""; + // buffer[0] is reserved + const prefixLength = buffer[1]; + const prefixBuffer = buffer.slice(2); + + const fullAddress = Buffer.alloc(16, 0); + prefixBuffer.copy(fullAddress); + + const parts: string[] = []; + for (let i = 0; i < 16; i += 2) { + parts.push(fullAddress.readUInt16BE(i).toString(16)); + } + return `${parts.join(":")}/${prefixLength}`; +} + +export function decodeIfId(buffer: Buffer): string { + if (buffer.length !== 8) return buffer.toString("hex"); + const parts: string[] = []; + for (let i = 0; i < 8; i++) { + parts.push(buffer[i].toString(16).padStart(2, "0")); + } + return parts.join(":"); +} + +export function decodeVendorSpecific(value: Buffer): VendorSpecificAttribute { + if (value.length < 4) { + return { + id: 26, + name: "Vendor-Specific", + vendorId: 0, + value: value.toString("hex"), + raw: value.toString("hex") + }; + } + + const vendorId = value.readUInt32BE(0); + const data = value.slice(4); + + // Attempt to parse sub-attributes + const subAttributes: { vendorType: number; value: string }[] = []; + let offset = 0; + let parsable = true; + + // Generic VSA parsing: Type (1 byte), Length (1 byte), Value (Length-2) + while (offset < data.length) { + if (offset + 2 > data.length) { + parsable = false; + break; + } + const t = data[offset]; + const l = data[offset + 1]; + + if (l < 2 || offset + l > data.length) { + parsable = false; + break; + } + + const val = data.slice(offset + 2, offset + l); + subAttributes.push({ + vendorType: t, + value: val.toString("hex") + }); + offset += l; + } + + return { + id: 26, + name: "Vendor-Specific", + vendorId, + value: parsable && subAttributes.length > 0 ? subAttributes : data.toString("hex"), + raw: value.toString("hex") + }; +} + +export function decodeAttribute(id: number, value: Buffer): ParsedRadiusAttribute { + if (id === 26) { + return decodeVendorSpecific(value); + } + + const def = RADIUS_ATTRIBUTES[id]; + + if (!def) { + return { + id, + name: `Unknown-Attribute-${id}`, + value: value.toString("hex"), + raw: value.toString("hex") + }; + } + + let decodedValue: any; + + try { + switch (def.type) { + case "string": + decodedValue = decodeString(value); + break; + case "integer": + decodedValue = decodeInteger(value); + break; + case "integer64": + decodedValue = decodeInteger64(value); + break; + case "date": + decodedValue = decodeDate(value); + break; + case "ipaddr": + decodedValue = decodeIpAddr(value); + break; + case "ipv6addr": + decodedValue = decodeIpv6Addr(value); + break; + case "ipv6prefix": + decodedValue = decodeIpv6Prefix(value); + break; + case "ifid": + decodedValue = decodeIfId(value); + break; + default: + decodedValue = value.toString("hex"); + } + } catch (e) { + decodedValue = value.toString("hex"); + } + + return { + id, + name: def.name, + value: decodedValue, + raw: value.toString("hex") + }; +} diff --git a/src/protocol.ts b/src/protocol.ts index 2763d61..2b94232 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,6 +1,7 @@ import dgram from "dgram"; import crypto from "crypto"; -import type { Logger, RadiusResult, RadiusProtocolOptions } from "./types"; +import type { Logger, RadiusResult, RadiusProtocolOptions, ParsedRadiusAttribute } from "./types"; +import { decodeAttribute } from "./helpers"; // Minimal RADIUS client using UDP for Access-Request/Accept exchange. // This is intentionally small and supports only PAP (User-Password) and Class attribute extraction. @@ -111,12 +112,13 @@ export async function radiusAuthenticate( if (logger) logger.warn('[radius] response authenticator verification error', e); } - // 2 = Access-Accept, 3 = Access-Reject - if (code === 2) { + // 2 = Access-Accept, 3 = Access-Reject, 11 = Access-Challenge + if (code === 2 || code === 3 || code === 11) { // parse attributes for Class (type 25) - handle multiple classes and validate properly let offset = 20; let foundClass: string | undefined = undefined; const allClasses: string[] = []; + const parsedAttributes: ParsedRadiusAttribute[] = []; while (offset + 2 <= msg.length) { const t = msg.readUInt8(offset); @@ -136,7 +138,14 @@ export async function radiusAuthenticate( const value = msg.slice(offset + 2, offset + l); - // Check if this is our target attribute + // NEW: Generic parsing + try { + parsedAttributes.push(decodeAttribute(t, value)); + } catch (e) { + if (logger) logger.warn('[radius] error decoding attribute', { type: t, error: e }); + } + + // Check if this is our target attribute (Legacy logic preserved) let isTargetAttribute = false; let extractedValue: string | undefined = undefined; @@ -199,9 +208,23 @@ export async function radiusAuthenticate( offset += l; } - resolve({ ok: true, class: foundClass, raw: msg.toString("hex") }); + const isOk = (code === 2); + let errorString: string | undefined = undefined; + if (!isOk) { + if (code === 3) errorString = 'access_reject'; + else if (code === 11) errorString = 'access_challenge'; + else errorString = 'unknown_code'; + } + + resolve({ + ok: isOk, + class: foundClass, + attributes: parsedAttributes, + raw: msg.toString("hex"), + error: errorString + }); } else { - resolve({ ok: false, raw: msg.toString("hex"), error: code === 3 ? 'access_reject' : 'unknown_code' }); + resolve({ ok: false, raw: msg.toString("hex"), error: 'unknown_code' }); } }); diff --git a/src/types.ts b/src/types.ts index 5c7b820..6326a16 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,9 +20,27 @@ export class ConsoleLogger implements Logger { } } +export interface ParsedAttribute { + id: number; + name: string; + value: any; + raw: string; // Hex string of value for reference +} + +export interface VendorSpecificAttribute { + id: 26; + name: "Vendor-Specific"; + vendorId: number; + value: any; // If parsed, structure; else hex string + raw: string; +} + +export type ParsedRadiusAttribute = ParsedAttribute | VendorSpecificAttribute; + export interface RadiusResult { ok: boolean; class?: string; + attributes?: ParsedRadiusAttribute[]; raw?: string; error?: string; } diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts new file mode 100644 index 0000000..3c55c93 --- /dev/null +++ b/tests/helpers.test.ts @@ -0,0 +1,115 @@ +import { describe, test, expect } from 'bun:test'; +import { decodeAttribute, decodeVendorSpecific } from '../src/helpers'; + +describe('Attribute Decoding', () => { + test('decodes User-Name (string)', () => { + const value = Buffer.from('alice', 'utf8'); + const result = decodeAttribute(1, value); + expect(result).toEqual({ + id: 1, + name: 'User-Name', + value: 'alice', + raw: value.toString('hex') + }); + }); + + test('decodes NAS-IP-Address (ipaddr)', () => { + const value = Buffer.from([192, 168, 1, 1]); + const result = decodeAttribute(4, value); + expect(result).toEqual({ + id: 4, + name: 'NAS-IP-Address', + value: '192.168.1.1', + raw: value.toString('hex') + }); + }); + + test('decodes NAS-Port (integer)', () => { + const value = Buffer.alloc(4); + value.writeUInt32BE(12345, 0); + const result = decodeAttribute(5, value); + expect(result).toEqual({ + id: 5, + name: 'NAS-Port', + value: 12345, + raw: value.toString('hex') + }); + }); + + test('decodes Event-Timestamp (date)', () => { + const now = Math.floor(Date.now() / 1000); + const value = Buffer.alloc(4); + value.writeUInt32BE(now, 0); + const result = decodeAttribute(55, value); + expect(result.id).toBe(55); + expect(result.name).toBe('Event-Timestamp'); + expect(result.value).toBeInstanceOf(Date); + expect((result.value as Date).getTime()).toBe(now * 1000); + }); + + test('decodes NAS-IPv6-Address (ipv6addr)', () => { + // 2001:db8::1 + const value = Buffer.from([ + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 + ]); + const result = decodeAttribute(95, value); + expect(result.value).toBe('2001:db8:0:0:0:0:0:1'); + }); + + test('decodes Framed-IPv6-Prefix (ipv6prefix)', () => { + // Reserved (1) + Prefix-Length (1) + Prefix (up to 16) + // /64 prefix: 2001:db8:: + const value = Buffer.concat([ + Buffer.from([0, 64]), + Buffer.from([0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0]) + ]); + // 97 is Framed-IPv6-Prefix + const result = decodeAttribute(97, value); + // Helper implementation pads with zeros + expect(result.value).toBe('2001:db8:0:0:0:0:0:0/64'); + }); + + test('decodes Unknown Attribute as hex', () => { + const value = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + const result = decodeAttribute(254, value); + expect(result).toEqual({ + id: 254, + name: 'Unknown-Attribute-254', + value: 'deadbeef', + raw: 'deadbeef' + }); + }); + + test('decodes Vendor-Specific (Type 26)', () => { + // Cisco (9) + // VSA: Type=1, Length=4, Value='AB' + const vendorId = Buffer.alloc(4); + vendorId.writeUInt32BE(9, 0); + + // Sub-attribute: Type 1, Length 4, Value 0x4142 + const subAttr = Buffer.from([1, 4, 0x41, 0x42]); + + const value = Buffer.concat([vendorId, subAttr]); + + const result = decodeAttribute(26, value); + + expect(result.id).toBe(26); + expect(result.name).toBe('Vendor-Specific'); + // @ts-ignore + expect(result.vendorId).toBe(9); + // @ts-ignore + expect(result.value).toEqual([ + { vendorType: 1, value: '4142' } + ]); + }); + + test('decodes Malformed Vendor-Specific as raw hex', () => { + const value = Buffer.from([0, 0, 0, 9, 1]); // Too short for sub-header + const result = decodeAttribute(26, value); + expect(result.name).toBe('Vendor-Specific'); + // @ts-ignore + expect(result.vendorId).toBe(9); + expect(typeof result.value).toBe('string'); + }); +});