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

A test pins SSH key-exchange order: anything before KEXINIT, a second KEXINIT, or ECDH_INIT or NEWKEYS out of turn is refused #7188

Merged opened this

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

Merged

scout/forge:scout/7182-a-test-pins-ssh-key-exchange-order-anyth → main 1 commit fork

  • Merged. scout/7182-a-test-pins-ssh-key-exchange-order-anyth was merged into main as c3bef7c.

Review

Sign in to review this change.
1 file changed +190 −0
test/ssh-kex-order.test.ts added +190
@@ -0,0 +1,190 @@
1 /**
2 * The SSH transport listens on a public port and promises to never throw at
3 * its caller. These tests drive it directly with plaintext packets sent out of
4 * order: each is refused with a DISCONNECT and a closed socket, and the
5 * server's host-key signature (KEX_ECDH_REPLY) never goes out for them.
6 */
7
8 import { describe, expect, test } from "bun:test";
9 import { generateKeyPairSync, randomBytes } from "node:crypto";
10 import { createLog } from "../src/kernel/log.ts";
11 import { mkdtempSync } from "node:fs";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { type HostKey, loadOrCreateHostKey } from "../src/ssh/keys.ts";
15 import { Transport } from "../src/ssh/transport.ts";
16 import {
17 concat,
18 DISCONNECT,
19 MSG,
20 Reader,
21 utf8,
22 Writer,
23 } from "../src/ssh/wire.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25
26 quietLogs();
27
28 interface Sent {
29 type: number;
30 payload: Uint8Array;
31 }
32
33 function harness() {
34 const hostKey: HostKey = loadOrCreateHostKey(
35 mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "kex-order-")),
36 );
37 const out: Uint8Array[] = [];
38 const closed: string[] = [];
39 const upper: Uint8Array[] = [];
40 const transport = new Transport(
41 {
42 write: (data) => out.push(data.slice()),
43 payload: (p) => upper.push(p),
44 closed: (why) => closed.push(why),
45 log: createLog("ssh-kex-order"),
46 },
47 { hostKey, serverVersion: "SSH-2.0-forge-test" },
48 );
49 transport.start();
50 const send = (...payloads: Uint8Array[]) => {
51 for (const p of payloads) transport.receive(plainPacket(p));
52 };
53 /** Plaintext packets the server wrote, in order, after its version line. */
54 const sent = (): Sent[] => {
55 const bytes = concat(...out);
56 let at = bytes.indexOf(0x0a) + 1;
57 const packets: Sent[] = [];
58 while (at + 5 <= bytes.length) {
59 const len = new DataView(bytes.buffer, bytes.byteOffset + at).getUint32(0);
60 const pad = bytes[at + 4]!;
61 const payload = bytes.subarray(at + 5, at + 4 + len - pad);
62 if (payload.length === 0) break;
63 packets.push({ type: payload[0]!, payload });
64 at += 4 + len;
65 }
66 return packets;
67 };
68 const disconnects = () =>
69 sent()
70 .filter((p) => p.type === MSG.DISCONNECT)
71 .map((p) => {
72 const r = new Reader(p.payload);
73 r.u8();
74 return { reason: r.u32(), text: new TextDecoder().decode(r.string()) };
75 });
76 const replies = () => sent().filter((p) => p.type === MSG.KEX_ECDH_REPLY);
77 const feed = (bytes: Uint8Array) => transport.receive(bytes);
78 return { feed, send, closed, upper, disconnects, replies, sent };
79 }
80
81 function plainPacket(payload: Uint8Array): Uint8Array {
82 let pad = 8 - ((payload.length + 5) % 8);
83 if (pad < 4) pad += 8;
84 const w = new Writer(payload.length + pad + 8);
85 return w
86 .u32(payload.length + pad + 1)
87 .u8(pad)
88 .raw(payload)
89 .raw(new Uint8Array(pad))
90 .build();
91 }
92
93 const VERSION = utf8("SSH-2.0-client\r\n");
94
95 function clientKexInit(): Uint8Array {
96 return new Writer(512)
97 .u8(MSG.KEXINIT)
98 .raw(randomBytes(16))
99 .nameList(["curve25519-sha256", "[email protected]"])
100 .nameList(["ssh-ed25519"])
101 .nameList(["[email protected]"])
102 .nameList(["[email protected]"])
103 .nameList(["hmac-sha2-256"])
104 .nameList(["hmac-sha2-256"])
105 .nameList(["none"])
106 .nameList(["none"])
107 .nameList([])
108 .nameList([])
109 .bool(false)
110 .u32(0)
111 .build();
112 }
113
114 function ecdhInit(): Uint8Array {
115 const { publicKey } = generateKeyPairSync("x25519");
116 const der = publicKey.export({ type: "spki", format: "der" });
117 return new Writer(64).u8(MSG.KEX_ECDH_INIT).string(der.subarray(-32)).build();
118 }
119
120 const newKeys = () => new Uint8Array([MSG.NEWKEYS]);
121
122 function expectRefused(
123 h: ReturnType<typeof harness>,
124 text: string,
125 reason: number = DISCONNECT.PROTOCOL_ERROR,
126 ) {
127 expect(h.closed).toEqual([text]);
128 expect(h.disconnects()).toEqual([{ reason, text }]);
129 expect(h.replies()).toHaveLength(0);
130 expect(h.upper).toHaveLength(0);
131 }
132
133 describe("SSH key-exchange order", () => {
134 function start() {
135 const h = harness();
136 return {
137 h,
138 go(...payloads: Uint8Array[]) {
139 h.feed(VERSION);
140 h.send(...payloads);
141 },
142 };
143 }
144
145 test("anything but KEXINIT first is refused", () => {
146 const { h, go } = start();
147 go(ecdhInit());
148 expectRefused(h, "expected KEXINIT first");
149 });
150
151 test("a service request before KEXINIT is refused the same way", () => {
152 const { h, go } = start();
153 go(new Writer(32).u8(MSG.SERVICE_REQUEST).string("ssh-userauth").build());
154 expectRefused(h, "expected KEXINIT first");
155 });
156
157 test("NEWKEYS first is refused", () => {
158 const { h, go } = start();
159 go(newKeys());
160 expectRefused(h, "expected KEXINIT first");
161 });
162
163 test("a second KEXINIT is refused", () => {
164 const { h, go } = start();
165 go(clientKexInit(), clientKexInit());
166 expectRefused(h, "duplicate KEXINIT");
167 });
168
169 test("NEWKEYS outside a key exchange is refused", () => {
170 const { h, go } = start();
171 go(clientKexInit(), newKeys());
172 expectRefused(h, "NEWKEYS outside key exchange");
173 });
174
175 test("a second KEX_ECDH_INIT ends the connection after one reply", () => {
176 const { h, go } = start();
177 go(clientKexInit(), ecdhInit(), ecdhInit());
178 expect(h.closed).toEqual(["duplicate KEX_ECDH_INIT"]);
179 expect(h.replies()).toHaveLength(1);
180 expect(h.upper).toHaveLength(0);
181 });
182
183 test("input after a refusal is ignored, not thrown", () => {
184 const { h, go } = start();
185 go(newKeys());
186 expect(() => h.send(clientKexInit(), ecdhInit())).not.toThrow();
187 expect(h.closed).toHaveLength(1);
188 expect(h.replies()).toHaveLength(0);
189 });
190 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs