clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Sign-in challenges keep nothing on the server, belong to one browser, and are rate limited

cat committed 2133113
/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

11 files changed, +221 -31
MREADME.md7++-
Mbin/forge3+
Alib/limits.ts50+++++++++++++++++
Mlib/login.ts87++++++++++++++++++++++--------
Mlib/private-server.ts22++++++--
Mserver/container/start.sh6++-
Mtest/authenticator.ts22+++++++-
Atest/scenarios/login-challenge-browser.sh15+++++
Atest/scenarios/login-options-caddy.sh15+++++
Atest/scenarios/login-options-flood.sh11++++
Atest/scenarios/login-options-limit.sh14+++++
M README.md +6 -1
M bin/forge +3 -0
A lib/limits.ts +50 -0
@@ -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+}
M lib/login.ts +65 -22
@@ -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}`);
M lib/private-server.ts +17 -5
@@ -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) {
M server/container/start.sh +5 -1
M test/authenticator.ts +20 -2
@@ -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 }
A test/scenarios/login-challenge-browser.sh +15 -0
A test/scenarios/login-options-caddy.sh +15 -0
A test/scenarios/login-options-flood.sh +11 -0
A test/scenarios/login-options-limit.sh +14 -0