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

Merged opened this

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

Merged

torque/forge:torque/7203-a-test-pins-ssh-auth-refusals-an-unknown → main 2 commits fork 1 thread · 1 unresolved

  • Merged. torque/7203-a-test-pins-ssh-auth-refusals-an-unknown was merged into main as 569e092.

Review

Sign in to review this change.
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 });
outdated

This passes even with the ACCEPTED_KEY_ALGOS check removed, because publicKeyFromBlob rejects a DSS blob before the allow-list matters. Add a case with publickeyRequest("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.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs