diff --git a/src/JsonPackMpint.ts b/src/JsonPackMpint.ts new file mode 100644 index 00000000..a3155d0e --- /dev/null +++ b/src/JsonPackMpint.ts @@ -0,0 +1,115 @@ +/** + * Represents an SSH multiprecision integer (mpint). + * + * An mpint is stored in two's complement format, 8 bits per byte, MSB first. + * According to RFC 4251: + * - Negative numbers have the value 1 as the most significant bit of the first byte + * - If the most significant bit would be set for a positive number, the number MUST be preceded by a zero byte + * - Unnecessary leading bytes with the value 0 or 255 MUST NOT be included + * - The value zero MUST be stored as a string with zero bytes of data + */ +export class JsonPackMpint { + /** + * The raw bytes representing the mpint in two's complement format, MSB first. + */ + public readonly data: Uint8Array; + + constructor(data: Uint8Array) { + this.data = data; + } + + /** + * Create an mpint from a BigInt value. + */ + public static fromBigInt(value: bigint): JsonPackMpint { + if (value === BigInt(0)) { + return new JsonPackMpint(new Uint8Array(0)); + } + + const negative = value < BigInt(0); + const bytes: number[] = []; + + if (negative) { + // For negative numbers, work with two's complement + const absValue = -value; + const bitLength = absValue.toString(2).length; + const byteLength = Math.ceil((bitLength + 1) / 8); // +1 for sign bit + + // Calculate two's complement + const twoComplement = (BigInt(1) << BigInt(byteLength * 8)) + value; + + for (let i = byteLength - 1; i >= 0; i--) { + bytes.push(Number((twoComplement >> BigInt(i * 8)) & BigInt(0xff))); + } + + // Ensure MSB is 1 for negative numbers + while (bytes.length > 0 && bytes[0] === 0xff && bytes.length > 1 && (bytes[1] & 0x80) !== 0) { + bytes.shift(); + } + } else { + // For positive numbers + let tempValue = value; + while (tempValue > BigInt(0)) { + bytes.unshift(Number(tempValue & BigInt(0xff))); + tempValue >>= BigInt(8); + } + + // Add leading zero if MSB is set (to indicate positive number) + if (bytes[0] & 0x80) { + bytes.unshift(0); + } + } + + return new JsonPackMpint(new Uint8Array(bytes)); + } + + /** + * Convert the mpint to a BigInt value. + */ + public toBigInt(): bigint { + if (this.data.length === 0) { + return BigInt(0); + } + + const negative = (this.data[0] & 0x80) !== 0; + + if (negative) { + // Two's complement for negative numbers + let value = BigInt(0); + for (let i = 0; i < this.data.length; i++) { + value = (value << BigInt(8)) | BigInt(this.data[i]); + } + // Convert from two's complement + const bitLength = this.data.length * 8; + return value - (BigInt(1) << BigInt(bitLength)); + } else { + // Positive number + let value = BigInt(0); + for (let i = 0; i < this.data.length; i++) { + value = (value << BigInt(8)) | BigInt(this.data[i]); + } + return value; + } + } + + /** + * Create an mpint from a number (limited to safe integer range). + */ + public static fromNumber(value: number): JsonPackMpint { + if (!Number.isInteger(value)) { + throw new Error('Value must be an integer'); + } + return JsonPackMpint.fromBigInt(BigInt(value)); + } + + /** + * Convert the mpint to a number (throws if out of safe integer range). + */ + public toNumber(): number { + const bigIntValue = this.toBigInt(); + if (bigIntValue > BigInt(Number.MAX_SAFE_INTEGER) || bigIntValue < BigInt(Number.MIN_SAFE_INTEGER)) { + throw new Error('Value is outside safe integer range'); + } + return Number(bigIntValue); + } +} diff --git a/src/__tests__/JsonPackMpint.spec.ts b/src/__tests__/JsonPackMpint.spec.ts new file mode 100644 index 00000000..bfb2fe62 --- /dev/null +++ b/src/__tests__/JsonPackMpint.spec.ts @@ -0,0 +1,97 @@ +import {JsonPackMpint} from '../JsonPackMpint'; + +describe('JsonPackMpint', () => { + describe('fromBigInt / toBigInt', () => { + test('encodes zero', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(0)); + expect(mpint.data.length).toBe(0); + expect(mpint.toBigInt()).toBe(BigInt(0)); + }); + + test('encodes positive number 0x9a378f9b2e332a7', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt('0x9a378f9b2e332a7')); + expect(mpint.data).toEqual(new Uint8Array([0x09, 0xa3, 0x78, 0xf9, 0xb2, 0xe3, 0x32, 0xa7])); + expect(mpint.toBigInt()).toBe(BigInt('0x9a378f9b2e332a7')); + }); + + test('encodes 0x80 with leading zero', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(0x80)); + expect(mpint.data).toEqual(new Uint8Array([0x00, 0x80])); + expect(mpint.toBigInt()).toBe(BigInt(0x80)); + }); + + test('encodes -1234', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(-1234)); + expect(mpint.data).toEqual(new Uint8Array([0xfb, 0x2e])); + expect(mpint.toBigInt()).toBe(BigInt(-1234)); + }); + + test('encodes -0xdeadbeef', () => { + const mpint = JsonPackMpint.fromBigInt(-BigInt('0xdeadbeef')); + expect(mpint.data).toEqual(new Uint8Array([0xff, 0x21, 0x52, 0x41, 0x11])); + expect(mpint.toBigInt()).toBe(-BigInt('0xdeadbeef')); + }); + + test('encodes small positive number', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(1)); + expect(mpint.data).toEqual(new Uint8Array([0x01])); + expect(mpint.toBigInt()).toBe(BigInt(1)); + }); + + test('encodes small negative number', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(-1)); + expect(mpint.data).toEqual(new Uint8Array([0xff])); + expect(mpint.toBigInt()).toBe(BigInt(-1)); + }); + + test('encodes 127 (no leading zero needed)', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(127)); + expect(mpint.data).toEqual(new Uint8Array([0x7f])); + expect(mpint.toBigInt()).toBe(BigInt(127)); + }); + + test('encodes 128 (leading zero needed)', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(128)); + expect(mpint.data).toEqual(new Uint8Array([0x00, 0x80])); + expect(mpint.toBigInt()).toBe(BigInt(128)); + }); + + test('encodes -128', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(-128)); + expect(mpint.data).toEqual(new Uint8Array([0x80])); + expect(mpint.toBigInt()).toBe(BigInt(-128)); + }); + + test('encodes -129', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(-129)); + expect(mpint.data).toEqual(new Uint8Array([0xff, 0x7f])); + expect(mpint.toBigInt()).toBe(BigInt(-129)); + }); + }); + + describe('fromNumber / toNumber', () => { + test('converts positive number', () => { + const mpint = JsonPackMpint.fromNumber(42); + expect(mpint.toNumber()).toBe(42); + }); + + test('converts negative number', () => { + const mpint = JsonPackMpint.fromNumber(-42); + expect(mpint.toNumber()).toBe(-42); + }); + + test('converts zero', () => { + const mpint = JsonPackMpint.fromNumber(0); + expect(mpint.toNumber()).toBe(0); + }); + + test('throws on non-integer', () => { + expect(() => JsonPackMpint.fromNumber(3.14)).toThrow('Value must be an integer'); + }); + + test('throws when out of safe integer range', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)); + expect(() => mpint.toNumber()).toThrow('Value is outside safe integer range'); + }); + }); +}); diff --git a/src/ssh/SshDecoder.ts b/src/ssh/SshDecoder.ts new file mode 100644 index 00000000..5865f1c7 --- /dev/null +++ b/src/ssh/SshDecoder.ts @@ -0,0 +1,156 @@ +import {Reader} from '@jsonjoy.com/buffers/lib/Reader'; +import type {IReader, IReaderResettable} from '@jsonjoy.com/buffers/lib'; +import type {BinaryJsonDecoder} from '../types'; +import {JsonPackMpint} from '../JsonPackMpint'; + +/** + * SSH 2.0 binary decoder for SSH protocol data types. + * Implements SSH binary decoding according to RFC 4251. + * + * Key SSH decoding principles: + * - Multi-byte quantities are transmitted in big-endian byte order (network byte order) + * - Strings are length-prefixed with uint32 + * - No padding is used (unlike XDR) + */ +export class SshDecoder + implements BinaryJsonDecoder +{ + public constructor(public reader: R = new Reader() as any) {} + + public read(uint8: Uint8Array): unknown { + this.reader.reset(uint8); + return this.readAny(); + } + + public decode(uint8: Uint8Array): unknown { + this.reader.reset(uint8); + return this.readAny(); + } + + public readAny(): unknown { + // Basic implementation - in practice this would need schema info + // For now, we'll throw as this should be used with explicit type methods + throw new Error('SshDecoder.readAny() requires explicit type methods'); + } + + /** + * Reads an SSH boolean value as a single byte. + * Returns true for non-zero values, false for zero. + */ + public readBoolean(): boolean { + return this.reader.u8() !== 0; + } + + /** + * Reads an SSH byte value (8-bit). + */ + public readByte(): number { + return this.reader.u8(); + } + + /** + * Reads an SSH uint32 value in big-endian format. + */ + public readUint32(): number { + const reader = this.reader; + const value = reader.view.getUint32(reader.x, false); // false = big-endian + reader.x += 4; + return value; + } + + /** + * Reads an SSH uint64 value in big-endian format. + */ + public readUint64(): bigint { + const reader = this.reader; + const value = reader.view.getBigUint64(reader.x, false); // false = big-endian + reader.x += 8; + return value; + } + + /** + * Reads an SSH string as binary data (Uint8Array). + * Format: uint32 length + data bytes (no padding). + */ + public readBinStr(): Uint8Array { + const length = this.readUint32(); + const reader = this.reader; + const data = new Uint8Array(length); + + for (let i = 0; i < length; i++) { + data[i] = reader.u8(); + } + + return data; + } + + /** + * Reads an SSH string with UTF-8 encoding. + * Format: uint32 length + UTF-8 bytes (no padding). + */ + public readStr(): string { + const length = this.readUint32(); + const reader = this.reader; + + // Read UTF-8 bytes + const utf8Bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + utf8Bytes[i] = reader.u8(); + } + + // Decode UTF-8 to string + return new TextDecoder('utf-8').decode(utf8Bytes); + } + + /** + * Reads an SSH string with ASCII encoding. + * Format: uint32 length + ASCII bytes (no padding). + */ + public readAsciiStr(): string { + const length = this.readUint32(); + const reader = this.reader; + let str = ''; + + for (let i = 0; i < length; i++) { + str += String.fromCharCode(reader.u8()); + } + + return str; + } + + /** + * Reads an SSH mpint (multiple precision integer). + * Format: uint32 length + data bytes in two's complement format, MSB first. + */ + public readMpint(): JsonPackMpint { + const length = this.readUint32(); + const reader = this.reader; + const data = new Uint8Array(length); + + for (let i = 0; i < length; i++) { + data[i] = reader.u8(); + } + + return new JsonPackMpint(data); + } + + /** + * Reads an SSH name-list. + * Format: uint32 length + comma-separated names. + * Returns an array of name strings. + */ + public readNameList(): string[] { + const nameListStr = this.readAsciiStr(); + if (nameListStr === '') { + return []; + } + return nameListStr.split(','); + } + + /** + * Reads binary data as SSH string (alias for readBinStr) + */ + public readBin(): Uint8Array { + return this.readBinStr(); + } +} diff --git a/src/ssh/SshEncoder.ts b/src/ssh/SshEncoder.ts new file mode 100644 index 00000000..376062e1 --- /dev/null +++ b/src/ssh/SshEncoder.ts @@ -0,0 +1,235 @@ +import type {IWriter, IWriterGrowable} from '@jsonjoy.com/buffers/lib'; +import type {BinaryJsonEncoder} from '../types'; +import {JsonPackMpint} from '../JsonPackMpint'; + +/** + * SSH 2.0 binary encoder for SSH protocol data types. + * Implements SSH binary encoding according to RFC 4251. + * + * Key SSH encoding principles: + * - Multi-byte quantities are transmitted in big-endian byte order (network byte order) + * - Strings are length-prefixed with uint32 + * - No padding is used (unlike XDR) + */ +export class SshEncoder implements BinaryJsonEncoder { + constructor(public readonly writer: IWriter & IWriterGrowable) {} + + public encode(value: unknown): Uint8Array { + const writer = this.writer; + writer.reset(); + this.writeAny(value); + return writer.flush(); + } + + /** + * Called when the encoder encounters a value that it does not know how to encode. + */ + public writeUnknown(value: unknown): void { + throw new Error('SSH encoder does not support unknown types'); + } + + public writeAny(value: unknown): void { + switch (typeof value) { + case 'boolean': + return this.writeBoolean(value); + case 'number': + return this.writeNumber(value); + case 'string': + return this.writeStr(value); + case 'object': { + if (value === null) return this.writeNull(); + const constructor = value.constructor; + switch (constructor) { + case Uint8Array: + return this.writeBin(value as Uint8Array); + case Array: + return this.writeNameList(value as string[]); + case JsonPackMpint: + return this.writeMpint(value as JsonPackMpint); + default: + return this.writeUnknown(value); + } + } + case 'bigint': + return this.writeUint64(value); + case 'undefined': + return this.writeNull(); + default: + return this.writeUnknown(value); + } + } + + /** + * SSH doesn't have a null type, but we provide it for interface compatibility. + */ + public writeNull(): void { + throw new Error('SSH protocol does not have a null type'); + } + + /** + * Writes an SSH boolean value as a single byte. + * The value 0 represents FALSE, and the value 1 represents TRUE. + */ + public writeBoolean(bool: boolean): void { + this.writer.u8(bool ? 1 : 0); + } + + /** + * Writes an SSH byte value (8-bit). + */ + public writeByte(byte: number): void { + this.writer.u8(byte & 0xff); + } + + /** + * Writes an SSH uint32 value in big-endian format. + */ + public writeUint32(uint: number): void { + const writer = this.writer; + writer.ensureCapacity(4); + writer.view.setUint32(writer.x, Math.trunc(uint) >>> 0, false); // big-endian + writer.move(4); + } + + /** + * Writes an SSH uint64 value in big-endian format. + */ + public writeUint64(uint: number | bigint): void { + const writer = this.writer; + writer.ensureCapacity(8); + + if (typeof uint === 'bigint') { + writer.view.setBigUint64(writer.x, uint, false); // big-endian + } else { + const truncated = Math.trunc(Math.abs(uint)); + const high = Math.floor(truncated / 0x100000000); + const low = truncated >>> 0; + writer.view.setUint32(writer.x, high, false); // high 32 bits + writer.view.setUint32(writer.x + 4, low, false); // low 32 bits + } + writer.move(8); + } + + /** + * Writes an SSH string as binary data (Uint8Array). + * Format: uint32 length + data bytes (no padding). + */ + public writeBinStr(data: Uint8Array): void { + this.writeUint32(data.length); + this.writer.buf(data, data.length); + } + + /** + * Writes an SSH string with UTF-8 encoding. + * Format: uint32 length + UTF-8 bytes (no padding). + */ + public writeStr(str: string): void { + const writer = this.writer; + const maxSize = str.length * 4; // Max UTF-8 bytes for string + writer.ensureCapacity(4 + maxSize); + + // Reserve space for length + const lengthOffset = writer.x; + writer.x += 4; + + // Write the string and get actual byte count + const bytesWritten = writer.utf8(str); + + // Go back to encode the actual length + const endPos = writer.x; + writer.x = lengthOffset; + this.writeUint32(bytesWritten); + writer.x = endPos; + } + + /** + * Writes an SSH string with ASCII encoding. + * Format: uint32 length + ASCII bytes (no padding). + */ + public writeAsciiStr(str: string): void { + const writer = this.writer; + writer.ensureCapacity(4 + str.length); + + this.writeUint32(str.length); + for (let i = 0; i < str.length; i++) { + writer.u8(str.charCodeAt(i) & 0x7f); // ASCII only + } + } + + /** + * Writes an SSH mpint (multiple precision integer). + * Format: uint32 length + data bytes in two's complement format, MSB first. + */ + public writeMpint(mpint: JsonPackMpint): void { + this.writeUint32(mpint.data.length); + this.writer.buf(mpint.data, mpint.data.length); + } + + /** + * Writes an SSH name-list. + * Format: uint32 length + comma-separated names. + */ + public writeNameList(names: string[]): void { + const nameListStr = names.join(','); + this.writeAsciiStr(nameListStr); + } + + // BinaryJsonEncoder interface methods + + /** + * Generic number writing - writes as uint32 by default + */ + public writeNumber(num: number): void { + if (Number.isInteger(num)) { + if (num >= 0 && num <= 0xffffffff) { + this.writeUint32(num); + } else { + this.writeUint64(num); + } + } else { + throw new Error('SSH protocol does not support floating point numbers'); + } + } + + /** + * Writes an integer value as uint32 + */ + public writeInteger(int: number): void { + this.writeUint32(int); + } + + /** + * Writes an unsigned integer value as uint32 + */ + public writeUInteger(uint: number): void { + this.writeUint32(uint); + } + + /** + * Writes a float value - SSH doesn't support floats + */ + public writeFloat(float: number): void { + throw new Error('SSH protocol does not support floating point numbers'); + } + + /** + * Writes binary data as SSH string + */ + public writeBin(buf: Uint8Array): void { + this.writeBinStr(buf); + } + + /** + * Writes arrays - not supported in base SSH protocol + */ + public writeArr(arr: unknown[]): void { + throw new Error('SSH protocol does not have a generic array type. Use writeNameList for name-list type.'); + } + + /** + * Writes objects - not supported in base SSH protocol + */ + public writeObj(obj: Record): void { + throw new Error('SSH protocol does not have an object type'); + } +} diff --git a/src/ssh/__tests__/SshDecoder.spec.ts b/src/ssh/__tests__/SshDecoder.spec.ts new file mode 100644 index 00000000..d2ae2b9a --- /dev/null +++ b/src/ssh/__tests__/SshDecoder.spec.ts @@ -0,0 +1,266 @@ +import {Reader} from '@jsonjoy.com/buffers/lib/Reader'; +import {SshDecoder} from '../SshDecoder'; +import {JsonPackMpint} from '../../JsonPackMpint'; + +describe('SshDecoder', () => { + let reader: Reader; + let decoder: SshDecoder; + + beforeEach(() => { + reader = new Reader(); + decoder = new SshDecoder(reader); + }); + + describe('primitive types', () => { + test('decodes boolean true', () => { + reader.reset(new Uint8Array([1])); + expect(decoder.readBoolean()).toBe(true); + }); + + test('decodes boolean false', () => { + reader.reset(new Uint8Array([0])); + expect(decoder.readBoolean()).toBe(false); + }); + + test('decodes non-zero as true', () => { + reader.reset(new Uint8Array([42])); + expect(decoder.readBoolean()).toBe(true); + }); + + test('decodes byte value', () => { + reader.reset(new Uint8Array([0x42])); + expect(decoder.readByte()).toBe(0x42); + }); + + test('decodes uint32', () => { + reader.reset(new Uint8Array([0x12, 0x34, 0x56, 0x78])); + expect(decoder.readUint32()).toBe(0x12345678); + }); + + test('decodes uint32 zero', () => { + reader.reset(new Uint8Array([0, 0, 0, 0])); + expect(decoder.readUint32()).toBe(0); + }); + + test('decodes uint32 max value', () => { + reader.reset(new Uint8Array([0xff, 0xff, 0xff, 0xff])); + expect(decoder.readUint32()).toBe(0xffffffff); + }); + + test('decodes uint64', () => { + reader.reset(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); + expect(decoder.readUint64()).toBe(BigInt('0x123456789ABCDEF0')); + }); + + test('decodes uint64 zero', () => { + reader.reset(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])); + expect(decoder.readUint64()).toBe(BigInt(0)); + }); + }); + + describe('string types', () => { + test('decodes empty string (UTF-8)', () => { + reader.reset(new Uint8Array([0, 0, 0, 0])); + expect(decoder.readStr()).toBe(''); + }); + + test('decodes ASCII string "testing" (UTF-8)', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 7, // length + 0x74, + 0x65, + 0x73, + 0x74, + 0x69, + 0x6e, + 0x67, // "testing" + ]); + reader.reset(data); + expect(decoder.readStr()).toBe('testing'); + }); + + test('decodes ASCII string', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 4, // length + 0x74, + 0x65, + 0x73, + 0x74, // "test" + ]); + reader.reset(data); + expect(decoder.readAsciiStr()).toBe('test'); + }); + + test('decodes binary string', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 3, // length + 0x01, + 0x02, + 0x03, + ]); + reader.reset(data); + const result = decoder.readBinStr(); + expect(result).toEqual(new Uint8Array([0x01, 0x02, 0x03])); + }); + + test('decodes empty binary string', () => { + reader.reset(new Uint8Array([0, 0, 0, 0])); + const result = decoder.readBinStr(); + expect(result).toEqual(new Uint8Array(0)); + }); + + test('readBin is alias for readBinStr', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 3, // length + 0x01, + 0x02, + 0x03, + ]); + reader.reset(data); + const result = decoder.readBin(); + expect(result).toEqual(new Uint8Array([0x01, 0x02, 0x03])); + }); + }); + + describe('mpint', () => { + test('decodes mpint zero', () => { + reader.reset(new Uint8Array([0, 0, 0, 0])); + const mpint = decoder.readMpint(); + expect(mpint.data.length).toBe(0); + expect(mpint.toBigInt()).toBe(BigInt(0)); + }); + + test('decodes mpint 0x9a378f9b2e332a7', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 8, // length + 0x09, + 0xa3, + 0x78, + 0xf9, + 0xb2, + 0xe3, + 0x32, + 0xa7, + ]); + reader.reset(data); + const mpint = decoder.readMpint(); + expect(mpint.toBigInt()).toBe(BigInt('0x9a378f9b2e332a7')); + }); + + test('decodes mpint 0x80', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 2, // length + 0x00, + 0x80, + ]); + reader.reset(data); + const mpint = decoder.readMpint(); + expect(mpint.toBigInt()).toBe(BigInt(0x80)); + }); + + test('decodes mpint -1234', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 2, // length + 0xfb, + 0x2e, + ]); + reader.reset(data); + const mpint = decoder.readMpint(); + expect(mpint.toBigInt()).toBe(BigInt(-1234)); + }); + + test('decodes mpint -0xdeadbeef', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 5, // length + 0xff, + 0x21, + 0x52, + 0x41, + 0x11, + ]); + reader.reset(data); + const mpint = decoder.readMpint(); + expect(mpint.toBigInt()).toBe(-BigInt('0xdeadbeef')); + }); + }); + + describe('name-list', () => { + test('decodes empty name-list', () => { + reader.reset(new Uint8Array([0, 0, 0, 0])); + expect(decoder.readNameList()).toEqual([]); + }); + + test('decodes single name "zlib"', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 4, // length + 0x7a, + 0x6c, + 0x69, + 0x62, // "zlib" + ]); + reader.reset(data); + expect(decoder.readNameList()).toEqual(['zlib']); + }); + + test('decodes name-list "zlib,none"', () => { + const data = new Uint8Array([ + 0, + 0, + 0, + 9, // length + 0x7a, + 0x6c, + 0x69, + 0x62, + 0x2c, + 0x6e, + 0x6f, + 0x6e, + 0x65, // "zlib,none" + ]); + reader.reset(data); + expect(decoder.readNameList()).toEqual(['zlib', 'none']); + }); + + test('decodes name-list with three items', () => { + const nameList = 'one,two,three'; + const bytes = new TextEncoder().encode(nameList); + const data = new Uint8Array(4 + bytes.length); + data[0] = 0; + data[1] = 0; + data[2] = 0; + data[3] = bytes.length; + data.set(bytes, 4); + + reader.reset(data); + expect(decoder.readNameList()).toEqual(['one', 'two', 'three']); + }); + }); +}); diff --git a/src/ssh/__tests__/SshEncoder.spec.ts b/src/ssh/__tests__/SshEncoder.spec.ts new file mode 100644 index 00000000..501b9c48 --- /dev/null +++ b/src/ssh/__tests__/SshEncoder.spec.ts @@ -0,0 +1,319 @@ +import {Writer} from '@jsonjoy.com/buffers/lib/Writer'; +import {SshEncoder} from '../SshEncoder'; +import {JsonPackMpint} from '../../JsonPackMpint'; + +describe('SshEncoder', () => { + let writer: Writer; + let encoder: SshEncoder; + + beforeEach(() => { + writer = new Writer(); + encoder = new SshEncoder(writer); + }); + + describe('primitive types', () => { + test('encodes boolean true', () => { + encoder.writeBoolean(true); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([1])); + }); + + test('encodes boolean false', () => { + encoder.writeBoolean(false); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0])); + }); + + test('encodes byte value', () => { + encoder.writeByte(0x42); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0x42])); + }); + + test('encodes uint32', () => { + encoder.writeUint32(0x12345678); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78])); + }); + + test('encodes uint32 zero', () => { + encoder.writeUint32(0); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); + }); + + test('encodes uint32 max value', () => { + encoder.writeUint32(0xffffffff); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0xff, 0xff, 0xff, 0xff])); + }); + + test('encodes uint64 from bigint', () => { + encoder.writeUint64(BigInt('0x123456789ABCDEF0')); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); + }); + + test('encodes uint64 from number', () => { + encoder.writeUint64(0x12345678); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0, 0x12, 0x34, 0x56, 0x78])); + }); + + test('encodes uint64 zero', () => { + encoder.writeUint64(BigInt(0)); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])); + }); + }); + + describe('string types', () => { + test('encodes empty string (UTF-8)', () => { + encoder.writeStr(''); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); + }); + + test('encodes ASCII string "testing" (UTF-8)', () => { + encoder.writeStr('testing'); + const result = writer.flush(); + const expected = new Uint8Array([ + 0, + 0, + 0, + 7, // length + 0x74, + 0x65, + 0x73, + 0x74, + 0x69, + 0x6e, + 0x67, // "testing" + ]); + expect(result).toEqual(expected); + }); + + test('encodes UTF-8 string', () => { + encoder.writeStr('hello'); + const result = writer.flush(); + expect(result[0]).toBe(0); + expect(result[1]).toBe(0); + expect(result[2]).toBe(0); + expect(result[3]).toBe(5); // length + expect(result.slice(4)).toEqual(new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f])); + }); + + test('encodes ASCII string', () => { + encoder.writeAsciiStr('test'); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 4, // length + 0x74, + 0x65, + 0x73, + 0x74, // "test" + ]), + ); + }); + + test('encodes binary string', () => { + const data = new Uint8Array([0x01, 0x02, 0x03]); + encoder.writeBinStr(data); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 3, // length + 0x01, + 0x02, + 0x03, + ]), + ); + }); + + test('encodes empty binary string', () => { + encoder.writeBinStr(new Uint8Array(0)); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); + }); + }); + + describe('mpint', () => { + test('encodes mpint zero', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(0)); + encoder.writeMpint(mpint); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); + }); + + test('encodes mpint 0x9a378f9b2e332a7', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt('0x9a378f9b2e332a7')); + encoder.writeMpint(mpint); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 8, // length + 0x09, + 0xa3, + 0x78, + 0xf9, + 0xb2, + 0xe3, + 0x32, + 0xa7, + ]), + ); + }); + + test('encodes mpint 0x80', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(0x80)); + encoder.writeMpint(mpint); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 2, // length + 0x00, + 0x80, + ]), + ); + }); + + test('encodes mpint -1234', () => { + const mpint = JsonPackMpint.fromBigInt(BigInt(-1234)); + encoder.writeMpint(mpint); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 2, // length + 0xfb, + 0x2e, + ]), + ); + }); + + test('encodes mpint -0xdeadbeef', () => { + const mpint = JsonPackMpint.fromBigInt(-BigInt('0xdeadbeef')); + encoder.writeMpint(mpint); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 5, // length + 0xff, + 0x21, + 0x52, + 0x41, + 0x11, + ]), + ); + }); + }); + + describe('name-list', () => { + test('encodes empty name-list', () => { + encoder.writeNameList([]); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); + }); + + test('encodes single name "zlib"', () => { + encoder.writeNameList(['zlib']); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 4, // length + 0x7a, + 0x6c, + 0x69, + 0x62, // "zlib" + ]), + ); + }); + + test('encodes name-list "zlib,none"', () => { + encoder.writeNameList(['zlib', 'none']); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 9, // length + 0x7a, + 0x6c, + 0x69, + 0x62, + 0x2c, + 0x6e, + 0x6f, + 0x6e, + 0x65, // "zlib,none" + ]), + ); + }); + + test('encodes name-list with three items', () => { + encoder.writeNameList(['one', 'two', 'three']); + const result = writer.flush(); + const str = new TextDecoder().decode(result.slice(4)); + expect(str).toBe('one,two,three'); + }); + }); + + describe('BinaryJsonEncoder interface', () => { + test('encodes integer', () => { + encoder.writeInteger(42); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + }); + + test('encodes unsigned integer', () => { + encoder.writeUInteger(42); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + }); + + test('encodes binary data', () => { + const data = new Uint8Array([1, 2, 3]); + encoder.writeBin(data); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 3, 1, 2, 3])); + }); + + test('throws on float', () => { + expect(() => encoder.writeFloat(3.14)).toThrow('SSH protocol does not support floating point numbers'); + }); + + test('throws on null', () => { + expect(() => encoder.writeNull()).toThrow('SSH protocol does not have a null type'); + }); + + test('throws on array', () => { + expect(() => encoder.writeArr([1, 2, 3])).toThrow('SSH protocol does not have a generic array type'); + }); + + test('throws on object', () => { + expect(() => encoder.writeObj({key: 'value'})).toThrow('SSH protocol does not have an object type'); + }); + }); +}); diff --git a/src/ssh/__tests__/codec.spec.ts b/src/ssh/__tests__/codec.spec.ts new file mode 100644 index 00000000..c86af3dd --- /dev/null +++ b/src/ssh/__tests__/codec.spec.ts @@ -0,0 +1,238 @@ +import {Writer} from '@jsonjoy.com/buffers/lib/Writer'; +import {Reader} from '@jsonjoy.com/buffers/lib/Reader'; +import {SshEncoder} from '../SshEncoder'; +import {SshDecoder} from '../SshDecoder'; +import {JsonPackMpint} from '../../JsonPackMpint'; + +describe('SSH codec round-trip', () => { + let writer: Writer; + let reader: Reader; + let encoder: SshEncoder; + let decoder: SshDecoder; + + beforeEach(() => { + writer = new Writer(); + reader = new Reader(); + encoder = new SshEncoder(writer); + decoder = new SshDecoder(reader); + }); + + describe('boolean', () => { + test('round-trips true', () => { + encoder.writeBoolean(true); + const encoded = writer.flush(); + reader.reset(encoded); + expect(decoder.readBoolean()).toBe(true); + }); + + test('round-trips false', () => { + encoder.writeBoolean(false); + const encoded = writer.flush(); + reader.reset(encoded); + expect(decoder.readBoolean()).toBe(false); + }); + }); + + describe('byte', () => { + test('round-trips byte values', () => { + encoder.writeByte(0); + encoder.writeByte(127); + encoder.writeByte(255); + const encoded = writer.flush(); + reader.reset(encoded); + expect(decoder.readByte()).toBe(0); + expect(decoder.readByte()).toBe(127); + expect(decoder.readByte()).toBe(255); + }); + }); + + describe('uint32', () => { + test('round-trips various uint32 values', () => { + const values = [0, 1, 127, 128, 255, 256, 65535, 65536, 0xffffffff]; + for (const value of values) { + encoder.writeUint32(value); + } + const encoded = writer.flush(); + reader.reset(encoded); + for (const value of values) { + expect(decoder.readUint32()).toBe(value); + } + }); + }); + + describe('uint64', () => { + test('round-trips various uint64 values', () => { + const values = [ + BigInt(0), + BigInt(1), + BigInt(127), + BigInt(128), + BigInt(255), + BigInt(256), + BigInt('0xFFFFFFFF'), + BigInt('0x123456789ABCDEF'), + ]; + for (const value of values) { + encoder.writeUint64(value); + } + const encoded = writer.flush(); + reader.reset(encoded); + for (const value of values) { + expect(decoder.readUint64()).toBe(value); + } + }); + }); + + describe('strings', () => { + test('round-trips UTF-8 strings', () => { + const strings = ['', 'hello', 'testing', 'Hello, World!', '🎉']; + for (const str of strings) { + encoder.writeStr(str); + } + const encoded = writer.flush(); + reader.reset(encoded); + for (const str of strings) { + expect(decoder.readStr()).toBe(str); + } + }); + + test('round-trips ASCII strings', () => { + const strings = ['', 'hello', 'testing', 'ABC123']; + for (const str of strings) { + encoder.writeAsciiStr(str); + } + const encoded = writer.flush(); + reader.reset(encoded); + for (const str of strings) { + expect(decoder.readAsciiStr()).toBe(str); + } + }); + + test('round-trips binary strings', () => { + const binaries = [ + new Uint8Array([]), + new Uint8Array([0]), + new Uint8Array([1, 2, 3, 4, 5]), + new Uint8Array([0xff, 0xfe, 0xfd]), + ]; + for (const bin of binaries) { + encoder.writeBinStr(bin); + } + const encoded = writer.flush(); + reader.reset(encoded); + for (const bin of binaries) { + expect(decoder.readBinStr()).toEqual(bin); + } + }); + }); + + describe('mpint', () => { + test('round-trips various mpint values', () => { + const values = [ + BigInt(0), + BigInt(1), + BigInt(-1), + BigInt(127), + BigInt(128), + BigInt(-128), + BigInt(-129), + BigInt(0x80), + BigInt(-1234), + BigInt('0x9a378f9b2e332a7'), + -BigInt('0xdeadbeef'), + ]; + for (const value of values) { + const mpint = JsonPackMpint.fromBigInt(value); + encoder.writeMpint(mpint); + } + const encoded = writer.flush(); + reader.reset(encoded); + for (const value of values) { + const decoded = decoder.readMpint(); + expect(decoded.toBigInt()).toBe(value); + } + }); + }); + + describe('name-list', () => { + test('round-trips various name-lists', () => { + const nameLists = [ + [], + ['zlib'], + ['zlib', 'none'], + ['one', 'two', 'three'], + ['algorithm1', 'algorithm2', 'algorithm3'], + ]; + for (const nameList of nameLists) { + encoder.writeNameList(nameList); + } + const encoded = writer.flush(); + reader.reset(encoded); + for (const nameList of nameLists) { + expect(decoder.readNameList()).toEqual(nameList); + } + }); + }); + + describe('complex scenarios', () => { + test('round-trips mixed data types', () => { + // Encode + encoder.writeBoolean(true); + encoder.writeUint32(42); + encoder.writeStr('hello'); + encoder.writeNameList(['one', 'two']); + encoder.writeUint64(BigInt(123456789)); + const mpint = JsonPackMpint.fromBigInt(BigInt(-1234)); + encoder.writeMpint(mpint); + encoder.writeBinStr(new Uint8Array([1, 2, 3])); + + const encoded = writer.flush(); + reader.reset(encoded); + + // Decode + expect(decoder.readBoolean()).toBe(true); + expect(decoder.readUint32()).toBe(42); + expect(decoder.readStr()).toBe('hello'); + expect(decoder.readNameList()).toEqual(['one', 'two']); + expect(decoder.readUint64()).toBe(BigInt(123456789)); + expect(decoder.readMpint().toBigInt()).toBe(BigInt(-1234)); + expect(decoder.readBinStr()).toEqual(new Uint8Array([1, 2, 3])); + }); + + test('round-trips SSH packet-like structure', () => { + // Simulating an SSH key exchange packet + encoder.writeByte(20); // SSH_MSG_KEXINIT + encoder.writeBinStr(new Uint8Array(16).fill(0x42)); // cookie + encoder.writeNameList(['diffie-hellman-group14-sha1']); + encoder.writeNameList(['ssh-rsa']); + encoder.writeNameList(['aes128-ctr']); + encoder.writeNameList(['aes128-ctr']); + encoder.writeNameList(['hmac-sha1']); + encoder.writeNameList(['hmac-sha1']); + encoder.writeNameList(['none']); + encoder.writeNameList(['none']); + encoder.writeNameList([]); + encoder.writeNameList([]); + encoder.writeBoolean(false); + encoder.writeUint32(0); + + const encoded = writer.flush(); + reader.reset(encoded); + + expect(decoder.readByte()).toBe(20); + expect(decoder.readBinStr()).toEqual(new Uint8Array(16).fill(0x42)); + expect(decoder.readNameList()).toEqual(['diffie-hellman-group14-sha1']); + expect(decoder.readNameList()).toEqual(['ssh-rsa']); + expect(decoder.readNameList()).toEqual(['aes128-ctr']); + expect(decoder.readNameList()).toEqual(['aes128-ctr']); + expect(decoder.readNameList()).toEqual(['hmac-sha1']); + expect(decoder.readNameList()).toEqual(['hmac-sha1']); + expect(decoder.readNameList()).toEqual(['none']); + expect(decoder.readNameList()).toEqual(['none']); + expect(decoder.readNameList()).toEqual([]); + expect(decoder.readNameList()).toEqual([]); + expect(decoder.readBoolean()).toBe(false); + expect(decoder.readUint32()).toBe(0); + }); + }); +}); diff --git a/src/ssh/index.ts b/src/ssh/index.ts new file mode 100644 index 00000000..7a65ac96 --- /dev/null +++ b/src/ssh/index.ts @@ -0,0 +1,9 @@ +/** + * SSH 2.0 Protocol module + * + * This module provides TypeScript encoder and decoder implementations + * for SSH 2.0 protocol data types based on RFC 4251 specification. + */ + +export * from './SshEncoder'; +export * from './SshDecoder';