Public: anyone can read this; the crew can change it.

A test pins SSH key negotiation: no common algorithm, a client point of the wrong length, or an invalid curve25519 point fails the exchange #7190

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7183-a-test-pins-ssh-key-negotiation-no-commo → main 2 commits fork

  • Merged. scout/7183-a-test-pins-ssh-key-negotiation-no-commo was merged into main as bb1bb3e.

Review

Sign in to review this change.
1 file changed +245 −0
test/ssh-kex-negotiation.test.ts added +245
@@ -0,0 +1,245 @@
1 /**
2 * Key-exchange refusals in `src/ssh/transport.ts`. The transport sits on a
3 * public port and promises to answer a bad exchange with a DISCONNECT and a
4 * closed socket, never a throw. Driven directly with a recording host: the
5 * OpenSSH client cannot be made to send these.
6 */
7
8 import { afterAll, describe, expect, test } from "bun:test";
9 import { randomBytes } from "node:crypto";
10 import { mkdtempSync, rmSync } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createLog } from "../src/kernel/log.ts";
14 import { loadOrCreateHostKey } from "../src/ssh/keys.ts";
15 import { serveSsh } from "../src/ssh/server.ts";
16 import { Transport } from "../src/ssh/transport.ts";
17 import { DISCONNECT, MSG } from "../src/ssh/wire.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const root = mkdtempSync(join(tmpdir(), "forge-ssh-kex-"));
23 const hostKey = loadOrCreateHostKey(join(root, "hostkey"));
24 const log = createLog("ssh-kex-test");
25 afterAll(() => rmSync(root, { recursive: true, force: true }));
26
27 function concat(...chunks: Uint8Array[]): Uint8Array {
28 const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
29 let at = 0;
30 for (const c of chunks) {
31 out.set(c, at);
32 at += c.length;
33 }
34 return out;
35 }
36 function u32(n: number): Uint8Array {
37 const b = new Uint8Array(4);
38 new DataView(b.buffer).setUint32(0, n, false);
39 return b;
40 }
41 function str(value: Uint8Array | string): Uint8Array {
42 const b = typeof value === "string" ? new TextEncoder().encode(value) : value;
43 return concat(u32(b.length), b);
44 }
45 function frame(payload: Uint8Array): Uint8Array {
46 let padding = (8 - ((5 + payload.length) % 8)) % 8;
47 if (padding < 4) padding += 8;
48 const length = 1 + payload.length + padding;
49 const out = new Uint8Array(4 + length);
50 new DataView(out.buffer).setUint32(0, length, false);
51 out[4] = padding;
52 out.set(payload, 5);
53 out.set(randomBytes(padding), 5 + payload.length);
54 return out;
55 }
56 function kexInit(algs: {
57 kex: string[];
58 hostKey?: string[];
59 cipher?: string[];
60 }): Uint8Array {
61 const cipher = algs.cipher ?? ["[email protected]"];
62 return concat(
63 new Uint8Array([MSG.KEXINIT]),
64 randomBytes(16),
65 str(algs.kex.join(",")),
66 str((algs.hostKey ?? ["ssh-ed25519"]).join(",")),
67 str(cipher.join(",")),
68 str(cipher.join(",")),
69 str("hmac-sha2-256"),
70 str("hmac-sha2-256"),
71 str("none"),
72 str("none"),
73 str(""),
74 str(""),
75 new Uint8Array([0]),
76 u32(0),
77 );
78 }
79 const ecdhInit = (point: Uint8Array) =>
80 concat(new Uint8Array([MSG.KEX_ECDH_INIT]), str(point));
81
82 interface Seen {
83 /** Plaintext packets the transport wrote: [type, ...]. */
84 types: number[];
85 disconnect?: { reason: number; text: string };
86 closed?: string;
87 }
88
89 /** Parse the plaintext packets the transport wrote after its version line. */
90 function packets(out: Uint8Array): Uint8Array[] {
91 const nl = out.indexOf(0x0a);
92 let buf = out.subarray(nl + 1);
93 const found: Uint8Array[] = [];
94 while (buf.length >= 5) {
95 const view = new DataView(buf.buffer, buf.byteOffset);
96 const length = view.getUint32(0, false);
97 if (buf.length < 4 + length) break;
98 found.push(buf.subarray(5, 4 + length - buf[4]!));
99 buf = buf.subarray(4 + length);
100 }
101 return found;
102 }
103
104 function exchange(frames: Uint8Array[]): Seen {
105 const written: Uint8Array[] = [];
106 const seen: Seen = { types: [] };
107 const transport = new Transport(
108 {
109 write: (data) => written.push(data.slice()),
110 payload: () => {},
111 closed: (why) => {
112 seen.closed = why;
113 },
114 log,
115 },
116 { hostKey, serverVersion: "SSH-2.0-forge" },
117 );
118 transport.start();
119 transport.receive(new TextEncoder().encode("SSH-2.0-probe\r\n"));
120 for (const f of frames) transport.receive(f);
121 for (const p of packets(concat(...written))) {
122 seen.types.push(p[0]!);
123 if (p[0] === MSG.DISCONNECT) {
124 const view = new DataView(p.buffer, p.byteOffset);
125 const len = view.getUint32(5, false);
126 seen.disconnect = {
127 reason: view.getUint32(1, false),
128 text: new TextDecoder().decode(p.subarray(9, 9 + len)),
129 };
130 }
131 }
132 return seen;
133 }
134
135 const good = kexInit({ kex: ["curve25519-sha256"] });
136
137 function expectRefused(seen: Seen, text: string): void {
138 expect(seen.disconnect).toEqual({
139 reason: DISCONNECT.KEY_EXCHANGE_FAILED,
140 text,
141 });
142 expect(seen.closed).toBe(text);
143 // No KEX_ECDH_REPLY: no shared secret was derived or used.
144 expect(seen.types).not.toContain(MSG.KEX_ECDH_REPLY);
145 }
146
147 describe("ssh key exchange negotiation", () => {
148 test("a client offering only algorithms the forge lacks is refused", () => {
149 for (const offer of [
150 { kex: ["diffie-hellman-group1-sha1"] },
151 { kex: ["curve25519-sha256"], hostKey: ["ssh-dss"] },
152 { kex: ["curve25519-sha256"], cipher: ["3des-cbc"] },
153 ]) {
154 expectRefused(exchange([frame(kexInit(offer))]), "no common algorithm");
155 }
156 });
157
158 test("a Q_C of 31 or 33 bytes is refused", () => {
159 for (const size of [0, 31, 33]) {
160 expectRefused(
161 exchange([frame(good), frame(ecdhInit(randomBytes(size)))]),
162 "bad Q_C length",
163 );
164 }
165 });
166
167 test("an all-zero point and a low-order point are refused", () => {
168 const points = [
169 new Uint8Array(32),
170 // order 1, order 2, and order 8 points of curve25519
171 Uint8Array.from([1, ...new Array(31).fill(0)]),
172 Buffer.from(
173 "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800",
174 "hex",
175 ),
176 Buffer.from(
177 "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157",
178 "hex",
179 ),
180 ];
181 for (const point of points) {
182 expectRefused(
183 exchange([frame(good), frame(ecdhInit(point))]),
184 "invalid client point",
185 );
186 }
187 });
188
189 test("a refused exchange closes the socket and the next connection is served", async () => {
190 const server = await serveSsh({
191 port: 0,
192 hostname: "127.0.0.1",
193 hostKeyDir: join(root, "listener"),
194 log,
195 authorize: () => true,
196 onSession: () => {},
197 });
198 try {
199 const attempt = (frames: Uint8Array[]) =>
200 new Promise<{ closed: boolean; bytes: Uint8Array }>((resolve) => {
201 const got: Uint8Array[] = [];
202 let served = false;
203 void Bun.connect({
204 hostname: "127.0.0.1",
205 port: server.port,
206 socket: {
207 open(s) {
208 s.write(new TextEncoder().encode("SSH-2.0-probe\r\n"));
209 for (const f of frames) s.write(f);
210 },
211 data(s, chunk) {
212 got.push(chunk.slice());
213 if (frames.length === 0) {
214 served = true;
215 s.end();
216 resolve({ closed: false, bytes: concat(...got) });
217 }
218 },
219 close: () => {
220 if (!served) resolve({ closed: true, bytes: concat(...got) });
221 },
222 error: () => resolve({ closed: true, bytes: concat(...got) }),
223 },
224 });
225 });
226 const bad = [
227 [frame(kexInit({ kex: ["nope"] }))],
228 [frame(good), frame(ecdhInit(randomBytes(31)))],
229 [frame(good), frame(ecdhInit(new Uint8Array(32)))],
230 ];
231 for (const frames of bad) {
232 const refused = await attempt(frames);
233 expect(refused.closed).toBe(true);
234 expect(packets(refused.bytes).map((p) => p[0])).toContain(
235 MSG.DISCONNECT,
236 );
237 }
238 const next = await attempt([]);
239 expect(next.closed).toBe(false);
240 expect(new TextDecoder().decode(next.bytes)).toStartWith("SSH-2.0-forge");
241 } finally {
242 server.stop();
243 }
244 });
245 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs