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

Bound Basic-auth argon2 work (M3 of #2922) #3020

Merged opened this

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

Merged

scout/forge:scout/3015-bound-basic-auth-argon2-work-m3-of-2922 → main 3 commits fork 6 threads · 6 unresolved

  • Merged. scout/3015-bound-basic-auth-argon2-work-m3-of-2922 was merged into main as 7a61024.

Review

Sign in to review this change.
13 files changed +229 −6
README.md +2
@@ -363,6 +363,8 @@ The relying party is the address the browser used (or
363363 `FORGE_URL`), so a passkey does not follow a forge to another hostname.
364364 Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) takes the same: a username and a password with the email optional, or a passkey (which needs an email; challenges from `POST /claim/options`), and a claim with neither a long enough password nor a passkey is refused without spending the link. An admin's everyday account (`web/pages/everyday.ts`) still makes its account with a passkey only (`web/passkey-ceremony.ts`, challenges from `POST /settings/everyday/options`): it reads no password, and without JavaScript the page says a passkey needs it.
365365
366 Password checks are bounded (`src/core/hash-gate.ts`): at most 4 argon2 verifications run at once, and a password over 1024 bytes is never hashed and cannot be set (sign-up, claim and change-password refuse it with "Use 1024 bytes or fewer."). A check over the cap is refused at once, not queued: the caller sees the same `throttled` answer as a full sign-in window (429 with `Retry-After: 1` on HTTP Basic, the API and the sign-in page, "try again" in the terminal; an over-long Basic password is a plain 401), and the attempt is given back to the sign-in windows. The 4-hash cap is a deliberate trade-off: an attacker who keeps 4 hashes busy can make real sign-ins get "try again in 1s"; the per-source sign-in window is what limits how long they can.
outdated

"the same throttled answer ... 429 with Retry-After on the API" is not true for Basic auth. Make it true or say Basic answers 401.

367
366368 A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned
367369 forge's `parent.json`) lists the parent under "Link an account you have" on `/signin`. The forge
368370 registers itself once at the parent's `/oauth/register` (the client id is kept
src/actions/users.ts +10 −2
@@ -3,6 +3,7 @@
33 * an account authenticates with over the SSH listener.
44 */
55
6 import { withinPasswordCap } from "../core/hash-gate.ts";
67 import { emailChanged, welcome } from "./verify.ts";
78 import type { App } from "../app.ts";
89 import type { Credential } from "../auth/webauthn.ts";
@@ -126,6 +127,7 @@ export async function signUp(
126127 const email = (input.email ?? "").trim();
127128 if (email !== "" && !givenEmail(email)) return err("invalid_email");
128129 if (input.password.length < MIN_SIGNUP_PASSWORD) return err("weak_password");
130 if (!withinPasswordCap(input.password)) return err("password_too_long");
129131 const secret = app.config.turnstileSecret;
130132 // The terminal cannot render a widget, so with a secret set it brings no
131133 // token and is refused, the forge's first account too: sign up on the web page.
@@ -316,7 +318,8 @@ export type ChangePasswordError =
316318 | "unauthorized"
317319 | "bad_credentials"
318320 | "throttled"
319 | "weak_password";
321 | "weak_password"
322 | "password_too_long";
320323
321324 /** Replace the password; the current one must be presented and verified. */
322325 export async function changePassword(
@@ -333,7 +336,12 @@ export async function changePassword(
333336 });
334337 if (!verified.ok) return err(verified.error.reason);
335338 const changed = await rehash(app.db, user.value.id, input.next);
336 if (!changed.ok) return err(changed.error === "weak_password" ? "weak_password" : "unauthorized");
339 if (!changed.ok)
340 return err(
341 changed.error === "weak_password" || changed.error === "password_too_long"
342 ? changed.error
343 : "unauthorized",
344 );
337345 app.bus.append({
338346 kind: "user.updated",
339347 actorId: user.value.id,
src/core/hash-gate.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * How many argon2 checks may run at once. Each one holds 64 MiB and a core,
3 * and the sign-in windows count attempts per minute, not attempts in flight:
4 * a burst of distinct accounts from many sources would still start them all
5 * together. Past the cap an attempt is refused at once, not queued, so a
6 * flood cannot pile up memory behind the hash. Passwords past `MAX_PASSWORD`
7 * bytes are never hashed: no person types one, and the cost grows with length.
8 */
9
10 export const MAX_CONCURRENT_HASHES = 4;
11 export const MAX_PASSWORD = 1024;
12 /** Seconds a refused caller is told to wait. */
13 export const GATE_RETRY_AFTER = 1;
14 /** What a person is told when a password they set is past the cap. */
15 export const PASSWORD_TOO_LONG = `Use ${MAX_PASSWORD} bytes or fewer.`;
16
17 /** Whether a password is within the cap, counted in bytes as it is hashed. */
18 export const withinPasswordCap = (password: string): boolean =>
19 Buffer.byteLength(password) <= MAX_PASSWORD;
20
21 let running = 0;
22
23 /** Run `work` in a free slot, or return undefined when every slot is taken. */
24 export async function withHashSlot<T>(
25 work: () => Promise<T>,
26 cap: number = MAX_CONCURRENT_HASHES,
27 ): Promise<T | undefined> {
28 if (running >= cap) return undefined;
29 running += 1;
30 try {
31 return await work();
32 } finally {
33 running -= 1;
34 }
35 }
src/core/users.ts +18 −2
@@ -3,6 +3,7 @@
33 * narrower; there is no second identity system.
44 */
55
6 import { GATE_RETRY_AFTER, withinPasswordCap, withHashSlot } from "./hash-gate.ts";
67 import { markUnproven, markVerified } from "./verify.ts";
78 import type { Database } from "bun:sqlite";
89 import { isSafeName } from "../git/guard.ts";
@@ -88,6 +89,8 @@ export const isActive = (user: User): boolean =>
8889 export const hiddenFrom = (user: User, viewer: User | undefined): boolean =>
8990 user.kind === "user" && !isActive(user) && viewer?.isAdmin !== true;
9091
92 // The minimum counts characters, the cap (`hash-gate.ts`) counts bytes: the
93 // hash reads bytes, and a person never meets the difference at these sizes.
9194 const MIN_PASSWORD = 10;
9295 const MAX_EMAIL = 254;
9396 const MAX_DISPLAY_NAME = 80;
@@ -98,6 +101,7 @@ export type CreateUserError =
98101 | "username_reserved"
99102 | "email_taken"
100103 | "weak_password"
104 | "password_too_long"
101105 | "signup_closed";
102106
103107 /**
@@ -280,6 +284,8 @@ export async function createUser(
280284 const password = agent ? undefined : input.password;
281285 if (password !== undefined && password.length < MIN_PASSWORD)
282286 return err("weak_password");
287 if (password !== undefined && !withinPasswordCap(password))
288 return err("password_too_long");
283289
284290 const existing = db
285291 .query<
@@ -423,8 +429,9 @@ export async function changePassword(
423429 db: Database,
424430 userId: string,
425431 next: string,
426 ): Promise<Result<void, "weak_password" | "no_such_user">> {
432 ): Promise<Result<void, "weak_password" | "password_too_long" | "no_such_user">> {
427433 if (next.length < MIN_PASSWORD) return err("weak_password");
434 if (!withinPasswordCap(next)) return err("password_too_long");
428435 const hash = await Bun.password.hash(next, "argon2id");
429436 const changed = db
430437 .query("UPDATE user SET password_hash = ?1 WHERE id = ?2 AND kind = 'user'")
@@ -494,10 +501,19 @@ export async function verifyPasswordThrottled(
494501 db: Database,
495502 attempt: PasswordAttempt,
496503 ): Promise<Result<User, PasswordRefusal>> {
504 if (!withinPasswordCap(attempt.password))
505 return err({ reason: "bad_credentials" });
497506 const admission = admitSignin(db, attempt.username, attempt.source);
outdated

The give-back and the 1s retryAfter on a full gate are untested. Add a test through verifyPasswordThrottled.

498507 if (!admission.admitted)
499508 return err({ reason: "throttled", retryAfter: admission.retryAfter });
500 const checked = await checkPassword(db, attempt.username, attempt.password);
509 const checked = await withHashSlot(() =>
510 checkPassword(db, attempt.username, attempt.password),
511 );
512 if (checked === undefined) {
outdated

This 429 only reaches the sign-in page and the API. HTTP Basic in src/http/server.ts drops it to a 401, so the bound is invisible there. Carry the retryAfter through, and test it.

513 // Never hashed, so it is not a guess: give it back and ask for a moment.
514 admission.succeeded();
515 return err({ reason: "throttled", retryAfter: GATE_RETRY_AFTER });
516 }
501517 if (!checked.ok) return err({ reason: "bad_credentials" });
502518 admission.succeeded();
503519 return checked;
src/http/context.ts +3
@@ -21,6 +21,9 @@ export interface Auth {
2121 readonly sessionId?: string;
2222 /** Set for a proposal token: all it may do (`http/proposal_gate.ts`). */
2323 readonly proposal?: Proposal;
24 /** Set when a Basic password was turned away by the sign-in windows or the
25 * hash gate, not found wrong: the request answers 429, not 401. */
26 readonly retryAfter?: number;
2427 }
2528
2629 export interface Ctx {
src/http/server.ts +7 −1
@@ -494,6 +494,7 @@ import {
494494 notFound,
495495 prefersHtml,
496496 securityFloor,
497 tooManyAttempts,
497498 } from "./respond.ts";
498499 import { compress } from "./compress.ts";
499500 import { RateLimiter, type Verdict } from "../core/ratelimit.ts";
@@ -590,7 +591,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
590591 }
591592 if (username !== "" && secret !== "") {
592593 // Counted in the sign-in windows like every password door; past them
593 // the right password is refused too, so a guesser learns nothing.
594 // the right password is refused too, with a 429 and Retry-After.
594595 const verified = await verifyPasswordThrottled(app.db, {
595596 username,
596597 password: secret,
@@ -598,6 +599,10 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> {
598599 });
599600 if (verified.ok)
600601 return { user: verified.value, scope: "admin", via: "password" };
602 // Not a wrong password: a git client or script must back off, not
603 // read this as bad credentials.
604 if (verified.error.reason === "throttled")
605 return { ...NO_AUTH, retryAfter: verified.error.retryAfter };
601606 }
602607 }
603608 return NO_AUTH;
@@ -2422,6 +2427,7 @@ async function answerGet(
24222427 return noRoute(request.method, url.pathname);
24232428 }
24242429 const auth = await resolveAuth(app, request);
2430 if (auth.retryAfter !== undefined) return tooManyAttempts(auth.retryAfter);
24252431 noteUsage(request, { pattern: matched.pattern, role: roleOf(auth) });
24262432 // From here a body's cap depends on who is asking (`http/limits.ts`), and
24272433 // a stream this request opens is the account's, to end with it.
src/tui/screens/common.ts +2
@@ -7,6 +7,7 @@
77 * of them. Nothing here knows about repos or issues — that is the point.
88 */
99
10 import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts";
1011 import { refusalText } from "../../http/refusal.ts";
1112 import { NOT_A_MEMBER } from "../../core/roles.ts";
1213 import type { Ctx, Screen } from "../app/app.ts";
@@ -841,6 +842,7 @@ const MESSAGES: Record<string, string> = {
841842 key_exists: "that key is already registered",
842843 taken: "that name is taken",
843844 weak_password: "the password is too short (10 characters at least)",
845 password_too_long: PASSWORD_TOO_LONG.toLowerCase().replace(/\.$/, ""),
844846 bad_credentials: "wrong username or password",
845847 throttled: "too many attempts: try again in a minute",

Low: the gate refuses with Retry-After 1s, but the terminal still says "try again in a minute" for every throttled. That is harmless but slightly wrong when the cause is the hash gate. If a later change touches this, word it without a duration, or carry retryAfter through to waitText the way the sign-up slot does.

846848 already_forked: "you already have a fork of this repo",
src/web/pages/account.ts +3
@@ -9,6 +9,7 @@
99 * has been read once.
1010 */
1111
12 import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts";
1213 import { resendVerification } from "../../actions/verify.ts";
1314 import { clientAddress } from "../../http/peer.ts";
1415 import { emailVerified } from "../../core/verify.ts";
@@ -506,6 +507,8 @@ export async function passwordUpdate(ctx: Ctx): Promise<Response> {
506507 field: "current" as const,
507508 text: "Too many attempts. Try again in a minute.",
508509 }
510 : changed.error === "password_too_long"
511 ? { field: "next" as const, text: PASSWORD_TOO_LONG }
509512 : changed.error === "weak_password"
510513 ? {
511514 field: "next" as const,
src/web/pages/claim.ts +2
@@ -18,6 +18,7 @@
1818 * so before anyone submits, and where to remove it.
1919 */
2020
21 import { PASSWORD_TOO_LONG, withinPasswordCap } from "../../core/hash-gate.ts";
2122 import { givenEmail, MIN_SIGNUP_PASSWORD, noEmail } from "../../actions/users.ts";
2223 import type { Credential } from "../../auth/webauthn.ts";
2324 import { crossOrigin, type Ctx } from "../../http/context.ts";
@@ -303,6 +304,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
303304 const errors = problems(ctx, values.username, values.email.trim(), withKey);
304305 if (!withKey && password.length < MIN_SIGNUP_PASSWORD)
305306 errors.password = `Passwords need at least ${MIN_SIGNUP_PASSWORD} characters.`;
307 else if (!withinPasswordCap(password)) errors.password = PASSWORD_TOO_LONG;
306308 if (Object.keys(errors).length > 0) return again(errors);
307309 // A key in the form is the passkey door; none is the password door.
308310 let credential: Credential | undefined;
src/web/pages/site.ts +2
@@ -8,6 +8,7 @@
88 * typed still in it. A dead end that loses input is a bug, not a message.
99 */
1010
11 import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts";
1112 import { NOT_A_MEMBER } from "../../core/roles.ts";
1213 import { storageLimitMessage } from "../../core/storage.ts";
1314 import { signOutHere } from "../../actions/signins.ts";
@@ -661,6 +662,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> {
661662 username_taken: { username: "That username is taken." },
662663 username_reserved: { username: "That name is reserved." },
663664 email_taken: { email: "That email already has an account." },
665 password_too_long: { password: PASSWORD_TOO_LONG },
664666 weak_password: { password: "Passwords need at least 12 characters." },
665667 invalid_email: { email: "That does not look like an email address." },
666668 human_check: { human: HUMAN_CHECK_MESSAGE },
test/hash-gate.test.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * Password hashing is bounded (#3015): four argon2 checks at once, and no
3 * password past 1024 bytes hashed or set. A full gate answers `throttled`
4 * (429 with Retry-After on Basic, the API and the sign-in page) and gives the
5 * attempt back to the sign-in windows; an over-long password is refused where
6 * it is set, in words, on every surface that sets one.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { signUp, changePassword as changeOwnPassword } from "../src/actions/users.ts";
14 import { MAX_CONCURRENT_HASHES, MAX_PASSWORD, withHashSlot } from "../src/core/hash-gate.ts";
15 import { admitSignin } from "../src/core/signin-window.ts";
16 import { changePassword, createUser, getUserById, verifyPasswordThrottled } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18
19 const LONG = "x".repeat(MAX_PASSWORD + 1);
20 const GOOD = "a-good-passphrase";
21
22 /** Hold every hash slot until the returned function is called. */
23 function fillGate(): () => Promise<void> {
24 let release!: () => void;
25 const held = new Promise<void>((r) => (release = r));
26 const holders = Array.from({ length: MAX_CONCURRENT_HASHES }, () => withHashSlot(() => held));
27 return async () => {
28 release();
outdated

Imports belong at the top of the file. Move these up and fold the two describe blocks together.

29 await Promise.all(holders);
30 };
31 }
32
33 test("slots beyond the cap are refused at once, and freed after", async () => {
34 let release!: () => void;
35 const held = new Promise<void>((r) => (release = r));
36 const first = withHashSlot(() => held.then(() => 1), 1);
37 expect(await withHashSlot(async () => 2, 1)).toBeUndefined();
38 release();
39 expect(await first).toBe(1);
40 expect(await withHashSlot(async () => 3, 1)).toBe(3);
41 });
42
43 test("a slot is freed when the work throws", async () => {
44 await withHashSlot(async () => {
45 throw new Error("x");
46 }, 1).catch(() => {});
47 expect(await withHashSlot(async () => 4, 1)).toBe(4);
48 });
49
50 describe("the gate and the cap on a forge", () => {
51 let dir: string;
52 let app: App;
53 let servers: Servers;
54 let ginaId: string;
55 const basic = (user: string, password: string) =>
56 fetch(`${servers.url}/api/v1/user`, {
57 headers: { authorization: `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}` },
58 });
59 const form = (path: string, fields: Record<string, string>) =>
60 fetch(`${servers.url}${path}`, {
61 method: "POST",
62 headers: { origin: servers.url },
63 body: new URLSearchParams(fields),
64 redirect: "manual",
65 });
66
67 beforeAll(async () => {
68 dir = await mkdtemp(join(tmpdir(), "forge-gate-"));
69 app = createApp({ dataDir: dir, port: 0 });
70 servers = await serve(app);
71 const made = await createUser(app.db, { origin: "seed", trusted: true,
72 username: "gina", email: "[email protected]", password: GOOD, adminIfFirst: true });
73 if (!made.ok) throw new Error(made.error);
74 ginaId = made.value.id;
75 });
76 afterAll(async () => {
77 await servers.stop?.();
78 await rm(dir, { recursive: true, force: true });
79 });
80
81 test("a full gate answers throttled with 1s and gives the window attempt back", async () => {
82 const release = fillGate();
83 const attempt = { username: "gina", password: GOOD, source: "10.0.0.9" };
84 // Far more tries than the per-account window (20/min) allows: each is
85 // refused by the gate, so none may be counted against it.
86 for (let i = 0; i < 40; i++) {
87 const out = await verifyPasswordThrottled(app.db, attempt);
88 expect(out.ok).toBe(false);
89 if (!out.ok) expect(out.error).toEqual({ reason: "throttled", retryAfter: 1 });
90 }
91 await release();
92 expect(admitSignin(app.db, "gina", "10.0.0.9").admitted).toBe(true);
93 expect((await verifyPasswordThrottled(app.db, attempt)).ok).toBe(true);
94 });
95
96 test("Basic with the gate full is 429 with Retry-After, not a 401", async () => {
97 const release = fillGate();
98 const res = await basic("gina", GOOD);
99 await release();
100 expect(res.status).toBe(429);
101 expect(res.headers.get("retry-after")).toBe("1");
102 expect((await basic("gina", GOOD)).status).toBe(200);
103 });
104
105 test("Basic with a password past the cap is a plain 401", async () => {
106 const res = await basic("gina", LONG);
107 expect(res.status).toBe(401);
108 expect(res.headers.get("retry-after")).toBeNull();
109 });
110
111 test("a password past the cap is refused where it is set", async () => {
112 const made = await createUser(app.db, { origin: "seed", trusted: true,
113 username: "long", email: "[email protected]", password: LONG });
114 expect(made.ok ? "ok" : made.error).toBe("password_too_long");
115 const changed = await changePassword(app.db, ginaId, LONG);
116 expect(changed.ok ? "ok" : changed.error).toBe("password_too_long");
117 });
118
119 test("sign-up and the own-password change refuse it too", async () => {
120 const signed = await signUp(app, { username: "lena", password: LONG }, { slot: { admitted: true } as never });
121 expect(signed.ok ? "ok" : signed.error).toBe("password_too_long");
122 const changed = await changeOwnPassword(
123 app,
124 getUserById(app.db, ginaId),
125 { current: GOOD, next: LONG, source: "10.0.0.8" },
126 );
127 expect(changed.ok ? "ok" : changed.error).toBe("password_too_long");
128 });
129
130 test("the sign-up page says so in words", async () => {
131 const res = await form("/signup", { username: "mila", password: LONG });
132 expect(res.status).toBe(422);
133 expect(await res.text()).toContain("Use 1024 bytes or fewer.");
134 });
135 });
test/signin-password-doors.test.ts +5 −1
@@ -67,7 +67,11 @@ describe("password doors share the sign-in windows", () => {
6767 test("HTTP Basic with a password refuses the right password after the window fills", async () => {
6868 const { url } = await forge();
6969 for (let i = 0; i < TRIES; i++) expect((await basic(url, `wrong-${i}`)).status).toBe(401);
70 expect((await basic(url, PASSWORD)).status).toBe(401);
70 // Past the window even the right password is turned away, as a 429 with
71 // Retry-After, the answer every other password door gives.
72 const refused = await basic(url, PASSWORD);
73 expect(refused.status).toBe(429);
74 expect(Number(refused.headers.get("retry-after"))).toBeGreaterThan(0);
7175 });
7276
7377 test("a right password is given back: git sending it on every request is not throttled", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs