Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/JsonPackMpint.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
97 changes: 97 additions & 0 deletions src/__tests__/JsonPackMpint.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
156 changes: 156 additions & 0 deletions src/ssh/SshDecoder.ts
Original file line number Diff line number Diff line change
@@ -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<R extends IReader & IReaderResettable = IReader & IReaderResettable>
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();
}
}
Loading