/login/options kept challenges in a map of 1000 that dropped the oldest, so anyone could push out a sign-in in progress by asking for options in a loop. A challenge now carries its expiry and an HMAC (a key the server makes when it starts) over it, what it is for and the browser that asked, which gets a five-minute __Host-forge-challenge cookie with a random id; verify checks all of that and remembers used nonces until they expire. A challenge fetched by one client can't be completed by another. Each address may ask for options 20 times a minute (lib/limits.ts). The address is X-Forge-Client-IP, which Caddy sets from {client_ip}, replacing any a client sent; in the container Caddy trusts Fly-Client-IP from the loopback tunnel only. Scenarios: a flood of 1500 options requests doesn't break a sign-in, the per-address limit, the limit through Caddy despite a spoofed header, and a challenge refused from another browser.- commit
- 2133113aac28d246e42c834ac8d1019d52b5d732
- parent
- fe26ac5eb0af68c410079aa849dc38bb8da598c4
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 127s, signed by cat
- kind
- fix risk score 2.0 of 3, Jev's estimate
- review
- #30 Security: sign-in, invites, owner pages, lake, caps and the edge
ci attestation
forge-attestation 1 type ci commit 2133113aac28d246e42c834ac8d1019d52b5d732 tree 599e42bb4d672a1b94a829bc876cd3d7ea1c42a0 script .forge/ci 8d2000a416f8160bcf710d98b72af9eb76287f58 result pass exit 0 started 2026-10-19T07:44:20Z duration 127s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:fe72579f256f06c7dd1f6a735eb6cf11422fd462ff0b0573118fdc6ca9cc69f6 316 lines |pushed wip/i18n |d77d631 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |d77d631 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |72f7a1e running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |72f7a1e fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |74e9852 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |74e9852 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 8d20c83 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |026acb9 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |026acb9 pass in 0s, signed |pushed main |8f46475 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |8f46475 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 6d2bbed -> /sites/dami/forge/guide/ |(node:54792) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.SMjB0k/wt/demo/root/.forge/www |(node:54951) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.SMjB0k/wt/demo/root/.forge/www |(node:55124) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.SMjB0k/wt/demo/root/.forge/www |(node:55290) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.SMjB0k/wt/demo/root/.forge/www |(node:55997) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.SMjB0k/wt/demo/root/.forge/www |235 of 235 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | README.md | 7 | ++- |
| M | bin/ | 3 | + |
| A | lib/ | 50 | +++++++++++++++++ |
| M | lib/ | 87 | ++++++++++++++++++++++-------- |
| M | lib/ | 22 | ++++++-- |
| M | server/ | 6 | ++- |
| M | test/ | 22 | +++++++- |
| A | test/ | 15 | +++++ |
| A | test/ | 15 | +++++ |
| A | test/ | 11 | ++++ |
| A | test/ | 14 | +++++ |
@@ -333,7 +333,12 @@ ssh git@git.example.com tokens [rm <id>]
333333 is offered first, so a passkey can be post-quantum. After `passkeys rm`,334334 the next page you open signed in tells your password manager which335335 passkeys still work (the WebAuthn Signal API), so it stops offering the336- removed one; it also signs out the sessions that passkey made.336+ removed one; it also signs out the sessions that passkey made. The337+ server keeps no challenges until they are used: each one carries its338+ expiry and an HMAC, and is bound to the browser that asked for it by a339+ `__Host-forge-challenge` cookie that lasts five minutes, so a flood of340+ requests can't push out anyone's sign-in. Each address may ask for341+ options 20 times a minute.337342 - **Sessions** are a `__Host-forge` cookie (Secure, HttpOnly, SameSite=Lax)338343 that lasts 30 days from its last use. Sign-out is a POST from the forge's339344 page, with a CSRF token. The events server streams a private repo to a@@ -1089,6 +1089,9 @@ $addr {
10891089 ?Referrer-Policy strict-origin-when-cross-origin10901090 ?Content-Security-Policy "frame-ancestors 'self'; base-uri 'none'; object-src 'none'"10911091 }1092+ # The servers behind Caddy count requests by address (lib/limits.ts);1093+ # this is the one they go by, whatever a client sent in its place.1094+ request_header X-Forge-Client-IP {client_ip}10921095 # Compress pages and assets, never the event stream (it would buffer),10931096 # code.db, which code search reads in byte ranges of the file as stored,10941097 # or release downloads, which are mostly compressed already.@@ -0,0 +1,50 @@
1+// Limits for the forge's Node servers (lib/private-server.ts,2+// lib/events-server.ts, lib/pages-server.ts): who a request comes from, how3+// often someone may do something, and how long a request may take to arrive.4+import type http from 'node:http';5+6+// The address a request comes from. The servers listen on 127.0.0.1 only,7+// behind Caddy, which sets X-Forge-Client-IP to the client it saw8+// ({client_ip}: behind the edge, Fly-Client-IP; bin/forge server9+// caddyfile, server/container/start.sh), replacing any the client sent.10+// Without Caddy (demo/serve.ts, scenarios), the socket's.11+export function clientIp(req: http.IncomingMessage): string {12+ const h = req.headers['x-forge-client-ip'];13+ const v = (Array.isArray(h) ? h[0] : h)?.trim();14+ return v && v.length <= 64 ? v : req.socket.remoteAddress ?? '';15+}16+17+// At most `max` hits per key in any `ms`. At most `keys` keys are kept,18+// the least recently counted going first, so a flood of new keys costs19+// memory only up to there.20+export class Window {21+ private hits = new Map<string, number[]>();22+ private max: number;23+ private ms: number;24+ private keys: number;25+ constructor(max: number, ms: number, keys = 10000) {26+ this.max = max;27+ this.ms = ms;28+ this.keys = keys;29+ }30+31+ // Whether key may have one more now; if so, it is counted.32+ take(key: string, t = Date.now()): boolean {33+ const mine = (this.hits.get(key) ?? []).filter((x) => t - x < this.ms);34+ if (mine.length >= this.max) return false;35+ mine.push(t);36+ this.hits.delete(key);37+ this.hits.set(key, mine);38+ while (this.hits.size > this.keys) this.hits.delete(this.hits.keys().next().value!);39+ return true;40+ }41+}42+43+// How long a request's headers, and the whole request, may take to arrive:44+// a client that trickles them in holds a socket no longer than this. (A45+// response, such as the event stream, may go on as long as it likes.)46+export function timeouts(server: http.Server): http.Server {47+ server.headersTimeout = 10_000;48+ server.requestTimeout = 30_000;49+ return server;50+}@@ -11,18 +11,45 @@
1111 // algorithms are the library's: ML-DSA-44 first where the runtime has it1212 // (Node 24.7 and later), then EdDSA, ES256 and RS256.1313 //14-// Challenges live in memory for five minutes and are used once. A signed-in14+// Challenges keep nothing on the server until they are used, so asking for15+// many can't push anyone else's out: each is a random nonce, its expiry16+// (five minutes on) and an HMAC over them with a key the server makes when17+// it starts, which also covers what it is for (signing in, or adding a18+// passkey with a given code) and the browser that asked. That browser gets19+// a short-lived cookie (__Host-forge-challenge) with a random id the HMAC20+// covers, so a challenge fetched by one client can't be completed by21+// another. A used challenge is remembered until it expires, so each works22+// once. A restart forgets the key: a sign-in in flight then fails, and the23+// next try works. A signed-in1524 // browser gets the user's accepted credential ids back, so the page can tell1625 // the password manager which passkeys still work (the WebAuthn Signal API,1726 // signalAllAcceptedCredentials), and a passkey the forge doesn't know gets1827 // signalUnknownCredential.1928 import type http from 'node:http';29+import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';2030 import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';2131 import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server';2232 import type { Identity, Passkey } from './identity.ts';2333 2434 const CHALLENGE_TTL = 5 * 60 * 1000;2535 36+// The cookie that ties a challenge to the browser that asked for it: host37+// only, HTTPS only, out of scripts' reach, sent to this site's own fetches only.38+export const CHALLENGE_COOKIE = '__Host-forge-challenge';39+export const challengeCookie = (value: string): string => `${CHALLENGE_COOKIE}=${value}; Path=/; Max-Age=${CHALLENGE_TTL / 1000}; Secure; HttpOnly; SameSite=Strict`;40+// The browser's id from its cookie, if it has a well-formed one.41+export function browserId(header: string | undefined): string | null {42+ for (const part of (header ?? '').split(';')) {43+ const i = part.indexOf('=');44+ if (i > 0 && part.slice(0, i).trim() === CHALLENGE_COOKIE) {45+ const v = part.slice(i + 1).trim();46+ return /^[A-Za-z0-9_-]{22}$/.test(v) ? v : null;47+ }48+ }49+ return null;50+}51+export const newBrowserId = (): string => randomBytes(16).toString('base64url');52+2653 export interface RelyingParty {2754 id: string;2855 origin: string;@@ -66,7 +93,9 @@ export class Login {
6693 rp: RelyingParty;6794 private id: () => Identity;6895 private isUser: (name: string) => boolean;69- private challenges = new Map<string, { kind: 'login' | 'enroll'; code?: string; expires: number }>();96+ // The HMAC key for challenges, and the nonces of used ones until they expire.97+ private key = randomBytes(32);98+ private used = new Map<string, number>();7099 71100 constructor(rp: RelyingParty, id: () => Identity, isUser: (name: string) => boolean) {72101 this.rp = rp;@@ -74,32 +103,46 @@ export class Login {
74103 this.isUser = isUser;75104 }76105 77- private remember(challenge: string, kind: 'login' | 'enroll', code?: string): void {78- const t = Date.now();79- for (const [c, v] of this.challenges) if (v.expires < t || this.challenges.size > 1000) this.challenges.delete(c);80- this.challenges.set(challenge, { kind, code, expires: t + CHALLENGE_TTL });106+ private mac(kind: 'login' | 'enroll', code: string, browser: string, nonce: Buffer, expires: Buffer): Buffer {107+ return createHmac('sha256', this.key).update(`forge-challenge\0${kind}\0${code}\0${browser}\0`).update(nonce).update(expires).digest();81108 }82109 83- // A challenge this server gave out for this, not used or expired: used up.84- private take(challenge: string, kind: 'login' | 'enroll', code?: string): boolean {85- const v = this.challenges.get(challenge);86- if (!v) return false;87- this.challenges.delete(challenge);88- return v.kind === kind && v.code === code && v.expires >= Date.now();110+ // A challenge: nonce (16 bytes), expiry (8, ms), HMAC (32).111+ private challenge(kind: 'login' | 'enroll', browser: string, code = ''): Uint8Array<ArrayBuffer> {112+ const nonce = randomBytes(16);113+ const expires = Buffer.alloc(8);114+ expires.writeBigUInt64BE(BigInt(Date.now() + CHALLENGE_TTL));115+ return new Uint8Array(Buffer.concat([nonce, expires, this.mac(kind, code, browser, nonce, expires)]));116+ }117+118+ // A challenge this server gave this browser for this, not used or119+ // expired: used up.120+ private take(challenge: string, kind: 'login' | 'enroll', browser: string | null, code = ''): boolean {121+ const b = Buffer.from(challenge, 'base64url');122+ if (!browser || b.length !== 56) return false;123+ const nonce = b.subarray(0, 16);124+ const expires = b.subarray(16, 24);125+ if (!timingSafeEqual(b.subarray(24), this.mac(kind, code, browser, nonce, expires))) return false;126+ const t = Date.now();127+ const until = Number(expires.readBigUInt64BE());128+ const n = nonce.toString('base64url');129+ if (until < t || this.used.has(n)) return false;130+ for (const [k, v] of this.used) if (v < t) this.used.delete(k);131+ this.used.set(n, until);132+ return true;89133 }90134 91135 signal(user: string): Signal {92136 return { rpID: this.rp.id, userID: this.id().handle(user), allAcceptedCredentialIDs: this.id().passkeys(user).map((p) => p.credential) };93137 }94138 95- async loginOptions(): Promise<unknown> {96- const opts = await generateAuthenticationOptions({ rpID: this.rp.id, userVerification: 'required', allowCredentials: [] });97- this.remember(opts.challenge, 'login');98- return opts;139+ // Options for the browser whose id this is (browserId, newBrowserId).140+ async loginOptions(browser: string): Promise<unknown> {141+ return generateAuthenticationOptions({ rpID: this.rp.id, userVerification: 'required', allowCredentials: [], challenge: this.challenge('login', browser) });99142 }100143 101144 // The passkey's user, after checking its signature over our challenge.102- async loginVerify(body: unknown): Promise<{ user: string; passkey: Passkey }> {145+ async loginVerify(body: unknown, browser: string | null): Promise<{ user: string; passkey: Passkey }> {103146 const res = body as AuthenticationResponseJSON;104147 if (!res || typeof res.id !== 'string' || typeof res.response !== 'object') throw new LoginError('not a passkey response');105148 const key = this.id().passkey(res.id);@@ -113,7 +156,7 @@ export class Login {
113156 let v;114157 try {115158 v = await verifyAuthenticationResponse({116- response: res, expectedChallenge: (c) => this.take(c, 'login'), expectedOrigin: this.rp.origin, expectedRPID: this.rp.id, requireUserVerification: true,159+ response: res, expectedChallenge: (c) => this.take(c, 'login', browser), expectedOrigin: this.rp.origin, expectedRPID: this.rp.id, requireUserVerification: true,117160 credential: { id: key.credential, publicKey: new Uint8Array(key.public_key), counter: key.counter, transports: key.transports ? key.transports.split(',') : undefined },118161 });119162 } catch (e) {@@ -124,7 +167,7 @@ export class Login {
124167 return { user: key.user, passkey: key };125168 }126169 127- async enrollOptions(code: unknown): Promise<unknown> {170+ async enrollOptions(code: unknown, browser: string): Promise<unknown> {128171 const e = typeof code === 'string' ? this.id().enrollment(code) : null;129172 if (!e || !this.isUser(e.user)) throw new LoginError('This link has expired or has been used. Run ssh passkey again for a new one.', { expired: true });130173 const opts = await generateRegistrationOptions({@@ -133,19 +176,19 @@ export class Login {
133176 attestationType: 'none',134177 excludeCredentials: this.id().passkeys(e.user).map((p) => ({ id: p.credential, transports: p.transports ? p.transports.split(',') : undefined })),135178 authenticatorSelection: { residentKey: 'required', userVerification: 'required' },179+ challenge: this.challenge('enroll', browser, code as string),136180 });137- this.remember(opts.challenge, 'enroll', code as string);138181 return opts;139182 }140183 141184 // Check the new passkey, then use the link up and keep the passkey.142- async enrollVerify(code: unknown, body: unknown, name: string): Promise<{ user: string; passkey: Passkey }> {185+ async enrollVerify(code: unknown, body: unknown, name: string, browser: string | null): Promise<{ user: string; passkey: Passkey }> {143186 const res = body as RegistrationResponseJSON;144187 if (typeof code !== 'string' || !res || typeof res.id !== 'string' || typeof res.response !== 'object') throw new LoginError('not a passkey response');145188 let v;146189 try {147190 v = await verifyRegistrationResponse({148- response: res, expectedChallenge: (c) => this.take(c, 'enroll', code), expectedOrigin: this.rp.origin, expectedRPID: this.rp.id, requireUserVerification: true,191+ response: res, expectedChallenge: (c) => this.take(c, 'enroll', browser, code), expectedOrigin: this.rp.origin, expectedRPID: this.rp.id, requireUserVerification: true,149192 });150193 } catch (e) {151194 throw new LoginError(`The passkey wasn't added: ${(e as Error).message}`);@@ -37,7 +37,8 @@ import { fileURLToPath } from 'node:url';
3737 import { INVITE_EXPIRED, Identity, SESSION_TTL, TOKEN_PREFIX, cookieValue, csrfToken, sameSecret, sessionCookie, type Session, type Token } from './identity.ts';3838 import { canRead, isPublic, readable, repoDir, users } from './access.ts';3939 import { DOWNLOAD, DOWNLOAD_HEADERS, resolve, sendFile } from './static.ts';40-import { Login, LoginError, agentName, readBody, relyingParty } from './login.ts';40+import { Login, LoginError, agentName, browserId, challengeCookie, newBrowserId, readBody, relyingParty } from './login.ts';41+import { Window, clientIp } from './limits.ts';4142 import { pageRenderer, type Render } from './render-page.ts';4243 import { assets } from './assets.ts';4344 import { summarize } from './repo.ts';@@ -190,6 +191,8 @@ const refusal = (err: string, otherwise: string): string => err.split('\n').filt
190191 // (the codes can't be guessed; this keeps anyone from trying), and one191192 // accept at a time, since each writes .forge/keys.192193 const INVITE_POSTS_PER_MINUTE = 30;194+// Sign-in options: each address may ask this many times a minute.195+const OPTIONS_PER_MINUTE = 20;193196 194197 // The sign-in and list pages: no framing, scripts and styles of our own only.195198 const PAGE_HEADERS = {@@ -435,6 +438,7 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
435438 json(res, 200, { user, enroll: gate.id.enroll(user, '').code });436439 }437440 441+ const optionsAsked = new Window(OPTIONS_PER_MINUTE, 60_000);438442 async function post(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {439443 // Only the forge's own pages may post here (and sign anyone in or out).440444 if (!login || req.headers.origin !== login.rp.origin) return json(res, 403, { error: 'not from this forge' });@@ -453,17 +457,25 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
453457 } catch {454458 return json(res, 400, { error: 'not JSON' });455459 }460+ // Options are tied to this browser's challenge cookie (lib/login.ts),461+ // which it gets with them, and each address asks only so often.462+ const browser = browserId(req.headers.cookie);463+ const options = async (make: (b: string) => Promise<unknown>): Promise<void> => {464+ if (!optionsAsked.take(clientIp(req))) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });465+ const b = browser ?? newBrowserId();466+ json(res, 200, await make(b), { 'Set-Cookie': challengeCookie(b) });467+ };456468 try {457- if (url.pathname === '/login/options') return json(res, 200, await login.loginOptions());469+ if (url.pathname === '/login/options') return await options((b) => login.loginOptions(b));458470 if (url.pathname === '/login/verify') {459- const { user, passkey } = await login.loginVerify(body);471+ const { user, passkey } = await login.loginVerify(body, browser);460472 return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));461473 }462474 if (url.pathname === '/login/invite/options') return await invite(res, 'options', body);463475 if (url.pathname === '/login/invite/accept') return await invite(res, 'accept', body);464- if (url.pathname === '/login/enroll/options') return json(res, 200, await login.enrollOptions(body.code));476+ if (url.pathname === '/login/enroll/options') return await options((b) => login.enrollOptions(body.code, b));465477 if (url.pathname === '/login/enroll/verify') {466- const { user, passkey } = await login.enrollVerify(body.code, body.response, agentName(req.headers['user-agent']));478+ const { user, passkey } = await login.enrollVerify(body.code, body.response, agentName(req.headers['user-agent']), browser);467479 return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));468480 }469481 } catch (e) {@@ -105,7 +105,11 @@ as_git $forge keys sync
105105 # TLS is terminated in front of us; Caddy serves plain HTTP on 8080. The106106 # config is written anew on every boot, so Caddy keeps no copy of it.107107 {108- printf '{\n\tauto_https off\n\thttp_port 8080\n\tadmin off\n\tpersist_config off\n}\n\n'108+ # Requests come from the edge through tailcat, which connects from109+ # loopback; Fly's proxy in front of the edge puts the client's address110+ # in Fly-Client-IP, which {client_ip} is then (X-Forge-Client-IP,111+ # lib/limits.ts). Nothing else reaches Caddy here.112+ printf '{\n\tauto_https off\n\thttp_port 8080\n\tadmin off\n\tpersist_config off\n\tservers {\n\t\ttrusted_proxies static 127.0.0.1/8 ::1/128\n\t\tclient_ip_headers Fly-Client-IP\n\t}\n}\n\n'109113 as_git $forge server caddyfile :8080 "$pages" "${pages:+http://$pages}"110114 } >/etc/caddy/Caddyfile111115 @@ -16,6 +16,10 @@
1616 // --no-uv no user verification1717 // --counter <n> the signature counter to send (default: one more than last time)1818 // --replay send the same signed response a second time (prints the second answer)19+// --other-browser send the signed response from another browser: without20+// the challenge cookie the options came with21+// --flood <n> before signing in, n other clients (each its own22+// address) ask for sign-in options1923 // --alg ml-dsa-44 enroll an ML-DSA-44 key instead of ES2562024 import fs from 'node:fs';2125 import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';@@ -47,6 +51,8 @@ const rpOverride = flag('--rp');
4751 const counterArg = flag('--counter');4852 const noUv = has('--no-uv');4953 const replay = has('--replay');54+const otherBrowser = has('--other-browser');55+const flood = Number(flag('--flood') ?? 0);5056 const alg = flag('--alg') ?? 'es256';5157 const [mode, server, ...rest] = args;5258 @@ -58,8 +64,12 @@ const u32 = (n: number): Buffer => {
5864 return b;5965 };6066 67+// The challenge cookie the options came with, sent back as a browser would.68+let jar = '';6169 async function call(path: string, body: unknown): Promise<{ status: number; data: Record<string, unknown>; cookie: string | null }> {62- const r = await fetch(server.replace(/\/$/, '') + path, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin, 'User-Agent': 'test-authenticator' }, body: JSON.stringify(body) });70+ const headers: Record<string, string> = { 'Content-Type': 'application/json', Origin: origin, 'User-Agent': 'test-authenticator' };71+ if (jar && !(otherBrowser && /\/verify$/.test(path))) headers.Cookie = jar;72+ const r = await fetch(server.replace(/\/$/, '') + path, { method: 'POST', headers, body: JSON.stringify(body) });6373 const text = await r.text();6474 let data: Record<string, unknown> = {};6575 try {@@ -68,6 +78,8 @@ async function call(path: string, body: unknown): Promise<{ status: number; data
6878 data = { error: text };6979 }7080 const set = r.headers.get('set-cookie') ?? '';81+ const c = /__Host-forge-challenge=([^;]*)/.exec(set);82+ if (c) jar = `__Host-forge-challenge=${c[1]}`;7183 const m = /__Host-forge=([^;]*)/.exec(set);7284 return { status: r.status, data, cookie: m ? m[1] : null };7385 }@@ -119,6 +131,12 @@ if (mode === 'enroll' && rest.length === 2) {
119131 const opts = await call('/login/options', {});120132 if (opts.status !== 200) done(opts);121133 const o = opts.data as { challenge: string; rpId: string };134+ for (let i = 0; i < flood; i++) {135+ const r = await fetch(server.replace(/\/$/, '') + '/login/options', {136+ method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin, 'X-Forge-Client-IP': `10.${(i >> 16) & 255}.${(i >> 8) & 255}.${i & 255}` }, body: '{}',137+ });138+ if (r.status !== 200) done({ status: r.status, data: { error: `flood request ${i}` }, cookie: null });139+ }122140 const counter = counterArg != null ? Number(counterArg) : key.counter + 1;123141 const authData = Buffer.concat([sha256(rpOverride ?? o.rpId), Buffer.from([flags(false)]), u32(counter)]);124142 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge: o.challenge, origin: clientOrigin, crossOrigin: false }));@@ -133,6 +151,6 @@ if (mode === 'enroll' && rest.length === 2) {
133151 if (replay) done(await call('/login/verify', response));134152 done(r);135153 } else {136- console.error('usage: node test/authenticator.ts enroll <server> <code> <key.json> | login <server> <key.json> [--origin o] [--client-origin o] [--rp id] [--no-uv] [--counter n] [--replay]');154+ console.error('usage: node test/authenticator.ts enroll <server> <code> <key.json> | login <server> <key.json> [--origin o] [--client-origin o] [--rp id] [--no-uv] [--counter n] [--replay] [--other-browser] [--flood n]');137155 process.exit(2);138156 }@@ -0,0 +1,15 @@
1+# A sign-in challenge belongs to the browser that asked for it: the options2+# come with a short-lived cookie the challenge is bound to, so a response3+# signed over it and sent from another browser (without that cookie) is4+# refused, and the same sign-in from the browser that asked works.5+. "$(dirname "$0")/../lib.sh"6+user dami7+export FORGE_BASE_URL=https://forge.test8+private_server9+http -s -i -X POST -H 'Origin: https://forge.test' -d '{}' "$PRIV/login/options" | tr -d '\r' >"$T/opts"10+has "$T/opts" '^Set-Cookie: __Host-forge-challenge=[A-Za-z0-9_-]{22}; Path=/; Max-Age=300; Secure; HttpOnly; SameSite=Strict$'11+code=$(ssh_as dami passkey | sed -n 's/^ .*#enroll=//p')12+fails_with 'HTTP 400: .*challenge' node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/dami.key" --other-browser13+ok node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/dami.key"14+fails_with 'HTTP 400: .*challenge' node "$T_HOME/test/authenticator.ts" login "$PRIV" "$T/dami.key" --other-browser15+ok node "$T_HOME/test/authenticator.ts" login "$PRIV" "$T/dami.key"@@ -0,0 +1,15 @@
1+# Through Caddy, the address the servers count by is the one Caddy saw2+# (X-Forge-Client-IP, set from {client_ip}): a client that sends its own3+# X-Forge-Client-IP, a new one each time, is still one address, and the4+# sign-in options limit stops it after 20.5+. "$(dirname "$0")/../lib.sh"6+user dami7+export FORGE_BASE_URL=https://forge.test8+caddy_server9+ask() { http -s -o /dev/null -w '%{http_code}' -X POST -H 'Origin: https://forge.test' -H "X-Forge-Client-IP: 198.51.100.$1" -d '{}' "$C/login/options"; }10+n=011+while [ "$n" -lt 20 ]; do12+ eq "$(ask "$n")" 20013+ n=$((n + 1))14+done15+eq "$(ask 99)" 429@@ -0,0 +1,11 @@
1+# Sign-in options keep nothing on the server until they are used, so a2+# flood of requests for them (here 1500, each from an address of its own,3+# more than the 1000 challenges the server once held) can't push out the4+# challenge of someone signing in meanwhile.5+. "$(dirname "$0")/../lib.sh"6+user dami7+export FORGE_BASE_URL=https://forge.test8+private_server9+code=$(ssh_as dami passkey | sed -n 's/^ .*#enroll=//p')10+ok node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/dami.key"11+ok node "$T_HOME/test/authenticator.ts" login "$PRIV" "$T/dami.key" --flood 1500@@ -0,0 +1,14 @@
1+# Each address may ask for sign-in options 20 times a minute; the next is2+# a 429 that says to wait. Another address is counted apart.3+. "$(dirname "$0")/../lib.sh"4+user dami5+export FORGE_BASE_URL=https://forge.test6+private_server7+ask() { http -s -o /dev/null -w '%{http_code}' -X POST -H 'Origin: https://forge.test' -H "X-Forge-Client-IP: $1" -d '{}' "$PRIV/login/options"; }8+n=09+while [ "$n" -lt 20 ]; do10+ eq "$(ask 192.0.2.1)" 20011+ n=$((n + 1))12+done13+eq "$(ask 192.0.2.1)" 42914+eq "$(ask 192.0.2.2)" 200