@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -1,3 +1,5 @@
1
+ import { ensureExecutable, rethrowIfMissing } from "@openparachute/depcheck";
2
+
1
3
  export interface CommandResult {
2
4
  code: number;
3
5
  stdout: string;
@@ -7,19 +9,34 @@ export interface CommandResult {
7
9
  export type Runner = (cmd: readonly string[]) => Promise<CommandResult>;
8
10
 
9
11
  export async function defaultRunner(cmd: readonly string[]): Promise<CommandResult> {
12
+ // Pre-flight the binary so a missing `tailscale` surfaces the friendly
13
+ // install UX (`@openparachute/depcheck`) instead of a raw spawn throw —
14
+ // closes the no-hint gap where `parachute expose tailnet` on a box without
15
+ // tailscale died with `Executable not found in $PATH: "tailscale"`.
16
+ // `cmd[0]` is always present for a real call; guard for the empty edge.
17
+ const binary = cmd[0];
18
+ if (binary) ensureExecutable(binary);
10
19
  // Inherit env so `tailscale` sees PATH (and HOME for state dir). Bun.spawn
11
20
  // defaults to empty env — see api-modules-ops.ts:defaultRun for rationale.
12
- const proc = Bun.spawn([...cmd], {
13
- stdout: "pipe",
14
- stderr: "pipe",
15
- env: process.env,
16
- });
17
- const [stdout, stderr, code] = await Promise.all([
18
- new Response(proc.stdout).text(),
19
- new Response(proc.stderr).text(),
20
- proc.exited,
21
- ]);
22
- return { code, stdout, stderr };
21
+ try {
22
+ const proc = Bun.spawn([...cmd], {
23
+ stdout: "pipe",
24
+ stderr: "pipe",
25
+ env: process.env,
26
+ });
27
+ const [stdout, stderr, code] = await Promise.all([
28
+ new Response(proc.stdout).text(),
29
+ new Response(proc.stderr).text(),
30
+ proc.exited,
31
+ ]);
32
+ return { code, stdout, stderr };
33
+ } catch (err) {
34
+ // Belt-and-suspenders: a spawn that slips past the pre-flight (binary
35
+ // removed between which() and spawn, or a race) still surfaces the
36
+ // friendly MissingDependencyError rather than the raw spawn throw.
37
+ if (binary) rethrowIfMissing(err, binary);
38
+ throw err;
39
+ }
23
40
  }
24
41
 
25
42
  export class TailscaleError extends Error {
package/src/totp.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * TOTP (RFC 6238) primitives + single-use backup codes for hub-login 2FA
4
+ * (hub#473). The pure crypto layer — no DB, no HTTP. The persistence layer
5
+ * (`two-factor-store.ts`) reads/writes these against `users` in hub.db; the
6
+ * login + enroll handlers compose both.
7
+ *
8
+ * Approach ported from `parachute-vault/src/two-factor.ts` (the deprecated
9
+ * vault impl), with two deliberate hub-side changes:
10
+ *
11
+ * - Storage is hub.db's `users` row, not vault's `config.yaml`. That lives
12
+ * in `two-factor-store.ts`; this file stays storage-agnostic.
13
+ * - Backup codes are hashed with **argon2id** (`@node-rs/argon2`), the same
14
+ * hasher hub uses for passwords (`users.ts`), rather than vault's bcrypt.
15
+ * One hash family across the hub keeps the dependency surface minimal and
16
+ * matches the brief ("same hash as passwords").
17
+ *
18
+ * TOTP parameters (interop default — what Google Authenticator / 1Password /
19
+ * Authy expect): SHA-1, 6 digits, 30s period. Validation accepts a ±1 window
20
+ * (≈90s effective tolerance) for clock drift. A given (secret, counter) is
21
+ * single-use within its acceptance lifetime — replays inside the window are
22
+ * rejected via an in-memory cache.
23
+ */
24
+ import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";
25
+ import * as OTPAuth from "otpauth";
26
+
27
+ /** Issuer label shown in the authenticator app + encoded in the otpauth URI. */
28
+ export const TOTP_ISSUER = "Parachute Hub";
29
+ /** Number of single-use backup codes minted per enrollment. */
30
+ export const BACKUP_CODE_COUNT = 10;
31
+ /** Length (characters) of each backup code. */
32
+ const BACKUP_CODE_LENGTH = 10;
33
+ /** TOTP secret size in bytes (20 = 160 bits, the RFC 6238 / RFC 4226 default). */
34
+ const TOTP_SECRET_BYTES = 20;
35
+
36
+ function makeTotp(secretBase32: string, label: string): OTPAuth.TOTP {
37
+ return new OTPAuth.TOTP({
38
+ issuer: TOTP_ISSUER,
39
+ label,
40
+ algorithm: "SHA1",
41
+ digits: 6,
42
+ period: 30,
43
+ secret: OTPAuth.Secret.fromBase32(secretBase32),
44
+ });
45
+ }
46
+
47
+ export interface GeneratedSecret {
48
+ /** Base32-encoded secret — show to the user for manual authenticator entry. */
49
+ secret: string;
50
+ /** `otpauth://totp/...` URI — encode as a QR code for scanning. */
51
+ otpauthUrl: string;
52
+ }
53
+
54
+ /**
55
+ * Generate a fresh TOTP secret + its `otpauth://` provisioning URI. `label`
56
+ * is the account label rendered in the authenticator app (typically the
57
+ * username). Does NOT persist anything — the caller stores the returned
58
+ * `secret` only after the user confirms a code (proving the authenticator
59
+ * was set up correctly).
60
+ */
61
+ export function generateTotpSecret(label: string): GeneratedSecret {
62
+ const secret = new OTPAuth.Secret({ size: TOTP_SECRET_BYTES }).base32;
63
+ const totp = makeTotp(secret, label);
64
+ return { secret, otpauthUrl: totp.toString() };
65
+ }
66
+
67
+ /** Build the `otpauth://` URI for an existing secret (e.g. re-display during enroll). */
68
+ export function otpauthUrlFor(secretBase32: string, label: string): string {
69
+ return makeTotp(secretBase32, label).toString();
70
+ }
71
+
72
+ /**
73
+ * In-memory cache of recently-used TOTP counters, to reject replay inside the
74
+ * ±1 acceptance window. Key = "sha256(secret):counter"; value = expiry ms.
75
+ * Bounded — entries auto-expire ~2 min after their window closes. Process-
76
+ * local (a restart clears it, which is itself fine: the window is 90s).
77
+ */
78
+ const usedTotpCounters = new Map<string, number>();
79
+
80
+ function gcUsedTotp(now: number): void {
81
+ for (const [k, exp] of usedTotpCounters) {
82
+ if (exp < now) usedTotpCounters.delete(k);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Verify a 6-digit TOTP code against `secretBase32`. Accepts ±1 window
88
+ * (prev / current / next 30s period). A given (secret, counter) is single-use
89
+ * within its acceptance lifetime — replays are rejected.
90
+ *
91
+ * `markUsed`: set false in tests that want to verify the same code twice.
92
+ * Defaults to true in production so a captured code can't be replayed inside
93
+ * its ~90s validity window.
94
+ */
95
+ export function verifyTotpCode(secretBase32: string, code: string, markUsed = true): boolean {
96
+ const trimmed = code.trim().replace(/\s+/g, "");
97
+ if (!/^\d{6}$/.test(trimmed)) return false;
98
+ try {
99
+ const totp = makeTotp(secretBase32, "owner");
100
+ const delta = totp.validate({ token: trimmed, window: 1 });
101
+ if (delta === null) return false;
102
+
103
+ const now = Date.now();
104
+ gcUsedTotp(now);
105
+ const counter = Math.floor(now / 30_000) + delta;
106
+ // Hash the secret so the in-memory replay cache never holds the plaintext
107
+ // TOTP secret as a map key (defense in depth against heap dumps / logs).
108
+ const secretHash = createHash("sha256").update(secretBase32).digest("hex");
109
+ const key = `${secretHash}:${counter}`;
110
+ if (usedTotpCounters.has(key)) return false;
111
+ if (markUsed) {
112
+ // Expire the entry a bit after the outer edge of the acceptance window.
113
+ usedTotpCounters.set(key, now + 120_000);
114
+ }
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /** Test-only: reset the replay-protection cache between cases. */
122
+ export function _resetTotpReplayCache(): void {
123
+ usedTotpCounters.clear();
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Backup codes
128
+ // ---------------------------------------------------------------------------
129
+
130
+ function randomBackupCode(): string {
131
+ // Lowercase alphanumeric minus ambiguous glyphs (0/o, 1/l/i). Read-aloud
132
+ // friendly + unambiguous when typed back in. Formatted as two 5-char groups
133
+ // (`abcde-fghij`) for legibility; the hyphen is cosmetic and stripped on
134
+ // verify so the user can type it with or without.
135
+ const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
136
+ const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH));
137
+ let out = "";
138
+ for (let i = 0; i < BACKUP_CODE_LENGTH; i++) {
139
+ out += alphabet[bytes[i]! % alphabet.length];
140
+ if (i === 4) out += "-";
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** Normalize a backup code for hashing / comparison: lowercase, no whitespace, no hyphens. */
146
+ export function normalizeBackupCode(code: string): string {
147
+ return code
148
+ .trim()
149
+ .toLowerCase()
150
+ .replace(/[\s-]+/g, "");
151
+ }
152
+
153
+ export interface GeneratedBackupCodes {
154
+ /** Plaintext codes to show the user ONCE (hyphenated for display). */
155
+ codes: string[];
156
+ /** argon2id hashes of the normalized codes — what gets stored. */
157
+ hashes: string[];
158
+ }
159
+
160
+ /**
161
+ * Generate {@link BACKUP_CODE_COUNT} fresh backup codes + their argon2id
162
+ * hashes. The plaintext `codes` are displayed once at enrollment; only the
163
+ * `hashes` are persisted (as a JSON array). Each code is hashed in its
164
+ * normalized form so display-formatting (hyphen) never affects verification.
165
+ */
166
+ export async function generateBackupCodes(): Promise<GeneratedBackupCodes> {
167
+ const codes: string[] = [];
168
+ const hashes: string[] = [];
169
+ for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
170
+ const code = randomBackupCode();
171
+ codes.push(code);
172
+ hashes.push(await argonHash(normalizeBackupCode(code)));
173
+ }
174
+ return { codes, hashes };
175
+ }
176
+
177
+ /**
178
+ * Check a submitted backup code against a stored hash list. Returns the
179
+ * **index** of the matching hash (so the caller can splice it out and persist
180
+ * the shorter list — single-use consumption), or `-1` for no match.
181
+ *
182
+ * Pure: does NOT mutate the input list or persist anything. Consumption +
183
+ * persistence is the store layer's job (`two-factor-store.ts`), which holds
184
+ * the DB transaction so verify-then-consume is atomic against concurrent
185
+ * login attempts.
186
+ */
187
+ export async function findBackupCodeIndex(
188
+ code: string,
189
+ hashes: readonly string[],
190
+ ): Promise<number> {
191
+ const normalized = normalizeBackupCode(code);
192
+ if (!normalized) return -1;
193
+ for (let i = 0; i < hashes.length; i++) {
194
+ try {
195
+ if (await argonVerify(hashes[i]!, normalized)) return i;
196
+ } catch {
197
+ // Corrupt / non-argon hash — skip.
198
+ }
199
+ }
200
+ return -1;
201
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * `/account/2fa` — user self-service TOTP 2FA enroll / disenroll (hub#473).
3
+ *
4
+ * GET /account/2fa — render current state (enrolled → status+disenroll;
5
+ * not enrolled → "set up" CTA).
6
+ * POST /account/2fa — dispatch on the `action` field:
7
+ * start → generate a secret + render QR/confirm
8
+ * confirm → verify code vs the in-flight secret,
9
+ * persist enrollment, show backup codes
10
+ * disable → verify current password, clear 2FA
11
+ *
12
+ * Auth posture: every endpoint requires an active session (the signed-in user
13
+ * acts on their OWN account). Same session-or-302-to-/login shape as
14
+ * `/account/change-password`.
15
+ *
16
+ * Enrollment is browser-first (right for headless servers): the secret is
17
+ * generated server-side, shown as a QR + manual base32 key, and only PERSISTED
18
+ * after the user confirms a live code (proving the authenticator was set up).
19
+ * The in-flight secret rides a hidden form field between `start` and `confirm`
20
+ * — it isn't stored until confirmation, so an abandoned setup leaves no state.
21
+ *
22
+ * No JSON layer — server-rendered HTML forms, same as the rest of `/account/*`,
23
+ * so 2FA setup works without JS.
24
+ */
25
+ import type { Database } from "bun:sqlite";
26
+ import QRCode from "qrcode";
27
+ import { renderAdminError } from "./admin-login-ui.ts";
28
+ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
29
+ import { isHttpsRequest } from "./request-protocol.ts";
30
+ import { findActiveSession } from "./sessions.ts";
31
+ import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
32
+ import {
33
+ backupCodesRemaining,
34
+ clearEnrollment,
35
+ getTotpState,
36
+ isTotpEnrolled,
37
+ persistEnrollment,
38
+ } from "./two-factor-store.ts";
39
+ import {
40
+ renderTwoFactorBackupCodes,
41
+ renderTwoFactorEnrolled,
42
+ renderTwoFactorEnrolling,
43
+ renderTwoFactorNotEnrolled,
44
+ } from "./two-factor-ui.ts";
45
+ import { PASSWORD_MAX_LEN, type User, getUserById, verifyPassword } from "./users.ts";
46
+
47
+ export interface TwoFactorDeps {
48
+ db: Database;
49
+ /** Test seam — defaults to real clock. */
50
+ now?: () => Date;
51
+ }
52
+
53
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
54
+ return new Response(body, {
55
+ status,
56
+ headers: { "content-type": "text/html; charset=utf-8", ...extra },
57
+ });
58
+ }
59
+
60
+ function redirect(location: string, extra: Record<string, string> = {}): Response {
61
+ return new Response(null, { status: 302, headers: { location, ...extra } });
62
+ }
63
+
64
+ /** Resolve the signed-in user, or a Response to return (302 / 401). */
65
+ function requireUser(deps: TwoFactorDeps, req: Request): { user: User } | { response: Response } {
66
+ const session = findActiveSession(deps.db, req);
67
+ if (!session) {
68
+ return { response: redirect(`/login?next=${encodeURIComponent("/account/2fa")}`) };
69
+ }
70
+ const user = getUserById(deps.db, session.userId);
71
+ if (!user) {
72
+ return { response: redirect("/login") };
73
+ }
74
+ return { user };
75
+ }
76
+
77
+ /** Render the QR SVG for a secret + the enrolling page. */
78
+ async function renderEnrolling(
79
+ csrfToken: string,
80
+ secret: string,
81
+ label: string,
82
+ errorMessage?: string,
83
+ ): Promise<Response> {
84
+ const otpauthUrl = otpauthUrlFor(secret, label);
85
+ // Inline SVG — self-contained, no external fetch (privacy posture).
86
+ const qrSvg = await QRCode.toString(otpauthUrl, {
87
+ type: "svg",
88
+ margin: 0,
89
+ errorCorrectionLevel: "M",
90
+ });
91
+ return htmlResponse(
92
+ renderTwoFactorEnrolling({
93
+ csrfToken,
94
+ qrSvg,
95
+ secret,
96
+ ...(errorMessage ? { errorMessage } : {}),
97
+ }),
98
+ );
99
+ }
100
+
101
+ /**
102
+ * GET /account/2fa — render the current 2FA state for the signed-in user.
103
+ */
104
+ export function handleTwoFactorGet(req: Request, deps: TwoFactorDeps): Response {
105
+ const got = requireUser(deps, req);
106
+ if ("response" in got) return got.response;
107
+ const user = got.user;
108
+
109
+ const csrf = ensureCsrfToken(req);
110
+ const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
111
+
112
+ if (isTotpEnrolled(deps.db, user.id)) {
113
+ const state = getTotpState(deps.db, user.id);
114
+ return htmlResponse(
115
+ renderTwoFactorEnrolled({
116
+ csrfToken: csrf.token,
117
+ enrolledAt: state.enrolledAt,
118
+ backupCodesRemaining: state.backupCodes.length,
119
+ }),
120
+ 200,
121
+ extra,
122
+ );
123
+ }
124
+ // `?disabled=1` — the disenroll POST 302s here so a refresh doesn't re-POST;
125
+ // surface a one-line confirmation on the not-enrolled page.
126
+ const disabled = new URL(req.url).searchParams.get("disabled") === "1";
127
+ return htmlResponse(
128
+ renderTwoFactorNotEnrolled({
129
+ csrfToken: csrf.token,
130
+ ...(disabled ? { notice: "Two-factor authentication has been turned off." } : {}),
131
+ }),
132
+ 200,
133
+ extra,
134
+ );
135
+ }
136
+
137
+ /**
138
+ * POST /account/2fa — dispatch on `action`.
139
+ */
140
+ export async function handleTwoFactorPost(req: Request, deps: TwoFactorDeps): Promise<Response> {
141
+ const got = requireUser(deps, req);
142
+ if ("response" in got) {
143
+ // For a POST without a session, a 302 to /login is fine (browser will
144
+ // follow with the form lost, which is acceptable — re-auth, then retry).
145
+ return got.response;
146
+ }
147
+ const user = got.user;
148
+
149
+ const form = await req.formData();
150
+ const formCsrf = form.get(CSRF_FIELD_NAME);
151
+ const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
152
+ if (!verifyCsrfToken(req, csrfToken || null)) {
153
+ return htmlResponse(
154
+ renderAdminError({
155
+ title: "Invalid form submission",
156
+ message: "The form's CSRF token did not match. Reload the page and try again.",
157
+ }),
158
+ 400,
159
+ );
160
+ }
161
+
162
+ const action = String(form.get("action") ?? "");
163
+
164
+ // --- start: generate a secret, render QR + confirm form ---------------
165
+ if (action === "start") {
166
+ // Refuse to start a fresh enrollment if already enrolled — disenroll
167
+ // first. Prevents accidentally clobbering a working setup.
168
+ if (isTotpEnrolled(deps.db, user.id)) {
169
+ return htmlResponse(
170
+ renderTwoFactorEnrolled({
171
+ csrfToken,
172
+ enrolledAt: getTotpState(deps.db, user.id).enrolledAt,
173
+ backupCodesRemaining: backupCodesRemaining(deps.db, user.id),
174
+ errorMessage: "Two-factor is already enabled. Turn it off first to re-enroll.",
175
+ }),
176
+ 409,
177
+ );
178
+ }
179
+ const { secret } = generateTotpSecret(user.username);
180
+ return renderEnrolling(csrfToken, secret, user.username);
181
+ }
182
+
183
+ // --- confirm: verify code vs the in-flight secret, persist ------------
184
+ if (action === "confirm") {
185
+ const secret = String(form.get("secret") ?? "");
186
+ const code = String(form.get("code") ?? "");
187
+ // Validate the in-flight secret format before it reaches verifyTotpCode /
188
+ // persistEnrollment (N1 — cheap defense in depth). The secret is a
189
+ // server-minted base32 string round-tripped through a hidden form field;
190
+ // it's session-gated + CSRF'd, but a malformed value (truncated paste,
191
+ // hand-crafted POST) should be rejected explicitly rather than stored.
192
+ // base32 alphabet (A–Z, 2–7) + optional `=` padding, ≥16 chars (our secret
193
+ // is 20 bytes → 32 base32 chars; 16 is a conservative floor).
194
+ if (!secret || !/^[A-Z2-7]+=*$/i.test(secret) || secret.length < 16) {
195
+ // Lost / malformed in-flight secret (stale form, bad paste) — restart.
196
+ return htmlResponse(
197
+ renderTwoFactorNotEnrolled({
198
+ csrfToken,
199
+ errorMessage: "Setup expired. Please start again.",
200
+ }),
201
+ 400,
202
+ );
203
+ }
204
+ // Defensive: someone POSTing confirm for an already-enrolled account.
205
+ if (isTotpEnrolled(deps.db, user.id)) {
206
+ return htmlResponse(
207
+ renderTwoFactorEnrolled({
208
+ csrfToken,
209
+ enrolledAt: getTotpState(deps.db, user.id).enrolledAt,
210
+ backupCodesRemaining: backupCodesRemaining(deps.db, user.id),
211
+ errorMessage: "Two-factor is already enabled.",
212
+ }),
213
+ 409,
214
+ );
215
+ }
216
+ if (!verifyTotpCode(secret, code)) {
217
+ return renderEnrolling(
218
+ csrfToken,
219
+ secret,
220
+ user.username,
221
+ "That code didn't match. Make sure your device clock is correct and try the current code.",
222
+ );
223
+ }
224
+ const result = await persistEnrollment(
225
+ deps.db,
226
+ user.id,
227
+ secret,
228
+ deps.now ?? (() => new Date()),
229
+ );
230
+ // Show the backup codes ONCE. Setting no-store so the browser doesn't
231
+ // cache the page with the plaintext codes in it.
232
+ return htmlResponse(renderTwoFactorBackupCodes({ codes: result.backupCodes }), 200, {
233
+ "cache-control": "no-store",
234
+ });
235
+ }
236
+
237
+ // --- disable: verify current password, clear 2FA ----------------------
238
+ if (action === "disable") {
239
+ if (!isTotpEnrolled(deps.db, user.id)) {
240
+ // Already off — render the not-enrolled page (idempotent).
241
+ return htmlResponse(
242
+ renderTwoFactorNotEnrolled({
243
+ csrfToken,
244
+ notice: "Two-factor authentication is already off.",
245
+ }),
246
+ );
247
+ }
248
+ const password = String(form.get("password") ?? "");
249
+ const state = getTotpState(deps.db, user.id);
250
+ const renderEnrolledError = (msg: string, status: number): Response =>
251
+ htmlResponse(
252
+ renderTwoFactorEnrolled({
253
+ csrfToken,
254
+ enrolledAt: state.enrolledAt,
255
+ backupCodesRemaining: state.backupCodes.length,
256
+ errorMessage: msg,
257
+ }),
258
+ status,
259
+ );
260
+ if (!password) {
261
+ return renderEnrolledError("Enter your current password to turn off two-factor.", 400);
262
+ }
263
+ // Cap before argon2id verify (CPU-DoS guard — same posture as /login).
264
+ if (password.length > PASSWORD_MAX_LEN) {
265
+ return renderEnrolledError(`Password must be ≤ ${PASSWORD_MAX_LEN} characters.`, 413);
266
+ }
267
+ const ok = await verifyPassword(user, password);
268
+ if (!ok) {
269
+ return renderEnrolledError("That password is incorrect.", 401);
270
+ }
271
+ clearEnrollment(deps.db, user.id, deps.now ?? (() => new Date()));
272
+ // Redirect to the GET so a refresh doesn't re-POST. The not-enrolled
273
+ // page renders the success notice via a query flag.
274
+ return redirect("/account/2fa?disabled=1", {
275
+ "cache-control": "no-store",
276
+ "x-secure-context": isHttpsRequest(req) ? "https" : "http",
277
+ });
278
+ }
279
+
280
+ return htmlResponse(
281
+ renderAdminError({
282
+ title: "Unknown action",
283
+ message: "That two-factor action isn't recognized. Reload the page and try again.",
284
+ }),
285
+ 400,
286
+ );
287
+ }