From 42b790e0b4f3e801ce7ab2dc28d6ca30b81e45e8 Mon Sep 17 00:00:00 2001 From: lwvemike Date: Tue, 9 Apr 2024 00:57:48 +0300 Subject: [PATCH] feat: finish ASCII authentication * add obfuscation --- pnpm-lock.yaml | 11 +- src/authentication.ts | 108 +---------- src/authentication/AuthenticationReply.ts | 131 +++++++++++++ src/authorization.ts | 103 +++++------ src/client.ts | 215 ++++++---------------- src/header.ts | 168 ++++++++--------- src/helpers.ts | 3 - src/index.ts | 12 +- src/packet.ts | 140 +++++++------- src/types.ts | 9 + src/utils.ts | 22 +++ 11 files changed, 426 insertions(+), 496 deletions(-) create mode 100644 src/authentication/AuthenticationReply.ts delete mode 100644 src/helpers.ts create mode 100644 src/types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff6c124..508f887 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1513,7 +1513,7 @@ packages: '@vue/shared': 3.3.10 estree-walker: 2.0.2 magic-string: 0.30.5 - postcss: 8.4.32 + postcss: 8.4.35 source-map-js: 1.0.2 dev: true @@ -3729,15 +3729,6 @@ packages: util-deprecate: 1.0.2 dev: true - /postcss@8.4.32: - resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.7 - picocolors: 1.0.0 - source-map-js: 1.0.2 - dev: true - /postcss@8.4.35: resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} engines: {node: ^10 || ^12 || >=14} diff --git a/src/authentication.ts b/src/authentication.ts index 15e613e..86c1ade 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -70,66 +70,6 @@ function validateAuthStart({ action, privLvl, authenType, authenService, userLen } } -export const STATUSES = { - TAC_PLUS_AUTHEN_STATUS_PASS: 0x01, - TAC_PLUS_AUTHEN_STATUS_FAIL: 0x02, - TAC_PLUS_AUTHEN_STATUS_GETDATA: 0x03, - TAC_PLUS_AUTHEN_STATUS_GETUSER: 0x04, - TAC_PLUS_AUTHEN_STATUS_GETPASS: 0x05, - TAC_PLUS_AUTHEN_STATUS_RESTART: 0x06, - TAC_PLUS_AUTHEN_STATUS_ERROR: 0x07, - TAC_PLUS_AUTHEN_STATUS_FOLLOW: 0x21, -} as const - -const ALLOWED_STATUSES = Object.values(STATUSES) - -type Status = typeof ALLOWED_STATUSES[number] - -function isStatus(maybeStatus: number): maybeStatus is Status { - return (ALLOWED_STATUSES as number[]).includes(maybeStatus) -} - -export const AUTH_REPLY_FLAGS = { - TAC_PLUS_REPLY_NO_ACTION: 0x00, - TAC_PLUS_REPLY_FLAG_NOECHO: 0x01, -} as const - -const ALLOWED_AUTH_REPLY_FLAGS = Object.values(AUTH_REPLY_FLAGS) - -type ReplyFlag = typeof ALLOWED_AUTH_REPLY_FLAGS[number] - -function isReplyFlag(maybeReplyFlag: number): maybeReplyFlag is ReplyFlag { - return (ALLOWED_AUTH_REPLY_FLAGS as number[]).includes(maybeReplyFlag) -} - -interface AuthReplyRecord { - status: Status - flags: ReplyFlag - messageLength: number - contentLength: number - content: string | null - message: string | null -} - -type UnknownAuthReply = Record<'status' | 'flags' | 'messageLength' | 'contentLength', number> - -function validateAuthReply({ status, flags, messageLength, contentLength }: UnknownAuthReply) { - if (!isStatus(status)) { - throw new Error('Invalid status') - } - - if (!isReplyFlag(flags)) { - throw new Error('Invalid reply flag') - } - - return { - status, - flags, - messageLength, - contentLength, - } -} - export const AUTH_CONTINUE_FLAGS = { TAC_PLUS_CONTINUE_FLAG_ABORT: 0x01, } as const @@ -179,7 +119,7 @@ const TAC_PLUS_VIRTUAL_PORT = '' const TAC_PLUS_VIRTUAL_REM_ADDR = '' interface CreateAuthContinueArgs { - password: string + userMsg: string flags: number data?: Buffer } @@ -240,43 +180,6 @@ export class Authentication { } } - static decodeAuthReply(data: Buffer, length: HeaderRecord['length']): AuthReplyRecord { - if (data.length < Authentication.REPLY_MIN_LENGTH) { - throw new Error('Invalid reply header length') - } - - const authReply = validateAuthReply({ - status: (data.readUInt8(0) & 0xF), - flags: (data.readUInt8(1) & 0xF), - messageLength: data.readUInt16BE(2), - contentLength: data.readUInt16BE(4), - }) - - let message = null - let content = null - let pos = 6 - - if (authReply.messageLength > 0) { - message = data.subarray(pos, pos + authReply.messageLength).toString('ascii') - pos += authReply.messageLength - } - - if (authReply.contentLength > 0) { - content = data.subarray(pos, pos + authReply.contentLength).toString('utf8') - pos += authReply.contentLength - } - - if (pos !== length) { - throw new Error('Incorrect length in header') - } - - return { - ...authReply, - message, - content, - } - } - static decodeAuthContinue(data: Buffer, length: HeaderRecord['length']): AuthContinueRecord { if (data.length < Authentication.CONTINUE_MIN_LENGTH) { throw new Error('Invalid continue header length') @@ -372,20 +275,19 @@ export class Authentication { } static createAuthContinue2(args: CreateAuthContinueArgs) { - const password = args.password + const userMsg = args.userMsg const data = args.data ?? Buffer.alloc(0) const flags = args.flags ?? 0 // TODO(lwvemike): see why this is needed 0 - const passwordBuffer = Buffer.from(password) + const userMsgBuffer = Buffer.from(userMsg) const header = Buffer.alloc(5) - header.writeUInt16BE(passwordBuffer.length, 0) + header.writeUInt16BE(userMsgBuffer.length, 0) header.writeUInt16BE(data.length, 2) header.writeUInt8(flags, 4) - return Buffer.concat([header, passwordBuffer, data]) + return Buffer.concat([header, userMsgBuffer, data]) } static readonly START_MIN_LENGTH = 8 - static readonly REPLY_MIN_LENGTH = 6 static readonly CONTINUE_MIN_LENGTH = 5 } diff --git a/src/authentication/AuthenticationReply.ts b/src/authentication/AuthenticationReply.ts new file mode 100644 index 0000000..a45efb1 --- /dev/null +++ b/src/authentication/AuthenticationReply.ts @@ -0,0 +1,131 @@ +import type { Buffer } from 'node:buffer' +import type { ToHumanReadable } from '../types' +import { getNameFromCollectionValue } from '../utils' +import type { Header } from '../header' + +function validateAuthReply({ status, flags, serverMsgLen, dataLen }: UnknownAuthReply) { + if (!isStatus(status)) { + throw new Error('Invalid status') + } + + if (!isReplyFlag(flags)) { + throw new Error('Invalid reply flag') + } + + return { + status, + flags, + serverMsgLen, + dataLen, + } +} + +export interface AuthReplyRecord { + status: Status + flags: ReplyFlag + serverMsgLen: number + dataLen: number + data: string | null + serverMsg: string | null +} + +export type UnknownAuthReply = Record<'status' | 'flags' | 'serverMsgLen' | 'dataLen', number> + +/** + * @throws Error + */ +export class AuthenticationReply implements ToHumanReadable { + readonly #status: AuthReplyRecord['status'] + readonly #serverMsg: AuthReplyRecord['serverMsg'] + readonly #flags: AuthReplyRecord['flags'] + readonly #serverMsgLen: AuthReplyRecord['serverMsgLen'] + readonly #dataLen: AuthReplyRecord['dataLen'] + readonly #data: AuthReplyRecord['data'] + + constructor(body: Buffer, length: Header['length']) { + if (body.length < AuthenticationReply.MIN_LENGTH) { + throw new Error('Invalid reply header length') + } + + const reply = validateAuthReply({ + status: (body.readUInt8(0) & 0xF), + flags: (body.readUInt8(1) & 0xF), + serverMsgLen: body.readUInt16BE(2), + dataLen: body.readUInt16BE(4), + }) + + let message = null + let content = null + let pos = 6 + + if (reply.serverMsgLen > 0) { + message = body.subarray(pos, pos + reply.serverMsgLen).toString('ascii') + pos += reply.serverMsgLen + } + + if (reply.dataLen > 0) { + content = body.subarray(pos, pos + reply.dataLen).toString('utf8') + pos += reply.dataLen + } + + if (pos !== length) { + throw new Error('Incorrect length in header') + } + + this.#status = reply.status + this.#serverMsg = message + this.#flags = reply.flags + this.#serverMsgLen = reply.serverMsgLen + this.#dataLen = reply.dataLen + this.#data = content + } + + get status() { + return this.#status + } + + toHumanReadable() { + return ( + `status: ${this.#status} | ${getNameFromCollectionValue(this.#status, AuthenticationReply.STATUSES)} + flags: ${this.#flags} | ${getNameFromCollectionValue(this.#flags, AuthenticationReply.FLAGS)} + serverMsgLen: ${this.#serverMsgLen} + dataLen: ${this.#dataLen} + serverMsg: ${this.#serverMsg} + data: ${this.#data}` + ) + } + + static readonly MIN_LENGTH = 6 + + static STATUSES = { + TAC_PLUS_AUTHEN_STATUS_PASS: 0x01, + TAC_PLUS_AUTHEN_STATUS_FAIL: 0x02, + TAC_PLUS_AUTHEN_STATUS_GETDATA: 0x03, + TAC_PLUS_AUTHEN_STATUS_GETUSER: 0x04, + TAC_PLUS_AUTHEN_STATUS_GETPASS: 0x05, + TAC_PLUS_AUTHEN_STATUS_RESTART: 0x06, + TAC_PLUS_AUTHEN_STATUS_ERROR: 0x07, + TAC_PLUS_AUTHEN_STATUS_FOLLOW: 0x21, + } as const + + static FLAGS = { + TAC_PLUS_REPLY_NO_ACTION: 0x00, + TAC_PLUS_REPLY_FLAG_NOECHO: 0x01, + } as const +} + +const ALLOWED_STATUSES = Object.values(AuthenticationReply.STATUSES) + +type Status = typeof ALLOWED_STATUSES[number] + +function isStatus(maybeStatus: number): maybeStatus is Status { + return (ALLOWED_STATUSES as number[]).includes(maybeStatus) +} + +const ALLOWED_AUTH_REPLY_FLAGS = Object.values(AuthenticationReply.FLAGS) + +type ReplyFlag = typeof ALLOWED_AUTH_REPLY_FLAGS[number] + +function isReplyFlag(maybeReplyFlag: number): maybeReplyFlag is ReplyFlag { + return (ALLOWED_AUTH_REPLY_FLAGS as number[]).includes(maybeReplyFlag) +} diff --git a/src/authorization.ts b/src/authorization.ts index ab45821..793020e 100644 --- a/src/authorization.ts +++ b/src/authorization.ts @@ -1,4 +1,3 @@ -import { Buffer } from 'node:buffer' import { AUTHEN_TYPES, isAuthenService, isPrivLevel } from './common' export const AUTHEN_METHODS = { @@ -97,57 +96,57 @@ function _validateAuthReply({ status }: UnknownAuthorizationReply) { } export class Authorization { - static createAuthRequest(args: CreateAuthorizationRequestArgs) { - const username = args.username - const authenMethod = args.authenMethod - const privLvl = args.privLvl - const authenType = args.authenType - const service = args.service - const argss = args.arguments ?? [] - const remAddr = args.remAddr ?? '' - const port = args.port ?? '' - - const usernameBuffer = Buffer.from(username) - const remAddrBuffer = Buffer.from(remAddr) - const portBuffer = Buffer.from(port) - - const headerLength = 8 - const argLenSum = argss.reduce((sum, arg) => sum + Buffer.byteLength(arg), 0) - const totalLength = headerLength + usernameBuffer.length + remAddrBuffer.length + portBuffer.length + argLenSum - - const header = Buffer.alloc(totalLength) - header.writeUInt8(authenMethod, 0) - header.writeUInt8(privLvl, 1) - header.writeUInt8(authenType, 2) - header.writeUInt8(service, 3) - header.writeUInt8(usernameBuffer.length, 4) - header.writeUInt8(portBuffer.length, 5) - header.writeUInt8(remAddrBuffer.length, 6) - header.writeUInt8(argss.length, 7) - - let offset = headerLength - for (const arg of argss) { - const argBuffer = Buffer.from(arg) - header.writeUInt8(argBuffer.length, offset) - offset++ - } - - offset = headerLength - header.write(username, offset) - offset += usernameBuffer.length - header.write(port, offset) - offset += portBuffer.length - header.write(remAddr, offset) - offset += remAddrBuffer.length - - for (const arg of argss) { - const argBuffer = Buffer.from(arg) - header.write(argBuffer.toString(), offset) - offset += argBuffer.length - } - - return header - } + // static createAuthRequest(args: CreateAuthorizationRequestArgs) { + // const username = args.username + // const authenMethod = args.authenMethod + // const privLvl = args.privLvl + // const authenType = args.authenType + // const service = args.service + // const argss = args.arguments ?? [] + // const remAddr = args.remAddr ?? '' + // const port = args.port ?? '' + + // const usernameBuffer = Buffer.from(username) + // const remAddrBuffer = Buffer.from(remAddr) + // const portBuffer = Buffer.from(port) + + // const headerLength = 8 + // const argLenSum = argss.reduce((sum, arg) => sum + Buffer.byteLength(arg), 0) + // const totalLength = headerLength + usernameBuffer.length + remAddrBuffer.length + portBuffer.length + argLenSum + + // const header = Buffer.alloc(totalLength) + // header.writeUInt8(authenMethod, 0) + // header.writeUInt8(privLvl, 1) + // header.writeUInt8(authenType, 2) + // header.writeUInt8(service, 3) + // header.writeUInt8(usernameBuffer.length, 4) + // header.writeUInt8(portBuffer.length, 5) + // header.writeUInt8(remAddrBuffer.length, 6) + // header.writeUInt8(argss.length, 7) + + // let offset = headerLength + // for (const arg of argss) { + // const argBuffer = Buffer.from(arg) + // header.writeUInt8(argBuffer.length, offset) + // offset++ + // } + + // offset = headerLength + // header.write(username, offset) + // offset += usernameBuffer.length + // header.write(port, offset) + // offset += portBuffer.length + // header.write(remAddr, offset) + // offset += remAddrBuffer.length + + // for (const arg of argss) { + // const argBuffer = Buffer.from(arg) + // header.write(argBuffer.toString(), offset) + // offset += argBuffer.length + // } + + // return header + // } static readonly REQUEST_MIN_LENGTH = 8 static readonly REPLY_MIN_LENGTH = 6 diff --git a/src/client.ts b/src/client.ts index e8c3a4b..978f4b1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,14 +1,13 @@ import type { Socket, TcpNetConnectOpts } from 'node:net' import { createConnection } from 'node:net' -import { randomBytes } from 'node:crypto' -import { Buffer } from 'node:buffer' -import type { HeaderRecord, HeaderType, Secret } from './header' -import { FLAGS, HEADER_TYPES, Header, MAJOR_VERSIONS, MINOR_VERSIONS, createVersionByte } from './header' +import type { Buffer } from 'node:buffer' +import type { HeaderRecord, Secret } from './header' +import { Header } from './header' import { Packet } from './packet' -import { AUTH_START_ACTIONS, Authentication, PrivilegeLevels, STATUSES } from './authentication' +import { AUTH_START_ACTIONS, Authentication } from './authentication' import { AUTHEN_SERVICE, AUTHEN_TYPES } from './common' -import { notImplemented } from './utils' -import { AUTHEN_METHODS, AUTHORIZATION_AUTHEN_TYPES, Authorization } from './authorization' +import { createClientSequenceNumberGenerator, getNameFromCollectionValue, notImplemented, randomInt } from './utils' +import { AuthenticationReply } from './authentication/AuthenticationReply' interface Logger { log: (message: string) => void @@ -21,36 +20,19 @@ type Options = host: string port: number secret: Secret - sessionId?: HeaderRecord['sessionId'] logger: Logger + socketTimeout?: number } & Pick -function randomInt(min: number, max: number) { - if (min >= max) { - throw new Error('min must be less than max') - } - - const bytes = Math.ceil((Math.log2(max) + 1) / 8) - - do { - const buffer = randomBytes(bytes) - - if (buffer[0] !== 0) { - return buffer.readUIntBE(0, bytes) % (max - min + 1) + min - } - } while (true) -} - export class Client { readonly #host: Options['host'] readonly #port: Options['port'] readonly #secret: Options['secret'] readonly #majorVersion: HeaderRecord['majorVersion'] readonly #minorVersion: HeaderRecord['minorVersion'] - readonly #sessionId: HeaderRecord['sessionId'] readonly #logger: Logger - #socket?: Socket + readonly #socketTimeout: number constructor(options: Options = Client.DEFAULT_OPTIONS) { this.#host = options.host @@ -58,158 +40,70 @@ export class Client { this.#secret = options.secret this.#majorVersion = options.majorVersion this.#minorVersion = options.minorVersion - this.#sessionId = options.sessionId ?? randomInt(1, 2 ** 32 - 1) this.#logger = options.logger + this.#socketTimeout = options.socketTimeout ?? 10_000 } - get version() { - return createVersionByte({ majorVersion: this.#majorVersion, minorVersion: this.#minorVersion }) - } - - send(body: Buffer, type: HeaderType, seqNo: HeaderRecord['seqNo'] = 1) { - const header = new Header({ - majorVersion: this.#majorVersion, - minorVersion: this.#minorVersion, - type, - flags: (this.#secret === null ? FLAGS.TAC_PLUS_UNENCRYPTED_FLAG : 0), - seqNo, - sessionId: this.#sessionId, - length: body.length, - }) - - const packet = new Packet(header, body, this.#secret) - - this.#logger.debug('Created TCP socket') - const socket = this.createSocket() - - // TODO(lwvemike): handle all socket events - socket.write(packet.toBuffer(), (err) => { - if (err) { - this.#logger.error(err.message) - // TODO(lwvemike): destroy the socket - } - }) - - return socket - } + // get version() { + // return createVersionByte({ majorVersion: this.#majorVersion, minorVersion: this.#minorVersion }) + // } authenticate(username: string, password: string) { - const passwordBuffer = Buffer.from(password) + const getNextSeqNo = createClientSequenceNumberGenerator() + const sessionId = randomInt() const body = Authentication.createAuthStart({ action: AUTH_START_ACTIONS.TAC_PLUS_AUTHEN_LOGIN, authenType: AUTHEN_TYPES.TAC_PLUS_AUTHEN_TYPE_ASCII, authenService: AUTHEN_SERVICE.TAC_PLUS_AUTHEN_SVC_NONE, username, - port: '', - remAddr: '', - data: passwordBuffer, }) - const header = new Header({ - majorVersion: this.#majorVersion, - minorVersion: this.#minorVersion, - type: AUTHEN_TYPES.TAC_PLUS_AUTHEN_TYPE_ASCII, - flags: (this.#secret === null ? FLAGS.TAC_PLUS_UNENCRYPTED_FLAG : 0), - seqNo: 1, - sessionId: this.#sessionId, - length: body.length, - }) + const header = this.createGenericASCIIAuthenticationHeader({ bodyLength: body.length, seqNo: getNextSeqNo(), sessionId }) - const packet = new Packet(header, body, this.#secret) + const authStartPacket = new Packet(header, body, this.#secret) const socket = this.createSocket() - const handleSocketWriteError = (error: Error | undefined) => { - if (error) { - this.#logger.error(error.message) - socket.destroy() - } - } + socket.write(authStartPacket.toBuffer()) - const handleAuthReply = (data: Buffer) => { - const authReplyResponse = Packet.decodePacket(data) - - if (authReplyResponse?.status === STATUSES.TAC_PLUS_AUTHEN_STATUS_PASS) { - this.#logger.debug('Pass') - } - else if (authReplyResponse?.status === STATUSES.TAC_PLUS_AUTHEN_STATUS_FAIL) { - this.#logger.debug('Fail') - } - else { - notImplemented(`${authReplyResponse?.status} STATUS`) + socket.on('data', (data: Buffer) => { + const decodedPacket = Packet.decodePacket(data, this.#secret) + + const authenticationReply = new AuthenticationReply(decodedPacket.body, decodedPacket.header.length) + + switch (authenticationReply.status) { + case AuthenticationReply.STATUSES.TAC_PLUS_AUTHEN_STATUS_GETPASS: { + const innerBody = Authentication.createAuthContinue2({ userMsg: password, flags: 0 }) + const innerHeader = this.createGenericASCIIAuthenticationHeader({ bodyLength: innerBody.length, seqNo: getNextSeqNo(), sessionId }) + const authContinuePacket = new Packet(innerHeader, innerBody, this.#secret) + + return socket.write(authContinuePacket.toBuffer()) + } + case AuthenticationReply.STATUSES.TAC_PLUS_AUTHEN_STATUS_FAIL: { + socket.end() + return this.#logger.error('Authentication FAIL') + } + case AuthenticationReply.STATUSES.TAC_PLUS_AUTHEN_STATUS_PASS: { + socket.end() + return this.#logger.log('Authentication PASS') + } + default: { + notImplemented(`${authenticationReply.status} | ${getNameFromCollectionValue(authenticationReply.status, AuthenticationReply.STATUSES)}`) + } } - - this.#logger.debug('Ended') - socket.destroy() - } - - socket.write(packet.toBuffer(), handleSocketWriteError) - - const handleAuthContinue = (data: Buffer) => { - socket.off('data', handleAuthContinue) - socket.on('data', handleAuthReply) - - const _authReply = Packet.decodePacket(data) - - const authContinue = Authentication.createAuthContinue2({ - password, - flags: 0x00, - }) - - const newHeader = new Header({ - majorVersion: this.#majorVersion, - minorVersion: this.#minorVersion, - type: HEADER_TYPES.TAC_PLUS_AUTHEN, - flags: (this.#secret === null ? FLAGS.TAC_PLUS_UNENCRYPTED_FLAG : 0), - seqNo: 3, - sessionId: this.#sessionId, - length: authContinue.length, - }) - - const buff = Buffer.concat([newHeader.toBuffer(), authContinue]) - - socket.write(buff, handleSocketWriteError) - } - - socket.on('data', handleAuthContinue) - } - - authorize(username: string, _password: string) { - const body = Authorization.createAuthRequest({ - username, - authenMethod: AUTHEN_METHODS.TAC_PLUS_AUTHEN_METH_NOT_SET, - privLvl: PrivilegeLevels.TAC_PLUS_PRIV_LVL_USER, - authenType: AUTHORIZATION_AUTHEN_TYPES.TAC_PLUS_AUTHEN_TYPE_ASCII, - service: 0, - arguments: [], }) + } - const header = new Header({ + private createGenericASCIIAuthenticationHeader({ bodyLength, seqNo, sessionId }: { sessionId: number, seqNo: number, bodyLength: number }) { + return new Header({ majorVersion: this.#majorVersion, minorVersion: this.#minorVersion, - type: AUTHEN_TYPES.TAC_PLUS_AUTHEN_TYPE_ASCII, - flags: (this.#secret === null ? FLAGS.TAC_PLUS_UNENCRYPTED_FLAG : 0), - seqNo: 1, - sessionId: this.#sessionId, - length: body.length, - }) - - const packet = new Packet(header, body, this.#secret) - - const socket = this.createSocket() - - const handleSocketWriteError = (error: Error | undefined) => { - if (error) { - this.#logger.error(error.message) - socket.destroy() - } - } - - socket.write(packet.toBuffer(), handleSocketWriteError) - - socket.on('data', (data: Buffer) => { - const _decoded = Packet.decodePacket(data) + type: Header.TYPES.TAC_PLUS_AUTHEN, + flags: (this.#secret === null ? Header.FLAGS.TAC_PLUS_UNENCRYPTED_FLAG : 0), + seqNo, + sessionId, + length: bodyLength, }) } @@ -223,8 +117,7 @@ export class Client { this.#logger.log('TACACS+ client connected') }) - // TODO(lwvemike): hardcoded right now, will be added in a variable in the future - socket.setTimeout(10_000, () => { + socket.setTimeout(this.#socketTimeout, () => { this.#logger.error('TACACS+ client connection timed out') socket.destroy() }) @@ -239,8 +132,8 @@ export class Client { this.#logger.log('TACACS+ client disconnected') }) - socket.on('close', () => { - this.#logger.log('TACACS+ client disconnected') + socket.on('close', (hadError) => { + this.#logger.log(`TACACS+ client closed ${hadError ? 'with' : 'without'} error`) }) socket.on('timeout', () => { @@ -255,8 +148,8 @@ export class Client { host: '127.0.0.1', port: 49, secret: null, - majorVersion: MAJOR_VERSIONS.TAC_PLUS_MAJOR_VER, - minorVersion: MINOR_VERSIONS.TAC_PLUS_MINOR_VER_DEFAULT, + majorVersion: Header.MAJOR_VERSIONS.TAC_PLUS_MAJOR_VER, + minorVersion: Header.MINOR_VERSIONS.TAC_PLUS_MINOR_VER_DEFAULT, logger: { // TODO(lwvemike): remove before release /* eslint-disable-next-line no-console */ diff --git a/src/header.ts b/src/header.ts index f48f8d0..e53e99c 100644 --- a/src/header.ts +++ b/src/header.ts @@ -1,5 +1,6 @@ import { Buffer } from 'node:buffer' -import { notImplemented } from './utils' +import { getNameFromCollectionValue, notImplemented } from './utils' +import type { ToBuffer, ToHumanReadable } from './types' export interface Versions { majorVersion: MajorVersion @@ -25,63 +26,6 @@ export interface UnknownHeader { type: number } -export function createVersionByte({ majorVersion, minorVersion }: Versions) { - return ((majorVersion & 0xF) << 4) | (minorVersion & 0xF) -} - -export const HEADER_TYPES = { - TAC_PLUS_AUTHEN: 0x01, - TAC_PLUS_AUTHOR: 0x02, - TAC_PLUS_ACCT: 0x03, -} as const - -export const ALLOWED_HEADER_TYPES = Object.values(HEADER_TYPES) - -export type HeaderType = typeof ALLOWED_HEADER_TYPES[number] - -function isHeaderType(maybeType: number): maybeType is HeaderType { - return (ALLOWED_HEADER_TYPES as number[]).includes(maybeType) -} - -export const FLAGS = { - TAC_PLUS_UNENCRYPTED_FLAG: 0x01, - TAC_PLUS_SINGLE_CONNECT_FLAG: 0x04, -} as const - -export const ALLOWED_FLAGS = Object.values(FLAGS) - -function isFlag(maybeFlag: number): maybeFlag is Flag { - return (ALLOWED_FLAGS as number[]).includes(maybeFlag) -} - -export type Flag = typeof FLAGS[keyof typeof FLAGS] - -export const MAJOR_VERSIONS = { - TAC_PLUS_MAJOR_VER_DEFAULT: 0x0, - TAC_PLUS_MAJOR_VER: 0xC, -} as const - -const ALLOWED_MAJOR_VERSIONS = Object.values(MAJOR_VERSIONS) - -type MajorVersion = typeof ALLOWED_MAJOR_VERSIONS[number] - -function isMajorVersion(maybeMajorVersion: number): maybeMajorVersion is MajorVersion { - return (ALLOWED_MAJOR_VERSIONS as number[]).includes(maybeMajorVersion) -} - -export const MINOR_VERSIONS = { - TAC_PLUS_MINOR_VER_DEFAULT: 0x0, - TAC_PLUS_MINOR_VER_ONE: 0x1, -} as const - -const ALLOWED_MINOR_VERSIONS = Object.values(MINOR_VERSIONS) - -type MinorVersion = typeof ALLOWED_MINOR_VERSIONS[number] - -function isMinorVersion(maybeMinorVersion: number): maybeMinorVersion is MinorVersion { - return (ALLOWED_MINOR_VERSIONS as number[]).includes(maybeMinorVersion) -} - function validateHeader({ majorVersion, minorVersion, flags, type, length, seqNo, sessionId }: UnknownHeader) { if (!isMajorVersion(majorVersion)) { throw new Error('Invalid major version') @@ -116,7 +60,10 @@ export type HeaderRecord = & BaseHeaderRecord & Record<'isEncrypted' | 'isSingleConnection', boolean> -export class Header { +/** + * @throws Error + */ +export class Header implements ToHumanReadable, ToBuffer { readonly #majorVersion: HeaderRecord['majorVersion'] readonly #minorVersion: HeaderRecord['minorVersion'] readonly #type: HeaderType @@ -125,6 +72,9 @@ export class Header { readonly #sessionId: number readonly #length: number + /** + * @description Validate the seqNo to be odd, because the constructor should be used only to create Headers that are sent to Tacacs+ server + */ constructor(unknownHeader: UnknownHeader) { const { majorVersion, @@ -165,11 +115,11 @@ export class Header { } get isEncrypted() { - return !((this.#flags & FLAGS.TAC_PLUS_UNENCRYPTED_FLAG) === FLAGS.TAC_PLUS_UNENCRYPTED_FLAG) + return !((this.#flags & Header.FLAGS.TAC_PLUS_UNENCRYPTED_FLAG) === Header.FLAGS.TAC_PLUS_UNENCRYPTED_FLAG) } get isSingleConnection() { - return ((this.#flags & FLAGS.TAC_PLUS_SINGLE_CONNECT_FLAG) === FLAGS.TAC_PLUS_UNENCRYPTED_FLAG) + return ((this.#flags & Header.FLAGS.TAC_PLUS_SINGLE_CONNECT_FLAG) === Header.FLAGS.TAC_PLUS_UNENCRYPTED_FLAG) } get type() { @@ -177,7 +127,7 @@ export class Header { } toBuffer() { - const buffer = Buffer.alloc(Header.SIZE) + const buffer = Buffer.alloc(Header.LENGTH) const versionByte = createVersionByte({ majorVersion: this.#majorVersion, @@ -199,8 +149,8 @@ export class Header { * @param raw */ static decode(raw: Buffer): Header { - if (raw.length !== Header.SIZE) { - throw new Error(`Header size must be ${Header.SIZE}, but received ${raw.length}`) + if (raw.length !== Header.LENGTH) { + throw new Error(`Header size must be ${Header.LENGTH}, but received ${raw.length}`) } let offset = 0 @@ -239,26 +189,76 @@ export class Header { }) } - toFormatted() { - return JSON.stringify({ - majorVersion: this.#majorVersion, - minorVersion: this.#minorVersion, - type: this.#type, - flags: this.#flags, - seqNo: this.#seqNo, - sessionId: this.#sessionId, - length: this.#length, - }, null, 2) + static readonly LENGTH = 12 + + static readonly TYPES = { + TAC_PLUS_AUTHEN: 0x01, + TAC_PLUS_AUTHOR: 0x02, + TAC_PLUS_ACCT: 0x03, + } as const + + static readonly FLAGS = { + TAC_PLUS_DEFAULT_FLAG: 0x00, + TAC_PLUS_UNENCRYPTED_FLAG: 0x01, + TAC_PLUS_SINGLE_CONNECT_FLAG: 0x04, + } as const + + static readonly MINOR_VERSIONS = { + TAC_PLUS_MINOR_VER_DEFAULT: 0x0, + TAC_PLUS_MINOR_VER_ONE: 0x1, + } as const + + static MAJOR_VERSIONS = { + TAC_PLUS_MAJOR_VER: 0xC, + } as const + + toHumanReadable() { + let formatted = '' + + formatted += `Major version: ${this.#majorVersion}\n` + formatted += `Minor version: ${this.#minorVersion}\n` + formatted += `Type: ${this.#type} | ${getNameFromCollectionValue(this.#type, Header.TYPES)}\n` + formatted += `Flags: ${this.#flags} | ${getNameFromCollectionValue(this.#flags, Header.FLAGS)}\n` + formatted += `Seq No: ${this.#seqNo}\n` + formatted += `Session Id: ${this.#sessionId}\n` + formatted += `Length: ${this.#length}\n` + + return formatted } +} - static readonly SIZE = 12 - static readonly DEFAULT_HEADER: BaseHeaderRecord = { - majorVersion: MAJOR_VERSIONS.TAC_PLUS_MAJOR_VER_DEFAULT, - minorVersion: MINOR_VERSIONS.TAC_PLUS_MINOR_VER_DEFAULT, - type: 0, - seqNo: 0x1, - flags: FLAGS.TAC_PLUS_UNENCRYPTED_FLAG, - sessionId: 0x0, - length: 0x0, - } +const ALLOWED_HEADER_TYPES = Object.values(Header.TYPES) + +export type HeaderType = typeof ALLOWED_HEADER_TYPES[number] + +function isHeaderType(maybeType: number): maybeType is HeaderType { + return (ALLOWED_HEADER_TYPES as number[]).includes(maybeType) +} + +const ALLOWED_FLAGS = Object.values(Header.FLAGS) + +function isFlag(maybeFlag: number): maybeFlag is Flag { + return (ALLOWED_FLAGS as number[]).includes(maybeFlag) +} + +export type Flag = typeof ALLOWED_FLAGS[number] + +export function createVersionByte({ majorVersion, minorVersion }: Versions) { + return ((majorVersion & 0xF) << 4) | (minorVersion & 0xF) +} + +const ALLOWED_MAJOR_VERSIONS = Object.values(Header.MAJOR_VERSIONS) + +type MajorVersion = typeof ALLOWED_MAJOR_VERSIONS[number] + +function isMajorVersion(maybeMajorVersion: number): maybeMajorVersion is MajorVersion { + return (ALLOWED_MAJOR_VERSIONS as number[]).includes(maybeMajorVersion) +} + +const ALLOWED_MINOR_VERSIONS = Object.values(Header.MINOR_VERSIONS) + +type MinorVersion = typeof ALLOWED_MINOR_VERSIONS[number] + +function isMinorVersion(maybeMinorVersion: number): maybeMinorVersion is MinorVersion { + return (ALLOWED_MINOR_VERSIONS as number[]).includes(maybeMinorVersion) } diff --git a/src/helpers.ts b/src/helpers.ts deleted file mode 100644 index e2543bb..0000000 --- a/src/helpers.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isOdd(value: number) { - return value & 1 -} diff --git a/src/index.ts b/src/index.ts index 472faa8..9ab75cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,14 @@ import { Client } from './client' -import { MAJOR_VERSIONS, MINOR_VERSIONS } from './header' +import { Header } from './header' const client = new Client({ host: '127.0.0.1', port: 49, - secret: null, - majorVersion: MAJOR_VERSIONS.TAC_PLUS_MAJOR_VER, - minorVersion: MINOR_VERSIONS.TAC_PLUS_MINOR_VER_DEFAULT, + secret: 'testing123', + majorVersion: Header.MAJOR_VERSIONS.TAC_PLUS_MAJOR_VER, + minorVersion: Header.MINOR_VERSIONS.TAC_PLUS_MINOR_VER_DEFAULT, logger: Client.DEFAULT_OPTIONS.logger, }) -// client.authenticate2('user3', 'cisco') -client.authorize('user4', 'cisco') +client.authenticate('user3', 'cisco') +// client.authenticate('user4', 'cisco') diff --git a/src/packet.ts b/src/packet.ts index 8586285..2a87ce9 100644 --- a/src/packet.ts +++ b/src/packet.ts @@ -1,104 +1,90 @@ import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' import type { Secret } from './header' -import { HEADER_TYPES, Header } from './header' -import { Authentication } from './authentication' -import { isOdd } from './helpers' -import { notImplemented } from './utils' +import { Header } from './header' +import type { ToBuffer, ToHumanReadable } from './types' -export class Packet { +function getPseudoPad(header: Header, dataLength: number, sharedSecret: Secret) { + const iterations = Math.floor(dataLength / 16) + 1 + const length = iterations * 16 + const pseudoPad = Buffer.alloc(length) + + for (let i = 0; i < iterations; i++) { + const sessionIdBuf = Buffer.alloc(4) + sessionIdBuf.writeUInt32BE(header.sessionId, 0) + + const sharedSecretBytes = Buffer.from(sharedSecret!, 'utf8') + + const md5 = createHash('md5') + md5.update(sessionIdBuf) + md5.update(sharedSecretBytes) + md5.update(Buffer.from([header.version, header.seqNo])) + + if (i > 0) { + const preI = i - 1 + md5.update(pseudoPad.subarray(preI * 16, preI * 16 + 16)) + } + + const digest = md5.digest() + digest.copy(pseudoPad, i * 16, 0, 16) + } + + return pseudoPad.subarray(0, dataLength) +} + +function xorPseudoPad(data: Buffer, pseudoPad: Buffer) { + const obfuscated = Buffer.alloc(data.length) + for (let i = 0; i < pseudoPad.length; i++) { + obfuscated[i] = data[i] ^ pseudoPad[i] + } + return obfuscated +} + +export function obfuscate(header: Header, data: Buffer, sharedSecret: Secret) { + const pseudoPad = getPseudoPad(header, data.length, sharedSecret) + return xorPseudoPad(data, pseudoPad) +} + +export class Packet implements ToHumanReadable, ToBuffer { readonly #header: Header readonly #bodyBuffer: Buffer readonly #secret: Secret - constructor(header: Header, bodyBuffer: Buffer, secret: Secret) { + constructor(header: Header, body: Buffer, secret: Secret) { this.#header = header - this.#bodyBuffer = bodyBuffer + this.#bodyBuffer = body this.#secret = secret } - get body() { - if (this.#header.isEncrypted) { - return this.encrypt(this.#bodyBuffer) - } - - return this.#bodyBuffer + toHumanReadable() { + return this.body.toString() } get header() { return this.#header } - toBuffer() { - return Buffer.concat([this.#header.toBuffer(), this.body]) - } - - static decodePacket(raw: Buffer) { - const headerBuffer = raw.subarray(0, Header.SIZE) - const header = Header.decode(headerBuffer) - - const body = raw.subarray(Header.SIZE, Header.SIZE + header.length) - - let data = body - if (header.isEncrypted) { - // TODO(lwvemike): handle encryption - data = body + get body() { + if (this.#secret !== null) { + return obfuscate(this.#header, this.#bodyBuffer, this.#secret) } - if (header.type === HEADER_TYPES.TAC_PLUS_AUTHEN) { - const shouldAuthContinue = isOdd(header.seqNo) - if (header.seqNo === 1) { - notImplemented('Authentication start') - } - - if (shouldAuthContinue) { - notImplemented('Authentication continue') - } + return this.#bodyBuffer + } - return Authentication.decodeAuthReply(data, header.length) + toBuffer() { + if (this.#secret !== null) { + return Buffer.concat([this.#header.toBuffer(), this.body]) } - if (header.type === HEADER_TYPES.TAC_PLUS_AUTHOR) { - notImplemented('Authorization') - } - else if (header.type === HEADER_TYPES.TAC_PLUS_ACCT) { - notImplemented('Accounting') - } + return Buffer.concat([this.#header.toBuffer(), this.#bodyBuffer]) } - // private encrypt(bodyBuffer: Buffer): Buffer { - // const bodyLength = bodyBuffer.length - - // const unhashed = Buffer.concat([ - // Buffer.alloc(4), - // Buffer.from(this.#secret ?? '', 'utf8'), - // Buffer.alloc(1), - // Buffer.alloc(1), - // ]) - - // unhashed.writeUInt32BE(this.#header.sessionId, 0) - // unhashed.writeUInt8(this.#header.version, 4) - // unhashed.writeUInt8(this.#header.seqNo, 5) - - // let pad = createHash('md5') - // .update(unhashed) - // .digest() - - // while (pad.length < bodyLength) { - // const digested = createHash('md5') - // .update(pad) - // .digest() + static decodePacket(raw: Buffer, secret: Secret) { + const header = Header.decode(raw.subarray(0, Header.LENGTH)) - // pad = Buffer.concat([pad, digested]) - // } + const bodyBuffer = raw.subarray(Header.LENGTH, Header.LENGTH + header.length) - // pad = pad.subarray(0, bodyLength) - - // const packetBody = Buffer.alloc(bodyLength) - - // for (let i = 0; i < bodyLength; i += 1) { - // packetBody[i] = bodyBuffer[i] ^ pad[i] - // } - - // return packetBody - // } + return new Packet(header, bodyBuffer, secret) + } } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..189bf9e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,9 @@ +import type { Buffer } from 'node:buffer' + +export interface ToHumanReadable { + toHumanReadable: () => string +} + +export interface ToBuffer { + toBuffer: () => Buffer +} diff --git a/src/utils.ts b/src/utils.ts index 0006239..38e5548 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,3 +12,25 @@ export function createVersion(majorVersion: number, minorVersion: number): numbe export function notImplemented(part: string): never { throw new Error(`${part} is Not implemented`) } + +export function getNameFromCollectionValue(value: number, collection: Record) { + return Object + .keys(collection) + .find(key => collection[key] === value) +} + +export function createClientSequenceNumberGenerator() { + let seqNo = -1 + + return () => { + return seqNo += 2 + } +} + +export function randomInt(max: number = 4_294_967_295) { + const randomFloat = Math.random() + const scaledNumber = Math.floor(randomFloat * max + 1) + const finalNumber = Math.min(scaledNumber, max) + + return finalNumber +}