Public: anyone can read this; the crew can change it.
A test pins SSH auth refusals: an unknown service, channel traffic before auth, a mismatched key blob and an unknown algorithm #7208
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7203-a-test-pins-ssh-auth-refusals-an-unknown → main 2 commits fork 1 thread · 1 unresolved
1 file
changed +334
−0
test/ssh-auth-refusals.test.ts added +334
| @@ -0,0 +1,334 @@ | ||
| 1 | /** | |
| 2 | * SSH auth refusals (#7203): every way an unauthenticated peer can misbehave | |
| 3 | * in `ssh-userauth` ends closed. An unknown service or channel traffic before | |
| 4 | * success disconnects; a non-publickey method, an unaccepted algorithm and a | |
| 5 | * blob that does not match its named algorithm each get FAILURE — and none of | |
| 6 | * them ever reaches `authorize` or `succeeded`. | |
| 7 | */ | |
| 8 | import { describe, expect, test } from "bun:test"; | |
| 9 | import { | |
| 10 | createPublicKey, | |
| 11 | generateKeyPairSync, | |
| 12 | sign, | |
| 13 | type KeyObject, | |
| 14 | } from "node:crypto"; | |
| 15 | import { Auth, type AuthHost } from "../src/ssh/auth.ts"; | |
| 16 | import type { SshPublicKey } from "../src/ssh/keys.ts"; | |
| 17 | import { DISCONNECT, MSG, Reader, sshString, Writer } from "../src/ssh/wire.ts"; | |
| 18 | ||
| 19 | const sessionId = new Uint8Array(32).fill(7); | |
| 20 | ||
| 21 | function jwkBytes(key: KeyObject, field: string): Uint8Array { | |
| 22 | const jwk = createPublicKey(key).export({ format: "jwk" }) as Record< | |
| 23 | string, | |
| 24 | string | |
| 25 | >; | |
| 26 | return Buffer.from(jwk[field]!, "base64url"); | |
| 27 | } | |
| 28 | ||
| 29 | const ed = generateKeyPairSync("ed25519").privateKey; | |
| 30 | const edBlob = new Writer() | |
| 31 | .string("ssh-ed25519") | |
| 32 | .string(jwkBytes(ed, "x")) | |
| 33 | .build(); | |
| 34 | const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey; | |
| 35 | const rsaBlob = new Writer() | |
| 36 | .string("ssh-rsa") | |
| 37 | .mpint(jwkBytes(rsa, "e")) | |
| 38 | .mpint(jwkBytes(rsa, "n")) | |
| 39 | .build(); | |
| 40 | ||
| 41 | interface Fake { | |
| 42 | host: AuthHost; | |
| 43 | sent: Uint8Array[]; | |
| 44 | disconnects: { reason: number; description: string }[]; | |
| 45 | unimplemented: number[]; | |
| 46 | authorized: { key: SshPublicKey; username: string }[]; | |
| 47 | succeeded: { key: SshPublicKey; username: string }[]; | |
| 48 | } | |
| 49 | ||
| 50 | /** A host that records everything and would authorize any key it is shown. */ | |
| 51 | function fakeHost(): Fake { | |
| 52 | const fake: Fake = { | |
| 53 | sent: [], | |
| 54 | disconnects: [], | |
| 55 | unimplemented: [], | |
| 56 | authorized: [], | |
| 57 | succeeded: [], | |
| 58 | host: undefined as unknown as AuthHost, | |
| 59 | }; | |
| 60 | const quiet = () => {}; | |
| 61 | fake.host = { | |
| 62 | send: (payload) => fake.sent.push(payload.slice()), | |
| 63 | unimplemented: (seq) => fake.unimplemented.push(seq), | |
| 64 | disconnect: (reason, description) => | |
| 65 | fake.disconnects.push({ reason, description }), | |
| 66 | sessionId: () => sessionId, | |
| 67 | authorize: (key, username) => { | |
| 68 | fake.authorized.push({ key, username }); | |
| 69 | return true; | |
| 70 | }, | |
| 71 | succeeded: (key, username) => fake.succeeded.push({ key, username }), | |
| 72 | banner: undefined, | |
| 73 | maxAttempts: undefined, | |
| 74 | log: { debug: quiet, info: quiet, warn: quiet, error: quiet }, | |
| 75 | }; | |
| 76 | return fake; | |
| 77 | } | |
| 78 | ||
| 79 | function serviceRequest(service: string): Uint8Array { | |
| 80 | return new Writer().u8(MSG.SERVICE_REQUEST).string(service).build(); | |
| 81 | } | |
| 82 | ||
| 83 | function methodRequest(service: string, method: string): Uint8Array { | |
| 84 | return new Writer() | |
| 85 | .u8(MSG.USERAUTH_REQUEST) | |
| 86 | .string("alice") | |
| 87 | .string(service) | |
| 88 | .string(method) | |
| 89 | .build(); | |
| 90 | } | |
| 91 | ||
| 92 | /** A publickey request; with `signWith`, a signature over RFC 4252 §7's blob. */ | |
| 93 | function publickeyRequest( | |
| 94 | algo: string, | |
| 95 | blob: Uint8Array, | |
| 96 | signWith?: (message: Uint8Array) => Uint8Array, | |
| 97 | ): Uint8Array { | |
| 98 | const prefix = new Writer() | |
| 99 | .u8(MSG.USERAUTH_REQUEST) | |
| 100 | .string("alice") | |
| 101 | .string("ssh-connection") | |
| 102 | .string("publickey") | |
| 103 | .bool(signWith !== undefined) | |
| 104 | .string(algo) | |
| 105 | .string(blob) | |
| 106 | .build(); | |
| 107 | if (!signWith) return prefix; | |
| 108 | const message = new Writer() | |
| 109 | .raw(sshString(sessionId)) | |
| 110 | .raw(prefix) | |
| 111 | .build(); | |
| 112 | return new Writer().raw(prefix).string(signWith(message)).build(); | |
| 113 | } | |
| 114 | ||
| 115 | /** The exact FAILURE payload: methods `publickey`, partial success false. */ | |
| 116 | const FAILURE = new Writer() | |
| 117 | .u8(MSG.USERAUTH_FAILURE) | |
| 118 | .nameList(["publickey"]) | |
| 119 | .bool(false) | |
| 120 | .build(); | |
| 121 | ||
| 122 | function expectNeverAuthenticated(fake: Fake, auth: Auth): void { | |
| 123 | expect(fake.authorized).toEqual([]); | |
| 124 | expect(fake.succeeded).toEqual([]); | |
| 125 | expect(auth.authenticated).toBe(false); | |
| 126 | expect(fake.sent.some((p) => p[0] === MSG.USERAUTH_SUCCESS)).toBe(false); | |
| 127 | } | |
| 128 | ||
| 129 | describe("ssh auth: services", () => { | |
| 130 | test("SERVICE_ACCEPT echoes ssh-userauth byte for byte (RFC 4253 §10)", async () => { | |
| 131 | const fake = fakeHost(); | |
| 132 | const auth = new Auth(fake.host); | |
| 133 | await auth.handle(serviceRequest("ssh-userauth"), 3); | |
| 134 | // byte SSH_MSG_SERVICE_ACCEPT (6), uint32 length 12, "ssh-userauth". | |
| 135 | expect([...fake.sent[0]!]).toEqual([ | |
| 136 | 6, 0, 0, 0, 12, ...new TextEncoder().encode("ssh-userauth"), | |
| 137 | ]); | |
| 138 | expect(fake.sent).toHaveLength(1); | |
| 139 | expect(fake.disconnects).toEqual([]); | |
| 140 | expectNeverAuthenticated(fake, auth); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a SERVICE_REQUEST for ssh-agent disconnects", async () => { | |
| 144 | const fake = fakeHost(); | |
| 145 | const auth = new Auth(fake.host); | |
| 146 | await auth.handle(serviceRequest("ssh-agent"), 3); | |
| 147 | expect(fake.disconnects).toEqual([ | |
| 148 | { | |
| 149 | reason: DISCONNECT.SERVICE_NOT_AVAILABLE, | |
| 150 | description: "no service ssh-agent", | |
| 151 | }, | |
| 152 | ]); | |
| 153 | expect(fake.sent).toEqual([]); | |
| 154 | expectNeverAuthenticated(fake, auth); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a USERAUTH_REQUEST for service ssh-foo disconnects", async () => { | |
| 158 | const fake = fakeHost(); | |
| 159 | const auth = new Auth(fake.host); | |
| 160 | const payload = new Writer() | |
| 161 | .u8(MSG.USERAUTH_REQUEST) | |
| 162 | .string("alice") | |
| 163 | .string("ssh-foo") | |
| 164 | .string("publickey") | |
| 165 | .bool(false) | |
| 166 | .string("ssh-ed25519") | |
| 167 | .string(edBlob) | |
| 168 | .build(); | |
| 169 | await auth.handle(payload, 4); | |
| 170 | expect(fake.disconnects).toEqual([ | |
| 171 | { | |
| 172 | reason: DISCONNECT.SERVICE_NOT_AVAILABLE, | |
| 173 | description: "no service ssh-foo", | |
| 174 | }, | |
| 175 | ]); | |
| 176 | expect(fake.sent).toEqual([]); | |
| 177 | expectNeverAuthenticated(fake, auth); | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | describe("ssh auth: traffic before success", () => { | |
| 182 | const cases: [string, Uint8Array][] = [ | |
| 183 | [ | |
| 184 | "GLOBAL_REQUEST", | |
| 185 | new Writer() | |
| 186 | .u8(MSG.GLOBAL_REQUEST) | |
| 187 | .string("tcpip-forward") | |
| 188 | .bool(true) | |
| 189 | .string("0.0.0.0") | |
| 190 | .u32(22) | |
| 191 | .build(), | |
| 192 | ], | |
| 193 | [ | |
| 194 | "CHANNEL_OPEN", | |
| 195 | new Writer() | |
| 196 | .u8(MSG.CHANNEL_OPEN) | |
| 197 | .string("session") | |
| 198 | .u32(0) | |
| 199 | .u32(1 << 20) | |
| 200 | .u32(1 << 15) | |
| 201 | .build(), | |
| 202 | ], | |
| 203 | ]; | |
| 204 | for (const [name, payload] of cases) { | |
| 205 | test(`${name} before USERAUTH_SUCCESS disconnects`, async () => { | |
| 206 | const fake = fakeHost(); | |
| 207 | const auth = new Auth(fake.host); | |
| 208 | await auth.handle(payload, 5); | |
| 209 | expect(fake.disconnects).toEqual([ | |
| 210 | { | |
| 211 | reason: DISCONNECT.PROTOCOL_ERROR, | |
| 212 | description: `message ${payload[0]} before authentication`, | |
| 213 | }, | |
| 214 | ]); | |
| 215 | expect(fake.unimplemented).toEqual([]); | |
| 216 | expect(fake.sent).toEqual([]); | |
| 217 | expectNeverAuthenticated(fake, auth); | |
| 218 | }); | |
| 219 | } | |
| 220 | }); | |
| 221 | ||
| 222 | describe("ssh auth: FAILURE", () => { | |
| 223 | for (const method of ["password", "none"]) { | |
| 224 | test(`method ${method} gets FAILURE listing only publickey`, async () => { | |
| 225 | const fake = fakeHost(); | |
| 226 | const auth = new Auth(fake.host); | |
| 227 | await auth.handle(methodRequest("ssh-connection", method), 4); | |
| 228 | expect(fake.sent).toEqual([FAILURE]); | |
| 229 | const r = new Reader(fake.sent[0]!); | |
| 230 | expect(r.u8()).toBe(MSG.USERAUTH_FAILURE); | |
| 231 | expect(r.nameList()).toEqual(["publickey"]); | |
| 232 | expect(r.bool()).toBe(false); | |
| 233 | expect(fake.disconnects).toEqual([]); | |
| 234 | expectNeverAuthenticated(fake, auth); | |
| 235 | }); | |
| 236 | } | |
| 237 | ||
| 238 | // `ssh-rsa` (SHA-1) is left out of ACCEPTED_KEY_ALGOS on purpose. The RSA | |
| 239 | // blob parses and its inner name matches, so only the allow-list stands | |
| 240 | // between this request and `authorize`. | |
| 241 | test("algorithm ssh-rsa with a valid RSA blob gets FAILURE (probe)", async () => { | |
| 242 | const fake = fakeHost(); | |
| 243 | const auth = new Auth(fake.host); | |
| 244 | await auth.handle(publickeyRequest("ssh-rsa", rsaBlob), 4); | |
| 245 | expect(fake.sent).toEqual([FAILURE]); | |
| 246 | expect(fake.disconnects).toEqual([]); | |
| 247 | expectNeverAuthenticated(fake, auth); | |
| 248 | }); | |
| 249 | ||
| 250 | test("algorithm ssh-rsa signed with SHA-1 gets FAILURE", async () => { | |
| 251 | const fake = fakeHost(); | |
| 252 | const auth = new Auth(fake.host); | |
| 253 | const payload = publickeyRequest("ssh-rsa", rsaBlob, (message) => | |
| 254 | new Writer() | |
| 255 | .string("ssh-rsa") | |
| 256 | .string(sign("sha1", message, rsa)) | |
| 257 | .build(), | |
| 258 | ); | |
| 259 | await auth.handle(payload, 4); | |
| 260 | expect(fake.sent).toEqual([FAILURE]); | |
| 261 | expect(fake.disconnects).toEqual([]); | |
| 262 | expectNeverAuthenticated(fake, auth); | |
| 263 | }); | |
| 264 | ||
| 265 | // A DSS blob is refused by the parser before the allow-list matters, so | |
| 266 | // this pins that an unparseable blob fails closed, not the allow-list. | |
| 267 | test("an unparseable ssh-dss blob gets FAILURE", async () => { | |
| 268 | const fake = fakeHost(); | |
| 269 | const auth = new Auth(fake.host); | |
| 270 | const dssBlob = new Writer() | |
| 271 | .string("ssh-dss") | |
| 272 | .mpint(new Uint8Array([1])) | |
| 273 | .mpint(new Uint8Array([2])) | |
| 274 | .mpint(new Uint8Array([3])) | |
| 275 | .mpint(new Uint8Array([4])) | |
| 276 | .build(); | |
| 277 | await auth.handle(publickeyRequest("ssh-dss", dssBlob), 4); | |
| 278 | expect(fake.sent).toEqual([FAILURE]); | |
| 279 | expect(fake.disconnects).toEqual([]); | |
| 280 | expectNeverAuthenticated(fake, auth); | |
| 281 | }); | |
| 282 | ||
| 283 | test("an ssh-ed25519 request carrying an RSA blob gets FAILURE", async () => { | |
| 284 | const fake = fakeHost(); | |
| 285 | const auth = new Auth(fake.host); | |
| 286 | await auth.handle(publickeyRequest("ssh-ed25519", rsaBlob), 4); | |
| 287 | expect(fake.sent).toEqual([FAILURE]); | |
| 288 | expect(fake.disconnects).toEqual([]); | |
| 289 | expectNeverAuthenticated(fake, auth); | |
| 290 | }); | |
| 291 | ||
| 292 | test("the same mismatch with a valid RSA signature still gets FAILURE", async () => { | |
| 293 | const fake = fakeHost(); | |
| 294 | const auth = new Auth(fake.host); | |
| 295 | const payload = publickeyRequest("ssh-ed25519", rsaBlob, (message) => | |
| 296 | new Writer() | |
| 297 | .string("rsa-sha2-256") | |
| 298 | .string(sign("sha256", message, rsa)) | |
| 299 | .build(), | |
| 300 | ); | |
| 301 | await auth.handle(payload, 4); | |
| 302 | expect(fake.sent).toEqual([FAILURE]); | |
| 303 | expect(fake.disconnects).toEqual([]); | |
| 304 | expectNeverAuthenticated(fake, auth); | |
| 305 | }); | |
| 306 | }); | |
| 307 | ||
| 308 | describe("ssh auth: the fake host is not vacuous", () => { | |
| 309 | // The refusals above prove nothing if the fake could never authorize, so | |
| 310 | // the well-formed requests must reach `authorize`, PK_OK and success. | |
| 311 | test("a matching ed25519 probe reaches authorize and gets PK_OK", async () => { | |
| 312 | const fake = fakeHost(); | |
| 313 | const auth = new Auth(fake.host); | |
| 314 | await auth.handle(publickeyRequest("ssh-ed25519", edBlob), 4); | |
| 315 | expect(fake.authorized).toHaveLength(1); | |
| 316 | expect(fake.sent[0]![0]).toBe(MSG.USERAUTH_PK_OK); | |
| 317 | expect(auth.authenticated).toBe(false); | |
| 318 | }); | |
| 319 | ||
| 320 | test("a matching signed ed25519 request succeeds", async () => { | |
| 321 | const fake = fakeHost(); | |
| 322 | const auth = new Auth(fake.host); | |
| 323 | const payload = publickeyRequest("ssh-ed25519", edBlob, (message) => | |
| 324 | new Writer() | |
| 325 | .string("ssh-ed25519") | |
| 326 | .string(sign(null, message, ed)) | |
| 327 | .build(), | |
| 328 | ); | |
| 329 | await auth.handle(payload, 4); | |
| 330 | expect(fake.succeeded).toHaveLength(1); | |
| 331 | expect(auth.authenticated).toBe(true); | |
| 332 | expect(fake.sent).toEqual([new Uint8Array([MSG.USERAUTH_SUCCESS])]); | |
| 333 | }); | |
| 334 | }); |
new above
offline
This passes even with the
ACCEPTED_KEY_ALGOScheck removed, becausepublicKeyFromBlobrejects a DSS blob before the allow-list matters. Add a case withpublickeyRequest("ssh-rsa", rsaBlob), both as a probe and signed with SHA-1. That blob parses and its algorithm matches, so only the allow-list can refuse it, and the test fails without the allow-list.