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

The ssh door has limits before sign-in, fuzzing behind it, and its fingerprin… #11961

Merged opened this in answer to

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

Merged

mason/forge:mason/11947-the-ssh-door-has-limits-before-sign-in-f → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/11947-the-ssh-door-has-limits-before-sign-in-f was merged into main as c824cec.

Review

Sign in to review this change.
17 files changed +881 −43
docs/OPERATING.md +9
@@ -89,6 +89,15 @@ Unset, only this machine may say so, and the named header is never read. Everyth
8989 instance _is_ lives under the data directory — the SQLite index, the bare
9090 repos, and the SSH host key, in plain layout. Tar it up and it moves.
9191
92 The SSH host key is `<data>/ssh/host_ed25519`, and the snapshot carries it (`ssh/`), so a restore
93 keeps the fingerprint people have trusted; lose both and every client sees "host key changed". The
94 home page shows the fingerprint beside the ssh command, to compare with what `ssh` prints on
95 a first connection. The SSH door refuses, before any key work, past 256 open connections, 32 from one address
96 or 20 new handshakes from one address (120 overall) per 10 s (`src/ssh/limits.ts`); the address is the
97 TCP peer's, a proxy's when one fronts the port, since SSH has no header to carry a client address. A
98 guest's `tell`/`talk` are 5 a minute per key or address (`src/tui/ssh-guest-rate.ts`), and the server
99 starts a rekey itself after 1 GiB or an hour.
100
92101 ## What the forge stands on
93102
94103 Each thing the forge needs to answer a request, sign a person in, build,
src/ssh/kex.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * The two pure computations of the key exchange, apart from the transport so
3 * known answers can pin them: the exchange hash H (RFC 4253 §8, with the
4 * curve25519 fields of RFC 8731 §3.1) and key derivation (RFC 4253 §7.2).
5 * Both use SHA-256, the hash of `curve25519-sha256`.
6 */
7
8 import { createHash } from "node:crypto";
9 import { sshString } from "./wire.ts";
10
11 export interface ExchangeHashInput {
12 /** V_C and V_S: the version lines without CR LF. */
13 clientVersion: Uint8Array;
14 serverVersion: Uint8Array;
15 /** I_C and I_S: the complete KEXINIT payloads, message byte included. */
16 peerKexInit: Uint8Array;
17 myKexInit: Uint8Array;
18 /** K_S: the host key blob. */
19 hostBlob: Uint8Array;
20 /** Q_C and Q_S: the raw 32-byte X25519 public keys. */
21 clientPublic: Uint8Array;
22 serverPublic: Uint8Array;
23 /** K as an SSH mpint, length prefix included. */
24 kMpint: Uint8Array;
25 }
26
27 export function exchangeHashOf(i: ExchangeHashInput): Uint8Array {
28 return new Uint8Array(
29 createHash("sha256")
30 .update(sshString(i.clientVersion))
31 .update(sshString(i.serverVersion))
32 .update(sshString(i.peerKexInit))
33 .update(sshString(i.myKexInit))
34 .update(sshString(i.hostBlob))
35 .update(sshString(i.clientPublic))
36 .update(sshString(i.serverPublic))
37 .update(i.kMpint)
38 .digest(),
39 );
40 }
41
42 /** `HASH(K || H || letter || session_id)`, extended with `HASH(K || H || out)` to `length`. */
43 export function deriveKey(
44 kMpint: Uint8Array,
45 exchangeHash: Uint8Array,
46 letter: string,
47 sessionId: Uint8Array,
48 length: number,
49 ): Uint8Array {
50 let out: Buffer = createHash("sha256")
51 .update(kMpint)
52 .update(exchangeHash)
53 .update(letter)
54 .update(sessionId)
55 .digest();
56 while (out.length < length) {
57 out = Buffer.concat([
58 out,
59 createHash("sha256").update(kMpint).update(exchangeHash).update(out).digest(),
60 ]);
61 }
62 return new Uint8Array(out.subarray(0, length));
63 }
src/ssh/limits.ts added +95
@@ -0,0 +1,95 @@
1 /**
2 * What the SSH door admits before any key work: a cap on open connections,
3 * a cap per address, and a rate on new handshakes. Each handshake costs an
4 * X25519 exchange and an Ed25519 signature on the forge's one thread, so a
5 * refusal here happens before a `Connection` (and its transport) exists.
6 *
7 * Whose address this is: SSH is raw TCP with no header to carry a client
8 * address, so it is always the socket's peer — the proxy's, when a proxy or
9 * load balancer fronts the port, and then every client behind it shares one
10 * per-address budget. The `FORGE_TRUSTED_PROXIES` rule of `src/http/peer.ts`
11 * has nothing to read here; which is why the per-address numbers are
12 * generous and the global ones are the real floor. An IPv6 peer is keyed by
13 * its /64.
14 *
15 * Pure state in, verdict out: the clock is passed in, nothing is timed here.
16 */
17
18 import { addressBytes } from "../kernel/cidr.ts";
19
20 /** Open connections, all addresses together. */
21 export const MAX_CONNECTIONS = 256;
22 /** Open connections from one address. */
23 export const MAX_CONNECTIONS_PER_ADDRESS = 32;
24 /** New connections (each a handshake) from one address per window. */
25 export const MAX_HANDSHAKES_PER_ADDRESS = 20;
26 /** New connections from everyone per window. */
27 export const MAX_HANDSHAKES = 120;
28 export const HANDSHAKE_WINDOW_MS = 10_000;
29
30 export interface SshLimitValues {
31 maxConnections: number;
32 maxPerAddress: number;
33 handshakesPerAddress: number;
34 handshakes: number;
35 windowMs: number;
36 }
37
38 export const DEFAULT_LIMITS: SshLimitValues = {
39 maxConnections: MAX_CONNECTIONS,
40 maxPerAddress: MAX_CONNECTIONS_PER_ADDRESS,
41 handshakesPerAddress: MAX_HANDSHAKES_PER_ADDRESS,
42 handshakes: MAX_HANDSHAKES,
43 windowMs: HANDSHAKE_WINDOW_MS,
44 };
45
46 export type Refusal = "too many connections" | "too many from this address" | "too many handshakes";
47
48 /** The key a peer is limited under: the address, an IPv6 one cut to its /64. */
49 export function addressKey(peer: string): string {
50 const bytes = addressBytes(peer);
51 if (bytes === undefined || bytes.length !== 16) return peer;
52 return Array.from(bytes.subarray(0, 8), (b) => b.toString(16).padStart(2, "0")).join("");
53 }
54
55 export class ConnectionLimits {
56 private open = 0;
57 private readonly perAddress = new Map<string, number>();
58 private readonly starts = new Map<string, number[]>();
59 private all: number[] = [];
60
61 constructor(private readonly limits: SshLimitValues = DEFAULT_LIMITS) {}
62
63 /** Admit a new connection (and count it), or say why not. */
64 admit(peer: string, now: number): { key: string } | { refused: Refusal } {
65 const key = addressKey(peer);
66 const l = this.limits;
67 if (this.open >= l.maxConnections) return { refused: "too many connections" };
68 if ((this.perAddress.get(key) ?? 0) >= l.maxPerAddress) return { refused: "too many from this address" };
69 const since = now - l.windowMs;
70 this.all = this.all.filter((t) => t > since);
71 const mine = (this.starts.get(key) ?? []).filter((t) => t > since);
72 if (this.all.length >= l.handshakes || mine.length >= l.handshakesPerAddress) return { refused: "too many handshakes" };
73 this.all.push(now);
74 mine.push(now);
75 this.starts.set(key, mine);
76 if (this.starts.size > 4096) this.sweep(since);
77 this.open++;
78 this.perAddress.set(key, (this.perAddress.get(key) ?? 0) + 1);
79 return { key };
80 }
81
82 /** A connection admitted under `key` has ended. */
83 release(key: string): void {
84 this.open = Math.max(0, this.open - 1);
85 const left = (this.perAddress.get(key) ?? 1) - 1;
86 if (left <= 0) this.perAddress.delete(key);
87 else this.perAddress.set(key, left);
88 }
89
90 private sweep(since: number): void {
91 for (const [key, times] of this.starts) {
92 if (times.every((t) => t <= since)) this.starts.delete(key);
93 }
94 }
95 }
src/ssh/server.ts +19 −1
@@ -11,6 +11,7 @@ import type { Log } from "../kernel/log.ts";
1111 import { Auth } from "./auth.ts";
1212 import { Channels, type SshSession } from "./channels.ts";
1313 import { loadOrCreateHostKey, type SshPublicKey } from "./keys.ts";
14 import { ConnectionLimits, DEFAULT_LIMITS, type SshLimitValues } from "./limits.ts";
1415 import { Transport } from "./transport.ts";
1516 import { DISCONNECT, WireError } from "./wire.ts";
1617
@@ -42,6 +43,10 @@ export interface SshServerOptions {
4243 loginGraceMs?: number;
4344 /** Signature-bearing auth attempts allowed per connection. @default 6 */
4445 maxAuthAttempts?: number;
46 /** Caps on connections and handshakes, refused before any key work. @default DEFAULT_LIMITS */
47 limits?: Partial<SshLimitValues>;
48 /** When the server starts a rekey itself. @default 1 GiB or an hour */
49 rekey?: { rekeyBytes?: number; rekeyMs?: number; now?: () => number };
4550 }
4651
4752 export interface SshServer {
@@ -93,6 +98,7 @@ class Connection {
9398 {
9499 hostKey,
95100 serverVersion: options.serverVersion ?? "SSH-2.0-forge",
101 ...(options.rekey ?? {}),
96102 },
97103 );
98104 this.auth = new Auth({
@@ -255,6 +261,7 @@ class Connection {
255261 export function serveSsh(options: SshServerOptions): Promise<SshServer> {
256262 const hostKey = loadOrCreateHostKey(options.hostKeyDir);
257263 const connections = new Set<Connection>();
264 const limits = new ConnectionLimits({ ...DEFAULT_LIMITS, ...options.limits });
258265 // Whoever is waiting for the last connection to leave, once draining.
259266 const drained: (() => void)[] = [];
260267 const drop = (connection: Connection) => {
@@ -267,7 +274,18 @@ export function serveSsh(options: SshServerOptions): Promise<SshServer> {
267274 port: options.port,
268275 socket: {
269276 open(socket) {
270 const connection = new Connection(socket, options, hostKey, drop);
277 // Before a Connection exists, so a refused peer costs no key work.
278 // `remoteAddress` is the TCP peer: a proxy's, if one fronts this port.
279 const verdict = limits.admit(socket.remoteAddress, Date.now());
280 if ("refused" in verdict) {
281 options.log.debug("ssh refused", { peer: socket.remoteAddress, why: verdict.refused });
282 socket.end(`${verdict.refused}\r\n`);
283 return;
284 }
285 const connection = new Connection(socket, options, hostKey, (gone) => {
286 limits.release(verdict.key);
287 drop(gone);
288 });
271289 socket.data = connection;
272290 connections.add(connection);
273291 connection.start();
src/ssh/transport.ts +49 −40
@@ -11,7 +11,6 @@
1111 import {
1212 createCipheriv,
1313 createDecipheriv,
14 createHash,
1514 createPublicKey,
1615 diffieHellman,
1716 generateKeyPairSync,
@@ -19,6 +18,7 @@ import {
1918 randomFillSync,
2019 } from "node:crypto";
2120 import type { Log } from "../kernel/log.ts";
21 import { deriveKey, exchangeHashOf } from "./kex.ts";
2222 import { type HostKey, SERVER_SIG_ALGS, signWithHostKey } from "./keys.ts";
2323 import {
2424 ByteQueue,
@@ -27,7 +27,6 @@ import {
2727 fromUtf8,
2828 MSG,
2929 Reader,
30 sshString,
3130 utf8,
3231 WireError,
3332 Writer,
@@ -94,9 +93,20 @@ export interface TransportHost {
9493 log: Log;
9594 }
9695
96 /** RFC 4253 §9: rekey after this much traffic (both ways) under one set of keys. */
97 export const REKEY_BYTES = 1024 * 1024 * 1024;
98 /** And after this long under one set of keys. */
99 export const REKEY_MS = 60 * 60 * 1000;
100
97101 export interface TransportOptions {
98102 readonly hostKey: HostKey;
99103 readonly serverVersion: string;
104 /** @default REKEY_BYTES */
105 readonly rekeyBytes?: number;
106 /** @default REKEY_MS */
107 readonly rekeyMs?: number;
108 /** @default Date.now */
109 readonly now?: () => number;
100110 }
101111
102112 function incrementInvocationCounter(iv: Uint8Array): void {
@@ -156,6 +166,10 @@ export class Transport {
156166 * forbids non-KEX traffic between our KEXINIT and our NEWKEYS). */
157167 private readonly parked: Uint8Array[] = [];
158168
169 /** Bytes moved, either way, since the keys now in use took effect. */
170 private volume = 0;
171 private keyedAt = 0;
172
159173 constructor(
160174 private readonly host: TransportHost,
161175 private readonly options: TransportOptions,
@@ -195,6 +209,7 @@ export class Transport {
195209 /** Send an upper-layer payload, parking it if a key exchange is running. */
196210 send(payload: Uint8Array): void {
197211 if (this.dead) return;
212 this.rekeyIfDue();
198213 if (this.kexActive) {
199214 this.parked.push(payload);
200215 return;
@@ -202,6 +217,21 @@ export class Transport {
202217 this.sendNow(payload);
203218 }
204219
220 /**
221 * The server starts a rekey itself once the keys have carried 1 GiB or an
222 * hour (checked as traffic passes: an idle line has nothing to protect).
223 * The peer's own KEXINIT meets ours in the same exchange.
224 */
225 private rekeyIfDue(): void {
226 if (this.dead || this.kexActive || !this.initialKexDone) return;
227 const now = (this.options.now ?? Date.now)();
228 if (
229 this.volume >= (this.options.rekeyBytes ?? REKEY_BYTES) ||
230 now - this.keyedAt >= (this.options.rekeyMs ?? REKEY_MS)
231 )
232 this.beginKex();
233 }
234
205235 /** Tell the peer we did not understand the packet at `seq` (RFC 4253 §11.4). */
206236 unimplemented(seq: number): void {
207237 this.send(new Writer(8).u8(MSG.UNIMPLEMENTED).u32(seq).build());
@@ -310,6 +340,7 @@ export class Transport {
310340 }
311341 payload = new Uint8Array(plain.subarray(1, length - padding));
312342 this.q.skip(total);
343 this.volume += total;
313344 incrementInvocationCounter(keys.iv);
314345 } else {
315346 // Cipher `none`: 4 + packet_length must be a multiple of 8.
@@ -337,6 +368,7 @@ export class Transport {
337368 this.seqIn = (this.seqIn + 1) >>> 0;
338369 this.packetsIn++;
339370 this.handle(payload, seq);
371 this.rekeyIfDue();
340372 return !this.dead;
341373 }
342374
@@ -368,6 +400,7 @@ export class Transport {
368400 frame.set(payload, 5);
369401 randomFillSync(frame, 5 + payload.length, padding);
370402 }
403 this.volume += frame.length;
371404 this.host.write(frame);
372405 this.seqOut = (this.seqOut + 1) >>> 0;
373406 }
@@ -622,23 +655,16 @@ export class Transport {
622655
623656 const hostBlob = this.options.hostKey.publicKey.blob;
624657 const kMpint = new Writer(48).mpint(shared).build();
625 const clientVersion = this.clientVersion ?? new Uint8Array(0);
626 const myKexInit = this.myKexInit ?? new Uint8Array(0);
627 const peerKexInit = this.peerKexInit ?? new Uint8Array(0);
628 // H hashes I_C and I_S as the *complete KEXINIT payloads* (message byte
629 // 20 included) — the exact bytes exchanged, never re-serialised.
630 const exchangeHash = new Uint8Array(
631 createHash("sha256")
632 .update(sshString(clientVersion))
633 .update(sshString(this.serverVersion))
634 .update(sshString(peerKexInit))
635 .update(sshString(myKexInit))
636 .update(sshString(hostBlob))
637 .update(sshString(clientPublic))
638 .update(sshString(serverPublic))
639 .update(kMpint)
640 .digest(),
641 );
658 const exchangeHash = exchangeHashOf({
659 clientVersion: this.clientVersion ?? new Uint8Array(0),
660 serverVersion: this.serverVersion,
661 peerKexInit: this.peerKexInit ?? new Uint8Array(0),
662 myKexInit: this.myKexInit ?? new Uint8Array(0),
663 hostBlob,
664 clientPublic,
665 serverPublic,
666 kMpint,
667 });
642668 // The first exchange hash becomes the session id and never changes, even
643669 // across rekeys (RFC 4253 §7.2).
644670 this.sessionIdBytes ??= exchangeHash;
@@ -654,27 +680,8 @@ export class Transport {
654680 .build(),
655681 );
656682
657 const derive = (letter: string, length: number): Uint8Array => {
658 // HASH(K || H || letter || session_id) where K is the *mpint* encoding
659 // but H and session_id are raw. The asymmetry is real (RFC 4253 §7.2).
660 let out: Buffer = createHash("sha256")
661 .update(kMpint)
662 .update(exchangeHash)
663 .update(letter)
664 .update(sessionId)
665 .digest();
666 while (out.length < length) {
667 out = Buffer.concat([
668 out,
669 createHash("sha256")
670 .update(kMpint)
671 .update(exchangeHash)
672 .update(out)
673 .digest(),
674 ]);
675 }
676 return new Uint8Array(out.subarray(0, length));
677 };
683 const derive = (letter: string, length: number): Uint8Array =>
684 deriveKey(kMpint, exchangeHash, letter, sessionId, length);
678685
679686 const inSpec = CIPHER_SPECS[this.negotiated.cipherIn]!;
680687 const outSpec = CIPHER_SPECS[this.negotiated.cipherOut]!;
@@ -729,6 +736,8 @@ export class Transport {
729736 if (!this.newKeysSent || !this.newKeysReceived) return;
730737 this.kexActive = false;
731738 this.initialKexDone = true;
739 this.volume = 0;
740 this.keyedAt = (this.options.now ?? Date.now)();
732741 while (this.parked.length > 0 && !this.dead && !this.kexActive) {
733742 // SAFETY: length > 0 was just checked.
734743 this.sendNow(this.parked.shift()!);
src/tui/ssh-guest-rate.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * A rate on what a guest (a key with no account) may say over ssh: `tell` and
3 * `talk` each start work, and a key costs a guest nothing to make, so the
4 * limit is on the address beside the key, with a cap on all guests together.
5 * The address is the TCP peer's (a proxy's, when one fronts the port).
6 * Pure: the clock is passed in.
7 */
8
9 export const GUEST_SAYS_PER_WINDOW = 5;
10 export const GUEST_SAYS_ALL_PER_WINDOW = 60;
11 export const GUEST_WINDOW_MS = 60_000;
12
13 export const GUEST_RATE_WORDS = "slow down — a guest may tell or talk only a few times a minute; sign in to lift that";
14
15 export class GuestRate {
16 private all: number[] = [];
17 private readonly by = new Map<string, number[]>();
18
19 constructor(
20 private readonly each = GUEST_SAYS_PER_WINDOW,
21 private readonly total = GUEST_SAYS_ALL_PER_WINDOW,
22 private readonly windowMs = GUEST_WINDOW_MS,
23 ) {}
24
25 /** Count one say by `who` (key fingerprint and address both); false when over a limit. */
26 allow(who: readonly string[], now: number): boolean {
27 const since = now - this.windowMs;
28 this.all = this.all.filter((t) => t > since);
29 if (this.all.length >= this.total) return false;
30 const lists = who.map((w) => (this.by.get(w) ?? []).filter((t) => t > since));
31 if (lists.some((l) => l.length >= this.each)) return false;
32 who.forEach((w, i) => {
33 lists[i]!.push(now);
34 this.by.set(w, lists[i]!);
35 });
36 this.all.push(now);
37 if (this.by.size > 4096) for (const [k, v] of this.by) if (v.every((t) => t <= since)) this.by.delete(k);
38 return true;
39 }
40 }
src/tui/ssh.ts +17
@@ -32,6 +32,7 @@ import { appForLogin, attachAppTty } from "../apps/tty.ts";
3232 import { servePulls } from "../apps/tty_pulls.ts";
3333 import type { Terminal } from "./term/terminal.ts";
3434 import type { TuiIdentity } from "./session.ts";
35 import { GUEST_RATE_WORDS, GuestRate } from "./ssh-guest-rate.ts";
3536 import { parseTalkAgain, parseTalkCommand, serveTalk, serveTalkAgain } from "./ssh-talk.ts";
3637 import { parseTellCommand, serveTell } from "./ssh-tell.ts";
3738
@@ -202,12 +203,25 @@ const appExecs: Record<string, typeof servePulls> = Object.assign(
202203 { pulls: servePulls },
203204 );
204205
206 /** One rate per forge, however many times the session handler is made. */
207 const guestRates = new WeakMap<Forge, GuestRate>();
208
205209 /**
206210 * What a forge does with an inbound SSH session: a shell is the TUI, an exec
207211 * is git, `tell`, or nothing. Never throws — a fault here would take the listener with
208212 * it, and one bad client must not.
209213 */
210214 export function forgeSshSession(forge: Forge): (session: SshSession) => void {
215 let guests = guestRates.get(forge);
216 if (guests === undefined) guestRates.set(forge, (guests = new GuestRate()));
217 // A guest's tell and talk start work; they share one rate (see ssh-guest-rate.ts).
218 const guestLimited = (session: SshSession, user: unknown): boolean => {
219 if (user !== undefined) return false;
220 if (guests.allow([`key:${session.key.fingerprint}`, `addr:${session.remoteAddress}`], Date.now())) return false;
221 session.writeStderr(`forge: ${GUEST_RATE_WORDS}${CRLF}`);
222 session.exit(1);
223 return true;
224 };
211225 return (session) => {
212226 try {
213227 const identity = identityFor(forge, session);
@@ -233,6 +247,7 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
233247 // app's crew, said from the app (through the terminal's own gate).
234248 const said = parseTellCommand(session.command);
235249 if (said !== undefined) {
250 if (guestLimited(session, identity.user)) return;
236251 void serveTell(forge, session, identity.user, said, door).catch(
237252 (error: unknown) => {
238253 forge.log.error("tell over ssh failed", { error: String(error) });
@@ -245,11 +260,13 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
245260 // `talk <words>`: the forge's own agent, as the person.
246261 const talked = parseTalkCommand(session.command);
247262 if (talked !== undefined && door === undefined) {
263 if (guestLimited(session, identity.user)) return;
248264 serveTalk(forge, session, identity.user, talked);
249265 return;
250266 }
251267 const again = parseTalkAgain(session.command);
252268 if (again !== undefined && door === undefined) {
269 if (guestLimited(session, identity.user)) return;
253270 serveTalkAgain(forge, session, identity.user, again.how, again.words);
254271 return;
255272 }
src/web/studio/home.ts +2 −2
@@ -913,8 +913,8 @@ bun run src/main.ts</code></pre>
913913 ${when(
914914 ssh,
915915 (command) => html`<div class="stack-tight">
916 ${terminalLine({ command })}
917 <p class="muted small">Or visit this forge from your terminal: your key is your identity.</p>
916 ${terminalLine({ command }, ctx.app.config.sshHostKey)}
917 <p class="muted small">Or visit this forge from your terminal: your key is your identity, and the host key above is yours to check.</p>
918918 </div>`,
919919 )}
920920 ${packPresses()}
outdated

Principles 1 and 8: a second muted explanation under 'Or visit this forge from your terminal', so the block explains itself twice, with hand-rolled code markup instead of a named component. Make the check part of the terminal line, one sentence, existing component.

test/door-ssh-fingerprint.test.ts added +41
@@ -0,0 +1,41 @@
1 /** The public home shows the ssh command, the host key fingerprint, once. */
2 import { afterAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
7 import { serve } from "../src/http/server.ts";
8
9 const dirs: string[] = [];
10 afterAll(async () => {
11 for (const d of dirs) await rm(d, { recursive: true, force: true });
12 });
13
14 async function home(sshPort?: number, fingerprint?: string) {
15 const dataDir = await mkdtemp(join(tmpdir(), "forge-door-fp-"));
16 dirs.push(dataDir);
17 const app = createApp({ dataDir, port: 0, host: "forge.example" });
18 if (sshPort !== undefined) app.config.sshPort = sshPort;
19 if (fingerprint !== undefined) app.config.sshHostKey = fingerprint;
20 const servers = await serve(app);
21 try {
22 return { text: await (await fetch(`${servers.url}/`)).text() };
23 } finally {
24 servers.stop();
25 app.close();
26 }
27 }
28
29 test("a stranger sees the fingerprint, once", async () => {
30 const fp = "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
31 const { text } = await home(2222, fp);
32 expect(text).toContain(`host key ${fp}`);
33 expect(text.split(`host key ${fp}`).length).toBe(2);
34 expect(text).not.toContain("ssh-keyscan");
35 });
36
37 test("with the terminal off there is no fingerprint", async () => {
38 const { text } = await home();
39 expect(text).not.toContain("ssh-keyscan");
40 expect(text).not.toContain("host key SHA256");
41 });
test/ssh-door-limits.test.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * The door's limits, before any key work: past the global cap, the
3 * per-address cap and the handshake rate a connection is refused with a line
4 * of text and closed — no version line, no KEXINIT, no signature.
5 */
6
7 import { afterEach, describe, expect, test } from "bun:test";
8 import { mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createLog } from "../src/kernel/log.ts";
12 import { addressKey, ConnectionLimits, DEFAULT_LIMITS, type SshLimitValues } from "../src/ssh/limits.ts";
13 import { serveSsh, type SshServer } from "../src/ssh/server.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 describe("ConnectionLimits", () => {
19 const small = { maxConnections: 3, maxPerAddress: 2, handshakesPerAddress: 100, handshakes: 100, windowMs: 1000 };
20
21 test("the global cap refuses, and a release makes room", () => {
22 const l = new ConnectionLimits(small);
23 const a = l.admit("10.0.0.1", 0);
24 l.admit("10.0.0.2", 0);
25 l.admit("10.0.0.3", 0);
26 expect(l.admit("10.0.0.4", 0)).toEqual({ refused: "too many connections" });
27 if ("key" in a) l.release(a.key);
28 expect("key" in l.admit("10.0.0.4", 0)).toBe(true);
29 });
30
31 test("the per-address cap refuses one address and not another", () => {
32 const l = new ConnectionLimits(small);
33 l.admit("10.0.0.1", 0);
34 l.admit("10.0.0.1", 0);
35 expect(l.admit("10.0.0.1", 0)).toEqual({ refused: "too many from this address" });
36 expect("key" in l.admit("10.0.0.2", 0)).toBe(true);
37 });
38
39 test("the handshake rate refuses per address and overall, then recovers with the window", () => {
40 const l = new ConnectionLimits({ maxConnections: 100, maxPerAddress: 100, handshakesPerAddress: 2, handshakes: 3, windowMs: 1000 });
41 for (const t of [0, 1]) {
42 const v = l.admit("10.0.0.1", t);
43 if ("key" in v) l.release(v.key);
44 }
45 expect(l.admit("10.0.0.1", 2)).toEqual({ refused: "too many handshakes" });
46 expect("key" in l.admit("10.0.0.2", 3)).toBe(true);
47 expect(l.admit("10.0.0.3", 4)).toEqual({ refused: "too many handshakes" });
48 expect("key" in l.admit("10.0.0.1", 1500)).toBe(true);
49 });
50
51 test("an IPv6 address is limited by its /64", () => {
52 expect(addressKey("2001:db8:1:2:aaaa::1")).toBe(addressKey("2001:db8:1:2:bbbb::9"));
53 expect(addressKey("2001:db8:1:3::1")).not.toBe(addressKey("2001:db8:1:2::1"));
54 expect(addressKey("10.0.0.1")).toBe("10.0.0.1");
55 });
56
57 test("the defaults are bounded", () => {
58 expect(DEFAULT_LIMITS.maxPerAddress).toBeLessThan(DEFAULT_LIMITS.maxConnections);
59 expect(DEFAULT_LIMITS.handshakesPerAddress).toBeLessThan(DEFAULT_LIMITS.handshakes);
60 });
61 });
62
63 describe("the listener", () => {
64 const roots: string[] = [];
65 let server: SshServer | undefined;
66 afterEach(() => {
67 server?.stop();
68 server = undefined;
69 for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true });
70 });
71
72 async function listen(limits: Partial<SshLimitValues>) {
73 const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "ssh-limits-"));
74 roots.push(dir);
75 server = await serveSsh({
76 port: 0,
77 hostname: "127.0.0.1",
78 hostKeyDir: dir,
79 log: createLog("ssh-limits"),
80 authorize: () => true,
81 onSession: () => {},
82 limits,
83 });
84 return server;
85 }
86
87 /** Open a connection, say nothing, and report what the server sent first. */
88 function open(port: number): Promise<{ first: string; closed: Promise<void>; end(): void }> {
89 return new Promise((resolve, reject) => {
90 let first = "";
91 let seen = false;
92 let closedNow: () => void = () => {};
93 const closed = new Promise<void>((r) => (closedNow = r));
94 let sock: Bun.Socket | undefined;
95 const settle = () => {
96 if (seen) return;
97 seen = true;
98 resolve({ first, closed, end: () => sock?.end() });
99 };
100 Bun.connect({
101 hostname: "127.0.0.1",
102 port,
103 socket: {
104 open(s) { sock = s; setTimeout(settle, 150); /* real-timer: wait for a banner that must not come */ },
105 data(_s, d) { first += new TextDecoder().decode(d); },
106 close() { closedNow(); settle(); },
107 error() { closedNow(); settle(); },
108 },
109 }).catch(reject);
110 });
111 }
112
113 const refusal = /too many/;
114
115 test("past the global cap the next connection is refused with no handshake", async () => {
116 const s = await listen({ maxConnections: 2, maxPerAddress: 100, handshakesPerAddress: 100, handshakes: 100 });
117 const held = [await open(s.port), await open(s.port)];
118 for (const h of held) expect(h.first.startsWith("SSH-2.0-")).toBe(true);
119 const refused = await open(s.port);
120 expect(refused.first).toMatch(refusal);
121 expect(refused.first).not.toContain("SSH-2.0-");
122 await refused.closed;
123 expect(s.sessions()).toBe(2);
124 held[0]!.end();
125 await held[0]!.closed;
126 await Bun.sleep(50); // real-timer: the server frees a closed socket on its own loop turn
127 expect((await open(s.port)).first.startsWith("SSH-2.0-")).toBe(true);
128 });
129
130 test("past the per-address cap the next connection from that address is refused", async () => {
131 const s = await listen({ maxConnections: 100, maxPerAddress: 2, handshakesPerAddress: 100, handshakes: 100 });
132 await open(s.port);
133 await open(s.port);
134 const refused = await open(s.port);
135 expect(refused.first).toMatch(/from this address/);
136 expect(refused.first).not.toContain("SSH-2.0-");
137 expect(s.sessions()).toBe(2);
138 });
139
140 test("past the handshake rate the next connection is refused, though none is open", async () => {
141 const s = await listen({ maxConnections: 100, maxPerAddress: 100, handshakesPerAddress: 3, handshakes: 100, windowMs: 60_000 });
142 for (let i = 0; i < 3; i++) {
143 const c = await open(s.port);
144 expect(c.first.startsWith("SSH-2.0-")).toBe(true);
145 c.end();
146 await c.closed;
147 }
148 await Bun.sleep(50); // real-timer: the server frees a closed socket on its own loop turn
149 const refused = await open(s.port);
150 expect(refused.first).toMatch(/too many handshakes/);
151 expect(refused.first).not.toContain("SSH-2.0-");
152 });
153 });
test/ssh-framing-refusals.test.ts +2
@@ -35,6 +35,8 @@ beforeAll(async () => {
3535 log: createLog("ssh-framing-test"),
3636 authorize: () => true,
3737 onSession: () => {},
38 // Many short connections from loopback: this file is not about the limits.
39 limits: { handshakesPerAddress: 10_000, handshakes: 10_000 },
3840 });
3941 });
4042
test/ssh-fuzz.test.ts added +181
@@ -0,0 +1,181 @@
1 /**
2 * Fuzzing the SSH door's own protocol code: random and mutated bytes into the
3 * wire primitives and into a pre-auth transport. Nothing throws past the
4 * connection, the unauthenticated queue never holds more than the RFC 4253
5 * §6.1 35 000-byte packet, and a stream that stays bad always ends in a
6 * DISCONNECT. Seeded: a failure names the seed (`FUZZ_SEED=<n>` reruns it).
7 */
8
9 import { describe, expect, test } from "bun:test";
10 import { mkdtempSync } 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 { Transport } from "../src/ssh/transport.ts";
16 import { ByteQueue, concat, MSG, Reader, WireError, Writer } from "../src/ssh/wire.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { int, rng, SEED, type Rand } from "./helpers/fuzz.ts";
19
20 quietLogs();
21
22 const RUNS = Number(process.env["FUZZ_RUNS"] ?? 300);
23 const CAP = 35_000;
24 const hostKey = loadOrCreateHostKey(mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "ssh-fuzz-")));
25
26 const bytes = (r: Rand, n: number): Uint8Array => Uint8Array.from({ length: n }, () => int(r, 256));
27
28 /** Flip, drop, insert and overwrite bytes: a valid packet turned slightly wrong. */
29 function mutate(r: Rand, input: Uint8Array): Uint8Array {
30 const out = Array.from(input);
31 for (let n = int(r, 6) + 1; n > 0; n--) {
32 const at = int(r, out.length + 1);
33 switch (int(r, 4)) {
34 case 0: out[at] = (out[at] ?? 0) ^ (1 << int(r, 8)); break;
35 case 1: out.splice(at, 1 + int(r, 4)); break;
36 case 2: out.splice(at, 0, ...bytes(r, 1 + int(r, 8))); break;
37 default: out[at] = int(r, 256);
38 }
39 }
40 return Uint8Array.from(out);
41 }
42
43 /** A plaintext binary packet (RFC 4253 §6) around `payload`. */
44 function packet(payload: Uint8Array): Uint8Array {
45 let pad = (8 - ((5 + payload.length) % 8)) % 8;
46 if (pad < 4) pad += 8;
47 const length = 1 + payload.length + pad;
48 const out = new Uint8Array(4 + length);
49 new DataView(out.buffer).setUint32(0, length);
50 out[4] = pad;
51 out.set(payload, 5);
52 return out;
53 }
54
55 const KEXINIT = new Writer(512)
56 .u8(MSG.KEXINIT).raw(new Uint8Array(16))
57 .nameList(["curve25519-sha256", "[email protected]"])
58 .nameList(["ssh-ed25519"])
59 .nameList(["[email protected]"]).nameList(["[email protected]"])
60 .nameList(["hmac-sha2-256"]).nameList(["hmac-sha2-256"])
61 .nameList(["none"]).nameList(["none"])
62 .nameList([]).nameList([])
63 .bool(false).u32(0)
64 .build();
65
66 const VERSION = new TextEncoder().encode("SSH-2.0-fuzz\r\n");
67
68 function harness() {
69 const out: Uint8Array[] = [];
70 const closed: string[] = [];
71 const faults: string[] = [];
72 const transport = new Transport(
73 {
74 write: (d) => out.push(d.slice()),
75 payload: () => {},
76 closed: (why) => closed.push(why),
77 log: { ...createLog("ssh-fuzz"), error: (m: string) => void faults.push(m) } as ReturnType<typeof createLog>,
78 },
79 { hostKey, serverVersion: "SSH-2.0-forge-fuzz" },
80 );
81 transport.start();
82 const queued = (): number => (transport as unknown as { q: ByteQueue }).q.size;
83 const wroteDisconnect = (): boolean => {
84 const all = concat(...out);
85 // A DISCONNECT is a plaintext packet whose payload starts with MSG.DISCONNECT.
86 for (let at = all.indexOf(0x0a) + 1; at + 6 <= all.length; ) {
87 const len = new DataView(all.buffer, all.byteOffset + at).getUint32(0);
88 if (all[at + 5] === MSG.DISCONNECT) return true;
89 at += 4 + len;
90 }
91 return false;
92 };
93 return { transport, closed, faults, queued, wroteDisconnect };
94 }
95
96 describe("wire primitives", () => {
97 test("a Reader over random bytes throws only WireError", () => {
98 const r = rng(SEED);
99 for (let i = 0; i < RUNS * 4; i++) {
100 const reader = new Reader(bytes(r, int(r, 64)));
101 try {
102 for (let n = 0; n < 8; n++) {
103 [() => reader.u8(), () => reader.u32(), () => reader.string(), () => reader.nameList(), () => reader.bool(), () => reader.bytes(int(r, 100))][int(r, 6)]!();
104 }
105 reader.end();
106 } catch (e) {
107 expect(e).toBeInstanceOf(WireError);
108 }
109 }
110 });
111
112 test("a ByteQueue holds exactly what was pushed, whatever the chunking", () => {
113 const r = rng(SEED + 1);
114 for (let i = 0; i < RUNS; i++) {
115 const q = new ByteQueue();
116 const all: number[] = [];
117 for (let n = int(r, 12); n > 0; n--) {
118 const chunk = bytes(r, int(r, 3000));
119 q.push(chunk);
120 all.push(...chunk);
121 const skip = int(r, q.size + 1);
122 all.splice(0, skip);
123 q.skip(skip);
124 expect(q.size).toBe(all.length);
125 }
126 expect(Array.from(q.peek(q.size))).toEqual(all);
127 }
128 });
129 });
130
131 describe("pre-auth transport", () => {
132 test("random and mutated packets never throw, never queue past the cap, and end in a disconnect", () => {
133 const r = rng(SEED + 2);
134 for (let i = 0; i < RUNS; i++) {
135 const h = harness();
136 const stream: Uint8Array[] = [VERSION];
137 for (let n = int(r, 5); n > 0; n--) {
138 const kind = int(r, 4);
139 stream.push(
140 kind === 0 ? bytes(r, int(r, 300))
141 : kind === 1 ? mutate(r, packet(KEXINIT))
142 : kind === 2 ? packet(bytes(r, int(r, 200)))
143 : mutate(r, packet(new Writer(64).u8(MSG.KEX_ECDH_INIT).string(bytes(r, int(r, 40))).build())),
144 );
145 }
146 // Whatever is half-sent is finished by this: past 35 000 bytes the next
147 // length header is 0xFFFFFFFF, which must end the connection.
148 stream.push(new Uint8Array(CAP + 8).fill(0xff));
149 for (const part of stream) {
150 for (let at = 0; at < part.length && h.closed.length === 0; at += 4096) {
151 expect(() => h.transport.receive(part.subarray(at, at + 4096))).not.toThrow();
152 if (h.closed.length === 0) expect(h.queued()).toBeLessThanOrEqual(CAP + 4);
153 }
154 }
155 expect(h.faults).toEqual([]);
156 expect(h.closed.length).toBe(1);
157 }
158 });
159
160 test("a version line is accepted whole or refused, never grown without bound", () => {
161 const r = rng(SEED + 3);
162 for (let i = 0; i < RUNS; i++) {
163 const h = harness();
164 for (let n = 0; n < 40 && h.closed.length === 0; n++) {
165 const noise = bytes(r, 1 + int(r, 200)).map((b) => (b === 0x0a ? 0x20 : b));
166 h.transport.receive(noise);
167 expect(h.queued()).toBeLessThanOrEqual(1024 + 256);
168 }
169 expect(h.closed.length).toBe(1);
170 }
171 });
172
173 test("a length header past the cap is a DISCONNECT, not an allocation", () => {
174 for (const length of [CAP + 1, 262_144, 0x7fffffff, 0xffffffff]) {
175 const h = harness();
176 h.transport.receive(concat(VERSION, new Uint8Array([length >>> 24, (length >>> 16) & 255, (length >>> 8) & 255, length & 255])));
177 expect(h.closed.length).toBe(1);
178 expect(h.wroteDisconnect()).toBe(true);
179 }
180 });
181 });
test/ssh-guest-rate.test.ts added +23
@@ -0,0 +1,23 @@
1 import { expect, test } from "bun:test";
2 import { GuestRate } from "../src/tui/ssh-guest-rate.ts";
3
4 test("a guest key is allowed a few says a window, then refused, then allowed again", () => {
5 const g = new GuestRate(2, 100, 1000);
6 expect(g.allow(["k:a", "a:1"], 0)).toBe(true);
7 expect(g.allow(["k:a", "a:1"], 1)).toBe(true);
8 expect(g.allow(["k:a", "a:1"], 2)).toBe(false);
9 expect(g.allow(["k:a", "a:1"], 1500)).toBe(true);
10 });
11
12 test("a fresh key from the same address does not get a fresh budget", () => {
13 const g = new GuestRate(2, 100, 1000);
14 g.allow(["k:a", "a:1"], 0);
15 g.allow(["k:b", "a:1"], 0);
16 expect(g.allow(["k:c", "a:1"], 0)).toBe(false);
17 });
18
19 test("all guests together have a cap", () => {
20 const g = new GuestRate(5, 3, 1000);
21 for (let i = 0; i < 3; i++) expect(g.allow([`k:${i}`, `a:${i}`], 0)).toBe(true);
22 expect(g.allow(["k:9", "a:9"], 0)).toBe(false);
23 });
test/ssh-known-answers.test.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * Known answers for the key exchange. The X25519 vectors and the mpint
3 * examples are the RFCs'; the exchange hash and the derived keys use K and Q
4 * from RFC 7748 §6.1 and were computed once by an independent implementation
5 * (Python's hashlib, written from RFC 4253 §7.2 and §8), not by this code.
6 */
7
8 import { expect, test } from "bun:test";
9 import { createPublicKey, createPrivateKey, diffieHellman } from "node:crypto";
10 import { deriveKey, exchangeHashOf } from "../src/ssh/kex.ts";
11 import { fingerprintOf } from "../src/ssh/keys.ts";
12 import { sshString, Writer } from "../src/ssh/wire.ts";
13
14 const hex = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, "hex"));
15 const toHex = (b: Uint8Array): string => Buffer.from(b).toString("hex");
16
17 const K = hex("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
18 const Q_C = hex("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
19 const Q_S = hex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
20
21 test("X25519 as the transport calls it matches RFC 7748 §6.1", () => {
22 const pkcs8 = Buffer.concat([hex("302e020100300506032b656e04220420"), hex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a")]);
23 const spki = Buffer.concat([hex("302a300506032b656e032100"), Buffer.from(Q_S)]);
24 const shared = diffieHellman({
25 privateKey: createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" }),
26 publicKey: createPublicKey({ key: spki, format: "der", type: "spki" }),
27 });
28 expect(toHex(shared)).toBe(toHex(K));
29 });
30
31 test("mpint encoding matches RFC 4251 §5", () => {
32 expect(toHex(new Writer(16).mpint(hex("")).build())).toBe("00000000");
33 expect(toHex(new Writer(16).mpint(hex("09a378f9b2e332a7")).build())).toBe("0000000809a378f9b2e332a7");
34 expect(toHex(new Writer(16).mpint(hex("80")).build())).toBe("000000020080");
35 });
36
37 const exchange = {
38 clientVersion: new TextEncoder().encode("SSH-2.0-client"),
39 serverVersion: new TextEncoder().encode("SSH-2.0-forge"),
40 peerKexInit: Uint8Array.from([20, ...Array.from({ length: 16 }, (_, i) => i), ...new TextEncoder().encode("client-kexinit")]),
41 myKexInit: Uint8Array.from([20, ...Array.from({ length: 16 }, (_, i) => 16 + i), ...new TextEncoder().encode("server-kexinit")]),
42 hostBlob: Uint8Array.from([0, 0, 0, 11, ...new TextEncoder().encode("ssh-ed25519"), 0, 0, 0, 32, ...Array.from({ length: 32 }, (_, i) => i)]),
43 clientPublic: Q_C,
44 serverPublic: Q_S,
45 kMpint: new Writer(48).mpint(K).build(),
46 };
47
48 const H = "b11eb4d9bab11f1f948a6f9d82e30e72bea4604453677185aeabfa8175c2d8f0";
49
50 test("the exchange hash is the known answer", () => {
51 expect(toHex(exchangeHashOf(exchange))).toBe(H);
52 });
53
54 test("every field of the exchange hash matters", () => {
55 for (const field of Object.keys(exchange) as (keyof typeof exchange)[]) {
56 const bent = Uint8Array.from(exchange[field]);
57 bent[bent.length - 1]! ^= 1;
58 expect(toHex(exchangeHashOf({ ...exchange, [field]: bent }))).not.toBe(H);
59 }
60 });
61
62 test("the six derived keys are the known answers (letters A to D, and the extension past one block)", () => {
63 const id = hex(H);
64 const k = (letter: string, n = 32) => toHex(deriveKey(exchange.kMpint, id, letter, id, n));
65 expect(k("A")).toBe("6fb4665da6001b59586965d7b61cd1188dfb299a8c23565828b9dbbe1866568e");
66 expect(k("B")).toBe("d72b996cb8e7b20b6e103876af82630807731987ac46a92aab896c660694477e");
67 expect(k("C")).toBe("a7d5c78f1d612295d3f419effce7a9ec2258ce9cbb4e3b61e4ed0239de5f653a");
68 expect(k("D")).toBe("0fc85b061fc62d5bae8358001c597713f3478be33c5d4b1f0560a20812eafe99");
69 expect(k("C", 80)).toBe(
70 "a7d5c78f1d612295d3f419effce7a9ec2258ce9cbb4e3b61e4ed0239de5f653a20ab4e55478c767dc9dcfc7e031e044306cbcdadb71b18b5c69c49d0e0fa94960848b73ea53d00ac54f490937767c5f2",
71 );
72 // The shorter key is a prefix of the longer one (RFC 4253 §7.2).
73 expect(k("C", 16)).toBe(k("C").slice(0, 32));
74 });
75
76 test("a fingerprint is SHA256: and unpadded base64 of the blob", () => {
77 const blob = sshString("ssh-ed25519");
78 expect(fingerprintOf(blob)).toBe("SHA256:" + new Bun.CryptoHasher("sha256").update(blob).digest("base64").replace(/=+$/, ""));
79 });
test/ssh-rekey-trigger.test.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * The server starts a rekey itself: after `rekeyBytes` of traffic or
3 * `rekeyMs` under one set of keys, and not before. The transport is driven
4 * directly, past its first exchange, with a clock the test holds.
5 */
6
7 import { expect, test } from "bun:test";
8 import { mkdtempSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createLog } from "../src/kernel/log.ts";
12 import { loadOrCreateHostKey } from "../src/ssh/keys.ts";
13 import { REKEY_BYTES, REKEY_MS, Transport } from "../src/ssh/transport.ts";
14 import { concat, MSG } from "../src/ssh/wire.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 const hostKey = loadOrCreateHostKey(mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "ssh-rekey-")));
20
21 function established(options: { rekeyBytes?: number; rekeyMs?: number }) {
22 let now = 1_000_000;
23 const out: Uint8Array[] = [];
24 const transport = new Transport(
25 { write: (d) => out.push(d.slice()), payload: () => {}, closed: () => {}, log: createLog("ssh-rekey") },
26 { hostKey, serverVersion: "SSH-2.0-forge", ...options, now: () => now },
27 );
28 transport.start();
29 // Stand in for a finished first exchange (the interop suite does a real one).
30 const inner = transport as unknown as { initialKexDone: boolean; kexActive: boolean; keyedAt: number; volume: number };
31 inner.kexActive = false;
32 inner.initialKexDone = true;
33 inner.keyedAt = now;
34 inner.volume = 0;
35 out.length = 0;
36 const kexinits = (): number => {
37 const all = concat(...out);
38 let n = 0;
39 for (let at = 0; at + 6 <= all.length; ) {
40 const len = new DataView(all.buffer, all.byteOffset + at).getUint32(0);
41 if (all[at + 5] === MSG.KEXINIT) n++;
42 at += 4 + len;
43 }
44 return n;
45 };
46 return { transport, kexinits, advance: (ms: number) => void (now += ms), inner };
47 }
48
49 test("the defaults are 1 GiB and an hour", () => {
50 expect(REKEY_BYTES).toBe(1024 ** 3);
51 expect(REKEY_MS).toBe(3_600_000);
52 });
53
54 test("no rekey while under both limits", () => {
55 const t = established({ rekeyBytes: 10_000, rekeyMs: 60_000 });
56 t.transport.send(new Uint8Array([MSG.IGNORE, 0, 0, 0, 0]));
57 t.advance(59_000);
58 t.transport.send(new Uint8Array([MSG.IGNORE, 0, 0, 0, 0]));
59 expect(t.kexinits()).toBe(0);
60 });
61
62 test("an hour under one set of keys starts a rekey", () => {
63 const t = established({ rekeyBytes: 10_000, rekeyMs: 60_000 });
64 t.advance(60_000);
65 t.transport.send(new Uint8Array([MSG.IGNORE, 0, 0, 0, 0]));
66 expect(t.kexinits()).toBe(1);
67 // Traffic during the exchange is parked, not another KEXINIT.
68 t.transport.send(new Uint8Array([MSG.IGNORE, 0, 0, 0, 0]));
69 expect(t.kexinits()).toBe(1);
70 });
71
72 test("the byte limit starts a rekey", () => {
73 const t = established({ rekeyBytes: 10_000, rekeyMs: 3_600_000 });
74 t.inner.volume = 10_000;
75 t.transport.send(new Uint8Array([MSG.IGNORE, 0, 0, 0, 0]));
76 expect(t.kexinits()).toBe(1);
77 });
test/ssh-tell.test.ts +10
@@ -18,6 +18,7 @@ import { appForLogin } from "../src/apps/tty.ts";
1818 import { createUser } from "../src/core/users.ts";
1919 import { forgeSshSession } from "../src/tui/ssh.ts";
2020 import { DIRECTION_NOTE } from "../src/crew/direct.ts";
21 import { GUEST_RATE_WORDS } from "../src/tui/ssh-guest-rate.ts";
2122 import { ACCOUNT_NEEDED_WORDS, CONTROL_WORDS, NO_CREW_WORDS } from "../src/tui/ssh-tell.ts";
2223 import { promote } from "./helpers/members.ts";
2324 import { quietLogs } from "./helpers/globals.ts";
@@ -223,4 +224,13 @@ describe("ssh forge tell", () => {
223224 app.db.query("UPDATE repo SET visibility = 'public', crew_outsiders = 1 WHERE id = ?1").run(alice.id);
224225 }
225226 });
227
228 test("a guest's tell and talk share a rate; a signed-in key is not held to it", async () => {
229 const seen: string[] = [];
230 for (let i = 0; i < 8; i++) seen.push((await exec(KEY_STRANGER, i % 2 === 0 ? "tell hi" : "talk hi")).err);
231 expect(seen.some((e) => e.includes(GUEST_RATE_WORDS))).toBe(true);
232 expect(seen.slice(0, 2).every((e) => !e.includes(GUEST_RATE_WORDS))).toBe(true);
233 const known = await exec(KEY_A, "tell still allowed");
234 expect(known.err).not.toContain(GUEST_RATE_WORDS);
235 });
226236 });
test/ssh.test.ts +21
@@ -783,6 +783,27 @@ describe.skipIf(NO_OPENSSH)("ssh rekey", () => {
783783 }, 60_000);
784784 });
785785
786 describe.skipIf(NO_OPENSSH)("ssh server-initiated rekey", () => {
787 test("the server starts a rekey itself after rekeyBytes, and the session survives it", async () => {
788 const down = payload(1024 * 1024);
789 const server = await start({
790 rekey: { rekeyBytes: 64 * 1024 },
791 onSession(session) {
792 session.onEof(() => session.exit(0));
793 session.write(down);
794 },
795 });
796 const client = ssh(server.port, keys.ed25519!, ["-v"], ["x"]);
797 await client.proc.stdin.end();
798 const out = await client.stdout;
799 const err = await client.stderr;
800 expect(await client.proc.exited).toBe(0);
801 expect(sha256(out)).toBe(sha256(down));
802 // The client logs each key exchange it takes part in: more than the first.
803 expect(err.match(/SSH2_MSG_KEX_ECDH_REPLY received/g)?.length ?? 0).toBeGreaterThan(1);
804 }, 60_000);
805 });
806
786807 describe.skipIf(NO_OPENSSH)("ssh resilience", () => {
787808 test("garbage after the version line is disconnected, server keeps serving", async () => {
788809 const server = await start({

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs