clone

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

Invites check the code first, and count posts per address

cat committed 473a10f
/login/invite/accept and forge user accept checked the key before the code, so anyone could ask whether a key had an account here ("that key is already on this forge"). The code is checked first now, on the web and in the shell, and without a live invite every post gets the same expired answer, whatever its key. The rate limit was one bucket for the whole forge that every post counted against, so junk posts could use up an invitee's turns: posts are counted per address now, 10 a minute, and only those with a live invite's code. Scenarios: a dead code answers the same for any key, and the per-address, live-only count.
commit
473a10f84180dbd2c0dc654aa8e72a2d525c9a7f
parent
2133113aac28d246e42c834ac8d1019d52b5d732
author
cat <admin@dev.site>
ci
✓ passed in 118s, signed by cat
kind
fix risk score 1.8 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 473a10f84180dbd2c0dc654aa8e72a2d525c9a7f
tree bec2e222de26eabd889ce8cd76f875a9728fd18d
script .forge/ci 8d2000a416f8160bcf710d98b72af9eb76287f58
result pass
exit 0
started 2026-10-19T07:46:28Z
duration 118s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:45ff5f20e2fa5bd0fd9777716e76d514b0581d1dcd5b57b209822e6d19711e78 318 lines
|pushed wip/i18n
|c3c5e47 running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|c3c5e47 pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|e368313 running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|e368313 fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|1bac5e0 running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|1bac5e0 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 10999ed -> /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
|cfe84f8 running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|cfe84f8 pass in 0s, signed
|pushed main
|32ae0a5 running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|32ae0a5 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 4104c25 -> /sites/dami/forge/guide/
|(node:87972) 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 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www
|(node:88133) 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 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www
|(node:88305) 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 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www
|(node:88472) 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 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www
|(node:89178) 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 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.TN0tGN/wt/demo/root/.forge/www
|235 of 235 pages render the same without 11ty
|guide: 8 files, links ok
|ok

Signature: good, cat

6 files changed, +87 -24
MREADME.md9+++++---
Mbin/forge5++++-
Mlib/identity.ts9++++++--
Mlib/private-server.ts34++++++++++++++----------------
Atest/scenarios/people-invite-key-hidden.sh30++++++++++++++++++++++++++
Atest/scenarios/people-invite-limit.sh24+++++++++++++++++++++
M README.md +6 -3
M bin/forge +4 -1
M lib/identity.ts +7 -2
@@ -655,6 +655,11 @@ function operatorCommand(state: string, cmd: string, args: string[], out: (s: st
655655 case 'invited':656656 if (args.length !== 1) fail('usage: invited <name>');657657 return id.invited(args[0]) ? 0 : 1;658+ case 'invitation':659+ // Is the code a live invite's? Without using it up.660+ if (args.length !== 1) fail('usage: invitation <code>');661+ if (!id.invitation(args[0])) fail(INVITE_EXPIRED);662+ return 0;658663 case 'accept': {659664 if (args.length !== 1) fail('usage: accept <code>');660665 const i = id.accept(args[0]);
@@ -695,12 +700,12 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
695700 const r = Identity.check(args[0]);696701 if (r !== 'ok') fail(`${args[0]}: ${r}`);697702 }698- else if (['invite', 'invites', 'invited', 'accept', 'users'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));703+ else if (['invite', 'invites', 'invited', 'invitation', 'accept', 'users'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));699704 else if (['passkey', 'passkeys', 'sessions', 'token', 'tokens'].includes(cmd)) {700705 const user = process.env.FORGE_USER;701706 if (!user) fail('no FORGE_USER');702707 sshCommand(state, user!, cmd, args, (s) => process.stdout.write(s), (s) => process.stderr.write(s));703- } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|accept|users ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');708+ } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|invitation|accept|users ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');704709 } catch (e) {705710 if (!(e instanceof Usage)) throw e;706711 process.stderr.write(`forge: ${e.message}\n`);
M lib/private-server.ts +16 -18
@@ -187,10 +187,12 @@ function forgeCmd(cfg: PrivateConfig, args: string[], input: string, extra: Node
187187 // What bin/forge said it refused: its "forge: " lines.188188 const refusal = (err: string, otherwise: string): string => err.split('\n').filter((l) => l.startsWith('forge: ')).map((l) => l.slice(7)).join('\n') || otherwise;189189 190-// Invites: the forge as a whole takes at most this many posts a minute191-// (the codes can't be guessed; this keeps anyone from trying), and one192-// accept at a time, since each writes .forge/keys.193-const INVITE_POSTS_PER_MINUTE = 30;190+// Invites: each address may post this many times a minute with a live191+// invite's code, and one accept runs at a time, since each writes192+// .forge/keys. A dead code (the codes can't be guessed) costs a lookup and193+// gets the same answer whatever was wrong, so it isn't counted: nobody can194+// use up an invitee's turns by posting junk.195+const INVITE_POSTS_PER_MINUTE = 10;194196 // Sign-in options: each address may ask this many times a minute.195197 const OPTIONS_PER_MINUTE = 20;196198
@@ -405,23 +407,19 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
405407 // checks and adds the way forge user add does, using the invite up, and406408 // answers with a code for the passkey enrollment that follows on the407409 // same page (the one ssh passkey would print). Nobody is signed in by it.408- const invitePosts: number[] = [];410+ // The code is checked first: without a live invite every post gets the411+ // same answer, whatever its key, so nothing is learned about keys.412+ const invitePosts = new Window(INVITE_POSTS_PER_MINUTE, 60_000);409413 let accepting = false;410- async function invite(res: http.ServerResponse, what: 'options' | 'accept', posted: unknown): Promise<void> {414+ async function invite(req: http.IncomingMessage, res: http.ServerResponse, what: 'options' | 'accept', posted: unknown): Promise<void> {411415 const body = (posted && typeof posted === 'object' ? posted : {}) as Record<string, unknown>;412- const t = Date.now();413- while (invitePosts.length && t - invitePosts[0] >= 60_000) invitePosts.shift();414- if (invitePosts.length >= INVITE_POSTS_PER_MINUTE) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });415- invitePosts.push(t);416416 const code = typeof body.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.code) ? body.code : '';417- if (what === 'options') {418- const i = code ? gate.id.invitation(code) : null;419- if (!i) return json(res, 400, { error: INVITE_EXPIRED, expired: true });420- return json(res, 200, { user: i.user, repo: i.repo, role: i.role });421- }417+ const i = code ? gate.id.invitation(code) : null;418+ if (!i) return json(res, 400, { error: INVITE_EXPIRED, expired: true });419+ if (!invitePosts.take(clientIp(req))) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });420+ if (what === 'options') return json(res, 200, { user: i.user, repo: i.repo, role: i.role });422421 const key = typeof body.key === 'string' ? body.key.trim() : '';423422 if (!key || key.length > 8192 || /[\r\n]/.test(key)) return json(res, 400, { error: 'Paste one SSH public key: the one line of your .pub file' });424- if (!code) return json(res, 400, { error: INVITE_EXPIRED, expired: true });425423 if (accepting) return json(res, 503, { error: 'The forge is busy: try again in a moment' }, { 'Retry-After': '5' });426424 accepting = true;427425 let r;
@@ -471,8 +469,8 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
471469 const { user, passkey } = await login.loginVerify(body, browser);472470 return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));473471 }474- if (url.pathname === '/login/invite/options') return await invite(res, 'options', body);475- if (url.pathname === '/login/invite/accept') return await invite(res, 'accept', body);472+ if (url.pathname === '/login/invite/options') return await invite(req, res, 'options', body);473+ if (url.pathname === '/login/invite/accept') return await invite(req, res, 'accept', body);476474 if (url.pathname === '/login/enroll/options') return await options((b) => login.enrollOptions(body.code, b));477475 if (url.pathname === '/login/enroll/verify') {478476 const { user, passkey } = await login.enrollVerify(body.code, body.response, agentName(req.headers['user-agent']), browser);
A test/scenarios/people-invite-key-hidden.sh +30 -0
@@ -0,0 +1,30 @@
1+# Without a live invite, posting a key to /login/invite/accept gets the same2+# answer whatever the key: one already on the forge, a new one or not a key3+# at all. So the page can't be asked whether a key has an account. The4+# shell side (forge user accept) checks the code first too.5+. "$(dirname "$0")/../lib.sh"6+user cat7+export FORGE_BASE_URL=https://forge.test8+private_server9+ssh-keygen -q -t ed25519 -N '' -C fox -f "$T/keys/fox"10+accept() {11+ node -e 'console.log(JSON.stringify({ code: process.argv[1], key: process.argv[2] }))' -- "$1" "$2" >"$T/body"12+ http -s -i -H 'Origin: https://forge.test' -H 'Content-Type: application/json' -d @"$T/body" "$PRIV/login/invite/accept" | grep -av '^Date: '13+}14+accept nonsense "$(cat "$T/keys/cat.pub")" >"$T/known"15+has "$T/known" '^HTTP/1.1 400 '16+has "$T/known" 'expired, been used or been taken back'17+lacks "$T/known" 'already on this forge'18+for key in "$(cat "$T/keys/fox.pub")" hello ""; do19+ accept nonsense "$key" >"$T/other"20+ cmp -s "$T/known" "$T/other" || { diff "$T/known" "$T/other" >&2 || true; fail "a dead invite answers differently for key '$key'"; }21+done22+# A code that was live and is used up answers the same.23+ok forge user invite fox24+code=$(sed -n 's/^ .*#invite=//p' "$T_LOG")25+ok forge user invites rm fox26+accept "$code" "$(cat "$T/keys/cat.pub")" >"$T/other"27+cmp -s "$T/known" "$T/other" || fail "a taken-back invite answers differently"28+printf '%s\n%s\n' nonsense "$(cat "$T/keys/cat.pub")" | forge user accept >"$T_LOG" 2>&1 && fail "user accept took a dead code"29+has "$T_LOG" 'expired, been used or been taken back'30+lacks "$T_LOG" 'already on this forge'
A test/scenarios/people-invite-limit.sh +24 -0