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

A test pins SSH auth limits: the 7th signed attempt and the 33rd unsigned probe each disconnect, and nothing is authorised #7205

Merged opened this

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

Merged

scout/forge:scout/7202-a-test-pins-ssh-auth-limits-the-7th-sign → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/7202-a-test-pins-ssh-auth-limits-the-7th-sign was merged into main as d85408d.

Review

Sign in to review this change.
1 file changed +148 −0
test/ssh-auth-limits.test.ts added +148
@@ -0,0 +1,148 @@
1 /**
2 * The brute-force caps on the public SSH door: 6 signed attempts and 32
3 * signature-less probes per connection. `Auth` is driven directly through a
4 * fake host, so no socket and no OpenSSH are needed.
5 */
6
7 import { describe, expect, test } from "bun:test";
8 import { generateKeyPairSync, sign } from "node:crypto";
9 import { createLog } from "../src/kernel/log.ts";
10 import { Auth, type AuthHost } from "../src/ssh/auth.ts";
11 import { DISCONNECT, MSG, sshString, Writer } from "../src/ssh/wire.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 const SESSION_ID = new Uint8Array(32).fill(7);
17
18 function newKey() {
19 const { publicKey, privateKey } = generateKeyPairSync("ed25519");
20 const raw = Buffer.from(publicKey.export({ format: "jwk" }).x!, "base64url");
21 const blob = new Writer(64).string("ssh-ed25519").string(raw).build();
22 return { privateKey, blob };
23 }
24
25 type Key = ReturnType<typeof newKey>;
26
27 /** USERAUTH_REQUEST for publickey; `signWith` makes a real signature, `true` a bad one. */

Stale doc: there is no signWith. The parameter is signed, where false is a probe, true a bad signature and "valid" a real one. Say that.

28 function request(key: Key, signed: false | true | "valid"): Uint8Array {
29 const head = new Writer(256)
30 .u8(MSG.USERAUTH_REQUEST)
31 .string("tester")
32 .string("ssh-connection")
33 .string("publickey")
34 .bool(signed !== false)
35 .string("ssh-ed25519")
36 .string(key.blob)
37 .build();
38 if (signed === false) return head;
39 const message = new Writer(512)
40 .raw(sshString(SESSION_ID))
41 .raw(head)
42 .build();
43 const raw =
44 signed === "valid"
45 ? sign(null, message, key.privateKey)
46 : new Uint8Array(64).fill(1);
47 const sig = new Writer(128).string("ssh-ed25519").string(raw).build();
48 return new Writer(512).raw(head).string(sig).build();
49 }
50
51 function harness(
52 allowed: (key: Key) => boolean,
53 keys: Key[],
54 maxAttempts?: number,
55 ) {
56 const sent: Uint8Array[] = [];
57 const disconnects: { reason: number; description: string }[] = [];
58 const succeeded: string[] = [];
59 const host: AuthHost = {
60 send: (payload) => sent.push(payload),
61 unimplemented: () => {},
62 disconnect: (reason, description) => disconnects.push({ reason, description }),
63 sessionId: () => SESSION_ID,
64 authorize: (key) => {
65 const match = keys.find((k) => Buffer.compare(k.blob, key.blob) === 0);
66 return match !== undefined && allowed(match);
67 },
68 succeeded: (_key, username) => succeeded.push(username),
69 banner: undefined,
70 maxAttempts,
71 log: createLog("ssh-auth-limits-test"),
72 };
73 return {
74 auth: new Auth(host),
75 types: () => sent.map((p) => p[0]),
76 disconnects,
77 succeeded,
78 };
79 }
80
81 describe("ssh auth limits", () => {
82 test("the 7th signed attempt disconnects and nothing is authorised", async () => {
83 const key = newKey();
84 const h = harness(() => true, [key]);
85 for (let i = 0; i < 6; i++) await h.auth.handle(request(key, true), 1);
86 expect(h.types()).toEqual(Array(6).fill(MSG.USERAUTH_FAILURE));
87 expect(h.disconnects).toEqual([]);
88
89 await h.auth.handle(request(key, true), 1);
90 expect(h.disconnects).toEqual([
91 {
92 reason: DISCONNECT.NO_MORE_AUTH_METHODS_AVAILABLE,
93 description: "too many authentication attempts",
94 },
95 ]);
96 expect(h.types()).toHaveLength(6);
97 expect(h.succeeded).toEqual([]);
98 expect(h.auth.authenticated).toBe(false);
99 });
100
101 test("the 33rd unsigned probe disconnects", async () => {
102 const key = newKey();
103 const h = harness(() => false, [key]);
104 for (let i = 0; i < 32; i++) await h.auth.handle(request(key, false), 1);
105 expect(h.types()).toEqual(Array(32).fill(MSG.USERAUTH_FAILURE));
106 expect(h.disconnects).toEqual([]);
107
108 await h.auth.handle(request(key, false), 1);
109 expect(h.disconnects).toHaveLength(1);
110 expect(h.disconnects[0]!.reason).toBe(

Both caps disconnect with the same reason code, so this assertion alone can't tell which branch fired. Also assert description is "too many public key probes", as the signed test does for its message. Then the probe test pins the probe branch specifically.

111 DISCONNECT.NO_MORE_AUTH_METHODS_AVAILABLE,
112 );
113 expect(h.types()).toHaveLength(32);
114 expect(h.succeeded).toEqual([]);
115 expect(h.auth.authenticated).toBe(false);
116 });
117
118 test("probes do not use up the signed-attempt budget", async () => {
119 const key = newKey();
120 const h = harness(() => true, [key]);
121 for (let i = 0; i < 20; i++) await h.auth.handle(request(key, false), 1);
122 await h.auth.handle(request(key, "valid"), 1);
123 expect(h.disconnects).toEqual([]);
124 expect(h.types().at(-1)).toBe(MSG.USERAUTH_SUCCESS);
125 expect(h.succeeded).toEqual(["tester"]);
126 expect(h.auth.authenticated).toBe(true);
127 });
128
129 test("host.maxAttempts overrides the default", async () => {
130 const key = newKey();
131 const h = harness(() => true, [key], 2);
132 await h.auth.handle(request(key, true), 1);
133 await h.auth.handle(request(key, true), 1);
134 expect(h.disconnects).toEqual([]);
135 await h.auth.handle(request(key, "valid"), 1);
136 expect(h.disconnects).toHaveLength(1);
137 expect(h.disconnects[0]!.description).toBe(
138 "too many authentication attempts",
139 );
140 expect(h.succeeded).toEqual([]);
141
142 const wide = harness(() => true, [key], 10);
143 for (let i = 0; i < 10; i++) await wide.auth.handle(request(key, true), 1);
144 expect(wide.disconnects).toEqual([]);
145 await wide.auth.handle(request(key, true), 1);
146 expect(wide.disconnects).toHaveLength(1);
147 });
148 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs