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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7183-a-test-pins-ssh-key-negotiation-no-commo → main 2 commits fork
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 | }); |
new above
offline