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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11947-the-ssh-door-has-limits-before-sign-in-f → main 4 commits fork 1 thread · 1 unresolved
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 | ||
| 89 | 89 | instance _is_ lives under the data directory — the SQLite index, the bare |
| 90 | 90 | repos, and the SSH host key, in plain layout. Tar it up and it moves. |
| 91 | 91 | |
| 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 | ||
| 92 | 101 | ## What the forge stands on |
| 93 | 102 | |
| 94 | 103 | 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"; | ||
| 11 | 11 | import { Auth } from "./auth.ts"; |
| 12 | 12 | import { Channels, type SshSession } from "./channels.ts"; |
| 13 | 13 | import { loadOrCreateHostKey, type SshPublicKey } from "./keys.ts"; |
| 14 | import { ConnectionLimits, DEFAULT_LIMITS, type SshLimitValues } from "./limits.ts"; | |
| 14 | 15 | import { Transport } from "./transport.ts"; |
| 15 | 16 | import { DISCONNECT, WireError } from "./wire.ts"; |
| 16 | 17 | |
| @@ -42,6 +43,10 @@ export interface SshServerOptions { | ||
| 42 | 43 | loginGraceMs?: number; |
| 43 | 44 | /** Signature-bearing auth attempts allowed per connection. @default 6 */ |
| 44 | 45 | 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 }; | |
| 45 | 50 | } |
| 46 | 51 | |
| 47 | 52 | export interface SshServer { |
| @@ -93,6 +98,7 @@ class Connection { | ||
| 93 | 98 | { |
| 94 | 99 | hostKey, |
| 95 | 100 | serverVersion: options.serverVersion ?? "SSH-2.0-forge", |
| 101 | ...(options.rekey ?? {}), | |
| 96 | 102 | }, |
| 97 | 103 | ); |
| 98 | 104 | this.auth = new Auth({ |
| @@ -255,6 +261,7 @@ class Connection { | ||
| 255 | 261 | export function serveSsh(options: SshServerOptions): Promise<SshServer> { |
| 256 | 262 | const hostKey = loadOrCreateHostKey(options.hostKeyDir); |
| 257 | 263 | const connections = new Set<Connection>(); |
| 264 | const limits = new ConnectionLimits({ ...DEFAULT_LIMITS, ...options.limits }); | |
| 258 | 265 | // Whoever is waiting for the last connection to leave, once draining. |
| 259 | 266 | const drained: (() => void)[] = []; |
| 260 | 267 | const drop = (connection: Connection) => { |
| @@ -267,7 +274,18 @@ export function serveSsh(options: SshServerOptions): Promise<SshServer> { | ||
| 267 | 274 | port: options.port, |
| 268 | 275 | socket: { |
| 269 | 276 | 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 | }); | |
| 271 | 289 | socket.data = connection; |
| 272 | 290 | connections.add(connection); |
| 273 | 291 | connection.start(); |
src/ssh/transport.ts +49 −40
| @@ -11,7 +11,6 @@ | ||
| 11 | 11 | import { |
| 12 | 12 | createCipheriv, |
| 13 | 13 | createDecipheriv, |
| 14 | createHash, | |
| 15 | 14 | createPublicKey, |
| 16 | 15 | diffieHellman, |
| 17 | 16 | generateKeyPairSync, |
| @@ -19,6 +18,7 @@ import { | ||
| 19 | 18 | randomFillSync, |
| 20 | 19 | } from "node:crypto"; |
| 21 | 20 | import type { Log } from "../kernel/log.ts"; |
| 21 | import { deriveKey, exchangeHashOf } from "./kex.ts"; | |
| 22 | 22 | import { type HostKey, SERVER_SIG_ALGS, signWithHostKey } from "./keys.ts"; |
| 23 | 23 | import { |
| 24 | 24 | ByteQueue, |
| @@ -27,7 +27,6 @@ import { | ||
| 27 | 27 | fromUtf8, |
| 28 | 28 | MSG, |
| 29 | 29 | Reader, |
| 30 | sshString, | |
| 31 | 30 | utf8, |
| 32 | 31 | WireError, |
| 33 | 32 | Writer, |
| @@ -94,9 +93,20 @@ export interface TransportHost { | ||
| 94 | 93 | log: Log; |
| 95 | 94 | } |
| 96 | 95 | |
| 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 | ||
| 97 | 101 | export interface TransportOptions { |
| 98 | 102 | readonly hostKey: HostKey; |
| 99 | 103 | 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; | |
| 100 | 110 | } |
| 101 | 111 | |
| 102 | 112 | function incrementInvocationCounter(iv: Uint8Array): void { |
| @@ -156,6 +166,10 @@ export class Transport { | ||
| 156 | 166 | * forbids non-KEX traffic between our KEXINIT and our NEWKEYS). */ |
| 157 | 167 | private readonly parked: Uint8Array[] = []; |
| 158 | 168 | |
| 169 | /** Bytes moved, either way, since the keys now in use took effect. */ | |
| 170 | private volume = 0; | |
| 171 | private keyedAt = 0; | |
| 172 | ||
| 159 | 173 | constructor( |
| 160 | 174 | private readonly host: TransportHost, |
| 161 | 175 | private readonly options: TransportOptions, |
| @@ -195,6 +209,7 @@ export class Transport { | ||
| 195 | 209 | /** Send an upper-layer payload, parking it if a key exchange is running. */ |
| 196 | 210 | send(payload: Uint8Array): void { |
| 197 | 211 | if (this.dead) return; |
| 212 | this.rekeyIfDue(); | |
| 198 | 213 | if (this.kexActive) { |
| 199 | 214 | this.parked.push(payload); |
| 200 | 215 | return; |
| @@ -202,6 +217,21 @@ export class Transport { | ||
| 202 | 217 | this.sendNow(payload); |
| 203 | 218 | } |
| 204 | 219 | |
| 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 | ||
| 205 | 235 | /** Tell the peer we did not understand the packet at `seq` (RFC 4253 §11.4). */ |
| 206 | 236 | unimplemented(seq: number): void { |
| 207 | 237 | this.send(new Writer(8).u8(MSG.UNIMPLEMENTED).u32(seq).build()); |
| @@ -310,6 +340,7 @@ export class Transport { | ||
| 310 | 340 | } |
| 311 | 341 | payload = new Uint8Array(plain.subarray(1, length - padding)); |
| 312 | 342 | this.q.skip(total); |
| 343 | this.volume += total; | |
| 313 | 344 | incrementInvocationCounter(keys.iv); |
| 314 | 345 | } else { |
| 315 | 346 | // Cipher `none`: 4 + packet_length must be a multiple of 8. |
| @@ -337,6 +368,7 @@ export class Transport { | ||
| 337 | 368 | this.seqIn = (this.seqIn + 1) >>> 0; |
| 338 | 369 | this.packetsIn++; |
| 339 | 370 | this.handle(payload, seq); |
| 371 | this.rekeyIfDue(); | |
| 340 | 372 | return !this.dead; |
| 341 | 373 | } |
| 342 | 374 | |
| @@ -368,6 +400,7 @@ export class Transport { | ||
| 368 | 400 | frame.set(payload, 5); |
| 369 | 401 | randomFillSync(frame, 5 + payload.length, padding); |
| 370 | 402 | } |
| 403 | this.volume += frame.length; | |
| 371 | 404 | this.host.write(frame); |
| 372 | 405 | this.seqOut = (this.seqOut + 1) >>> 0; |
| 373 | 406 | } |
| @@ -622,23 +655,16 @@ export class Transport { | ||
| 622 | 655 | |
| 623 | 656 | const hostBlob = this.options.hostKey.publicKey.blob; |
| 624 | 657 | 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 | }); | |
| 642 | 668 | // The first exchange hash becomes the session id and never changes, even |
| 643 | 669 | // across rekeys (RFC 4253 §7.2). |
| 644 | 670 | this.sessionIdBytes ??= exchangeHash; |
| @@ -654,27 +680,8 @@ export class Transport { | ||
| 654 | 680 | .build(), |
| 655 | 681 | ); |
| 656 | 682 | |
| 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); | |
| 678 | 685 | |
| 679 | 686 | const inSpec = CIPHER_SPECS[this.negotiated.cipherIn]!; |
| 680 | 687 | const outSpec = CIPHER_SPECS[this.negotiated.cipherOut]!; |
| @@ -729,6 +736,8 @@ export class Transport { | ||
| 729 | 736 | if (!this.newKeysSent || !this.newKeysReceived) return; |
| 730 | 737 | this.kexActive = false; |
| 731 | 738 | this.initialKexDone = true; |
| 739 | this.volume = 0; | |
| 740 | this.keyedAt = (this.options.now ?? Date.now)(); | |
| 732 | 741 | while (this.parked.length > 0 && !this.dead && !this.kexActive) { |
| 733 | 742 | // SAFETY: length > 0 was just checked. |
| 734 | 743 | 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"; | ||
| 32 | 32 | import { servePulls } from "../apps/tty_pulls.ts"; |
| 33 | 33 | import type { Terminal } from "./term/terminal.ts"; |
| 34 | 34 | import type { TuiIdentity } from "./session.ts"; |
| 35 | import { GUEST_RATE_WORDS, GuestRate } from "./ssh-guest-rate.ts"; | |
| 35 | 36 | import { parseTalkAgain, parseTalkCommand, serveTalk, serveTalkAgain } from "./ssh-talk.ts"; |
| 36 | 37 | import { parseTellCommand, serveTell } from "./ssh-tell.ts"; |
| 37 | 38 | |
| @@ -202,12 +203,25 @@ const appExecs: Record<string, typeof servePulls> = Object.assign( | ||
| 202 | 203 | { pulls: servePulls }, |
| 203 | 204 | ); |
| 204 | 205 | |
| 206 | /** One rate per forge, however many times the session handler is made. */ | |
| 207 | const guestRates = new WeakMap<Forge, GuestRate>(); | |
| 208 | ||
| 205 | 209 | /** |
| 206 | 210 | * What a forge does with an inbound SSH session: a shell is the TUI, an exec |
| 207 | 211 | * is git, `tell`, or nothing. Never throws — a fault here would take the listener with |
| 208 | 212 | * it, and one bad client must not. |
| 209 | 213 | */ |
| 210 | 214 | 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 | }; | |
| 211 | 225 | return (session) => { |
| 212 | 226 | try { |
| 213 | 227 | const identity = identityFor(forge, session); |
| @@ -233,6 +247,7 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 233 | 247 | // app's crew, said from the app (through the terminal's own gate). |
| 234 | 248 | const said = parseTellCommand(session.command); |
| 235 | 249 | if (said !== undefined) { |
| 250 | if (guestLimited(session, identity.user)) return; | |
| 236 | 251 | void serveTell(forge, session, identity.user, said, door).catch( |
| 237 | 252 | (error: unknown) => { |
| 238 | 253 | forge.log.error("tell over ssh failed", { error: String(error) }); |
| @@ -245,11 +260,13 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 245 | 260 | // `talk <words>`: the forge's own agent, as the person. |
| 246 | 261 | const talked = parseTalkCommand(session.command); |
| 247 | 262 | if (talked !== undefined && door === undefined) { |
| 263 | if (guestLimited(session, identity.user)) return; | |
| 248 | 264 | serveTalk(forge, session, identity.user, talked); |
| 249 | 265 | return; |
| 250 | 266 | } |
| 251 | 267 | const again = parseTalkAgain(session.command); |
| 252 | 268 | if (again !== undefined && door === undefined) { |
| 269 | if (guestLimited(session, identity.user)) return; | |
| 253 | 270 | serveTalkAgain(forge, session, identity.user, again.how, again.words); |
| 254 | 271 | return; |
| 255 | 272 | } |
src/web/studio/home.ts +2 −2
| @@ -913,8 +913,8 @@ bun run src/main.ts</code></pre> | ||
| 913 | 913 | ${when( |
| 914 | 914 | ssh, |
| 915 | 915 | (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> | |
| 918 | 918 | </div>`, |
| 919 | 919 | )} |
| 920 | 920 | ${packPresses()} |
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 () => { | ||
| 35 | 35 | log: createLog("ssh-framing-test"), |
| 36 | 36 | authorize: () => true, |
| 37 | 37 | onSession: () => {}, |
| 38 | // Many short connections from loopback: this file is not about the limits. | |
| 39 | limits: { handshakesPerAddress: 10_000, handshakes: 10_000 }, | |
| 38 | 40 | }); |
| 39 | 41 | }); |
| 40 | 42 |
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"; | ||
| 18 | 18 | import { createUser } from "../src/core/users.ts"; |
| 19 | 19 | import { forgeSshSession } from "../src/tui/ssh.ts"; |
| 20 | 20 | import { DIRECTION_NOTE } from "../src/crew/direct.ts"; |
| 21 | import { GUEST_RATE_WORDS } from "../src/tui/ssh-guest-rate.ts"; | |
| 21 | 22 | import { ACCOUNT_NEEDED_WORDS, CONTROL_WORDS, NO_CREW_WORDS } from "../src/tui/ssh-tell.ts"; |
| 22 | 23 | import { promote } from "./helpers/members.ts"; |
| 23 | 24 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -223,4 +224,13 @@ describe("ssh forge tell", () => { | ||
| 223 | 224 | app.db.query("UPDATE repo SET visibility = 'public', crew_outsiders = 1 WHERE id = ?1").run(alice.id); |
| 224 | 225 | } |
| 225 | 226 | }); |
| 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 | }); | |
| 226 | 236 | }); |
test/ssh.test.ts +21
| @@ -783,6 +783,27 @@ describe.skipIf(NO_OPENSSH)("ssh rekey", () => { | ||
| 783 | 783 | }, 60_000); |
| 784 | 784 | }); |
| 785 | 785 | |
| 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 | ||
| 786 | 807 | describe.skipIf(NO_OPENSSH)("ssh resilience", () => { |
| 787 | 808 | test("garbage after the version line is disconnected, server keeps serving", async () => { |
| 788 | 809 | const server = await start({ |
new above
offline
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.