Skip to content

Commit b01e41f

Browse files
authored
Merge pull request #58 from jsonjoy-com/copilot/fix-b7ecd981-de84-4d8c-8f54-27514466ca3d
feat: Add SSH 2.0 data type codec with encoder and decoder
2 parents 0ab7527 + baf77a9 commit b01e41f

File tree

8 files changed

+1435
-0
lines changed

8 files changed

+1435
-0
lines changed

src/JsonPackMpint.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Represents an SSH multiprecision integer (mpint).
3+
*
4+
* An mpint is stored in two's complement format, 8 bits per byte, MSB first.
5+
* According to RFC 4251:
6+
* - Negative numbers have the value 1 as the most significant bit of the first byte
7+
* - If the most significant bit would be set for a positive number, the number MUST be preceded by a zero byte
8+
* - Unnecessary leading bytes with the value 0 or 255 MUST NOT be included
9+
* - The value zero MUST be stored as a string with zero bytes of data
10+
*/
11+
export class JsonPackMpint {
12+
/**
13+
* The raw bytes representing the mpint in two's complement format, MSB first.
14+
*/
15+
public readonly data: Uint8Array;
16+
17+
constructor(data: Uint8Array) {
18+
this.data = data;
19+
}
20+
21+
/**
22+
* Create an mpint from a BigInt value.
23+
*/
24+
public static fromBigInt(value: bigint): JsonPackMpint {
25+
if (value === BigInt(0)) {
26+
return new JsonPackMpint(new Uint8Array(0));
27+
}
28+
29+
const negative = value < BigInt(0);
30+
const bytes: number[] = [];
31+
32+
if (negative) {
33+
// For negative numbers, work with two's complement
34+
const absValue = -value;
35+
const bitLength = absValue.toString(2).length;
36+
const byteLength = Math.ceil((bitLength + 1) / 8); // +1 for sign bit
37+
38+
// Calculate two's complement
39+
const twoComplement = (BigInt(1) << BigInt(byteLength * 8)) + value;
40+
41+
for (let i = byteLength - 1; i >= 0; i--) {
42+
bytes.push(Number((twoComplement >> BigInt(i * 8)) & BigInt(0xff)));
43+
}
44+
45+
// Ensure MSB is 1 for negative numbers
46+
while (bytes.length > 0 && bytes[0] === 0xff && bytes.length > 1 && (bytes[1] & 0x80) !== 0) {
47+
bytes.shift();
48+
}
49+
} else {
50+
// For positive numbers
51+
let tempValue = value;
52+
while (tempValue > BigInt(0)) {
53+
bytes.unshift(Number(tempValue & BigInt(0xff)));
54+
tempValue >>= BigInt(8);
55+
}
56+
57+
// Add leading zero if MSB is set (to indicate positive number)
58+
if (bytes[0] & 0x80) {
59+
bytes.unshift(0);
60+
}
61+
}
62+
63+
return new JsonPackMpint(new Uint8Array(bytes));
64+
}
65+
66+
/**
67+
* Convert the mpint to a BigInt value.
68+
*/
69+
public toBigInt(): bigint {
70+
if (this.data.length === 0) {
71+
return BigInt(0);
72+
}
73+
74+
const negative = (this.data[0] & 0x80) !== 0;
75+
76+
if (negative) {
77+
// Two's complement for negative numbers
78+
let value = BigInt(0);
79+
for (let i = 0; i < this.data.length; i++) {
80+
value = (value << BigInt(8)) | BigInt(this.data[i]);
81+
}
82+
// Convert from two's complement
83+
const bitLength = this.data.length * 8;
84+
return value - (BigInt(1) << BigInt(bitLength));
85+
} else {
86+
// Positive number
87+
let value = BigInt(0);
88+
for (let i = 0; i < this.data.length; i++) {
89+
value = (value << BigInt(8)) | BigInt(this.data[i]);
90+
}
91+
return value;
92+
}
93+
}
94+
95+
/**
96+
* Create an mpint from a number (limited to safe integer range).
97+
*/
98+
public static fromNumber(value: number): JsonPackMpint {
99+
if (!Number.isInteger(value)) {
100+
throw new Error('Value must be an integer');
101+
}
102+
return JsonPackMpint.fromBigInt(BigInt(value));
103+
}
104+
105+
/**
106+
* Convert the mpint to a number (throws if out of safe integer range).
107+
*/
108+
public toNumber(): number {
109+
const bigIntValue = this.toBigInt();
110+
if (bigIntValue > BigInt(Number.MAX_SAFE_INTEGER) || bigIntValue < BigInt(Number.MIN_SAFE_INTEGER)) {
111+
throw new Error('Value is outside safe integer range');
112+
}
113+
return Number(bigIntValue);
114+
}
115+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {JsonPackMpint} from '../JsonPackMpint';
2+
3+
describe('JsonPackMpint', () => {
4+
describe('fromBigInt / toBigInt', () => {
5+
test('encodes zero', () => {
6+
const mpint = JsonPackMpint.fromBigInt(BigInt(0));
7+
expect(mpint.data.length).toBe(0);
8+
expect(mpint.toBigInt()).toBe(BigInt(0));
9+
});
10+
11+
test('encodes positive number 0x9a378f9b2e332a7', () => {
12+
const mpint = JsonPackMpint.fromBigInt(BigInt('0x9a378f9b2e332a7'));
13+
expect(mpint.data).toEqual(new Uint8Array([0x09, 0xa3, 0x78, 0xf9, 0xb2, 0xe3, 0x32, 0xa7]));
14+
expect(mpint.toBigInt()).toBe(BigInt('0x9a378f9b2e332a7'));
15+
});
16+
17+
test('encodes 0x80 with leading zero', () => {
18+
const mpint = JsonPackMpint.fromBigInt(BigInt(0x80));
19+
expect(mpint.data).toEqual(new Uint8Array([0x00, 0x80]));
20+
expect(mpint.toBigInt()).toBe(BigInt(0x80));
21+
});
22+
23+
test('encodes -1234', () => {
24+
const mpint = JsonPackMpint.fromBigInt(BigInt(-1234));
25+
expect(mpint.data).toEqual(new Uint8Array([0xfb, 0x2e]));
26+
expect(mpint.toBigInt()).toBe(BigInt(-1234));
27+
});
28+
29+
test('encodes -0xdeadbeef', () => {
30+
const mpint = JsonPackMpint.fromBigInt(-BigInt('0xdeadbeef'));
31+
expect(mpint.data).toEqual(new Uint8Array([0xff, 0x21, 0x52, 0x41, 0x11]));
32+
expect(mpint.toBigInt()).toBe(-BigInt('0xdeadbeef'));
33+
});
34+
35+
test('encodes small positive number', () => {
36+
const mpint = JsonPackMpint.fromBigInt(BigInt(1));
37+
expect(mpint.data).toEqual(new Uint8Array([0x01]));
38+
expect(mpint.toBigInt()).toBe(BigInt(1));
39+
});
40+
41+
test('encodes small negative number', () => {
42+
const mpint = JsonPackMpint.fromBigInt(BigInt(-1));
43+
expect(mpint.data).toEqual(new Uint8Array([0xff]));
44+
expect(mpint.toBigInt()).toBe(BigInt(-1));
45+
});
46+
47+
test('encodes 127 (no leading zero needed)', () => {
48+
const mpint = JsonPackMpint.fromBigInt(BigInt(127));
49+
expect(mpint.data).toEqual(new Uint8Array([0x7f]));
50+
expect(mpint.toBigInt()).toBe(BigInt(127));
51+
});
52+
53+
test('encodes 128 (leading zero needed)', () => {
54+
const mpint = JsonPackMpint.fromBigInt(BigInt(128));
55+
expect(mpint.data).toEqual(new Uint8Array([0x00, 0x80]));
56+
expect(mpint.toBigInt()).toBe(BigInt(128));
57+
});
58+
59+
test('encodes -128', () => {
60+
const mpint = JsonPackMpint.fromBigInt(BigInt(-128));
61+
expect(mpint.data).toEqual(new Uint8Array([0x80]));
62+
expect(mpint.toBigInt()).toBe(BigInt(-128));
63+
});
64+
65+
test('encodes -129', () => {
66+
const mpint = JsonPackMpint.fromBigInt(BigInt(-129));
67+
expect(mpint.data).toEqual(new Uint8Array([0xff, 0x7f]));
68+
expect(mpint.toBigInt()).toBe(BigInt(-129));
69+
});
70+
});
71+
72+
describe('fromNumber / toNumber', () => {
73+
test('converts positive number', () => {
74+
const mpint = JsonPackMpint.fromNumber(42);
75+
expect(mpint.toNumber()).toBe(42);
76+
});
77+
78+
test('converts negative number', () => {
79+
const mpint = JsonPackMpint.fromNumber(-42);
80+
expect(mpint.toNumber()).toBe(-42);
81+
});
82+
83+
test('converts zero', () => {
84+
const mpint = JsonPackMpint.fromNumber(0);
85+
expect(mpint.toNumber()).toBe(0);
86+
});
87+
88+
test('throws on non-integer', () => {
89+
expect(() => JsonPackMpint.fromNumber(3.14)).toThrow('Value must be an integer');
90+
});
91+
92+
test('throws when out of safe integer range', () => {
93+
const mpint = JsonPackMpint.fromBigInt(BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1));
94+
expect(() => mpint.toNumber()).toThrow('Value is outside safe integer range');
95+
});
96+
});
97+
});

src/ssh/SshDecoder.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {Reader} from '@jsonjoy.com/buffers/lib/Reader';
2+
import type {IReader, IReaderResettable} from '@jsonjoy.com/buffers/lib';
3+
import type {BinaryJsonDecoder} from '../types';
4+
import {JsonPackMpint} from '../JsonPackMpint';
5+
6+
/**
7+
* SSH 2.0 binary decoder for SSH protocol data types.
8+
* Implements SSH binary decoding according to RFC 4251.
9+
*
10+
* Key SSH decoding principles:
11+
* - Multi-byte quantities are transmitted in big-endian byte order (network byte order)
12+
* - Strings are length-prefixed with uint32
13+
* - No padding is used (unlike XDR)
14+
*/
15+
export class SshDecoder<R extends IReader & IReaderResettable = IReader & IReaderResettable>
16+
implements BinaryJsonDecoder
17+
{
18+
public constructor(public reader: R = new Reader() as any) {}
19+
20+
public read(uint8: Uint8Array): unknown {
21+
this.reader.reset(uint8);
22+
return this.readAny();
23+
}
24+
25+
public decode(uint8: Uint8Array): unknown {
26+
this.reader.reset(uint8);
27+
return this.readAny();
28+
}
29+
30+
public readAny(): unknown {
31+
// Basic implementation - in practice this would need schema info
32+
// For now, we'll throw as this should be used with explicit type methods
33+
throw new Error('SshDecoder.readAny() requires explicit type methods');
34+
}
35+
36+
/**
37+
* Reads an SSH boolean value as a single byte.
38+
* Returns true for non-zero values, false for zero.
39+
*/
40+
public readBoolean(): boolean {
41+
return this.reader.u8() !== 0;
42+
}
43+
44+
/**
45+
* Reads an SSH byte value (8-bit).
46+
*/
47+
public readByte(): number {
48+
return this.reader.u8();
49+
}
50+
51+
/**
52+
* Reads an SSH uint32 value in big-endian format.
53+
*/
54+
public readUint32(): number {
55+
const reader = this.reader;
56+
const value = reader.view.getUint32(reader.x, false); // false = big-endian
57+
reader.x += 4;
58+
return value;
59+
}
60+
61+
/**
62+
* Reads an SSH uint64 value in big-endian format.
63+
*/
64+
public readUint64(): bigint {
65+
const reader = this.reader;
66+
const value = reader.view.getBigUint64(reader.x, false); // false = big-endian
67+
reader.x += 8;
68+
return value;
69+
}
70+
71+
/**
72+
* Reads an SSH string as binary data (Uint8Array).
73+
* Format: uint32 length + data bytes (no padding).
74+
*/
75+
public readBinStr(): Uint8Array {
76+
const length = this.readUint32();
77+
const reader = this.reader;
78+
const data = new Uint8Array(length);
79+
80+
for (let i = 0; i < length; i++) {
81+
data[i] = reader.u8();
82+
}
83+
84+
return data;
85+
}
86+
87+
/**
88+
* Reads an SSH string with UTF-8 encoding.
89+
* Format: uint32 length + UTF-8 bytes (no padding).
90+
*/
91+
public readStr(): string {
92+
const length = this.readUint32();
93+
const reader = this.reader;
94+
95+
// Read UTF-8 bytes
96+
const utf8Bytes = new Uint8Array(length);
97+
for (let i = 0; i < length; i++) {
98+
utf8Bytes[i] = reader.u8();
99+
}
100+
101+
// Decode UTF-8 to string
102+
return new TextDecoder('utf-8').decode(utf8Bytes);
103+
}
104+
105+
/**
106+
* Reads an SSH string with ASCII encoding.
107+
* Format: uint32 length + ASCII bytes (no padding).
108+
*/
109+
public readAsciiStr(): string {
110+
const length = this.readUint32();
111+
const reader = this.reader;
112+
let str = '';
113+
114+
for (let i = 0; i < length; i++) {
115+
str += String.fromCharCode(reader.u8());
116+
}
117+
118+
return str;
119+
}
120+
121+
/**
122+
* Reads an SSH mpint (multiple precision integer).
123+
* Format: uint32 length + data bytes in two's complement format, MSB first.
124+
*/
125+
public readMpint(): JsonPackMpint {
126+
const length = this.readUint32();
127+
const reader = this.reader;
128+
const data = new Uint8Array(length);
129+
130+
for (let i = 0; i < length; i++) {
131+
data[i] = reader.u8();
132+
}
133+
134+
return new JsonPackMpint(data);
135+
}
136+
137+
/**
138+
* Reads an SSH name-list.
139+
* Format: uint32 length + comma-separated names.
140+
* Returns an array of name strings.
141+
*/
142+
public readNameList(): string[] {
143+
const nameListStr = this.readAsciiStr();
144+
if (nameListStr === '') {
145+
return [];
146+
}
147+
return nameListStr.split(',');
148+
}
149+
150+
/**
151+
* Reads binary data as SSH string (alias for readBinStr)
152+
*/
153+
public readBin(): Uint8Array {
154+
return this.readBinStr();
155+
}
156+
}

0 commit comments

Comments
 (0)