@openparachute/hub 0.5.14-rc.9 → 0.6.0
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.
- package/README.md +23 -0
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +30 -21
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +41 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-modules-ops.ts +49 -11
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-explanations.ts +46 -18
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +71 -19
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/setup-wizard.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
renderCsrfHiddenInput,
|
|
56
56
|
verifyCsrfToken,
|
|
57
57
|
} from "./csrf.ts";
|
|
58
|
+
import { type ExposeState, readExposeState } from "./expose-state.ts";
|
|
58
59
|
import {
|
|
59
60
|
SETUP_EXPOSE_MODES,
|
|
60
61
|
type SetupExposeMode,
|
|
@@ -207,13 +208,41 @@ export interface DerivedWizardState {
|
|
|
207
208
|
*/
|
|
208
209
|
export const FIRST_VAULT_SHORT: CuratedModuleShort = "vault";
|
|
209
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Map a live exposure layer (`expose-state.json`) onto the wizard's
|
|
213
|
+
* `setup_expose_mode`. The two enums overlap exactly on the layers the
|
|
214
|
+
* exposure file can carry: `ExposeLayer` is `tailnet | public`, both of
|
|
215
|
+
* which are valid `SetupExposeMode` values. (There's no `localhost`
|
|
216
|
+
* exposure layer — running nothing is the absence of a state file, which
|
|
217
|
+
* `readExposeState` reports as `undefined`, so a missing/unexposed hub
|
|
218
|
+
* never seeds and the wizard still asks.)
|
|
219
|
+
*
|
|
220
|
+
* Returns `undefined` when no exposure is live (or the reader throws on
|
|
221
|
+
* a malformed file — we swallow that and fall through to "still ask"
|
|
222
|
+
* rather than crashing the wizard GET).
|
|
223
|
+
*/
|
|
224
|
+
function exposeModeFromLiveState(read: () => ExposeState | undefined): SetupExposeMode | undefined {
|
|
225
|
+
let state: ExposeState | undefined;
|
|
226
|
+
try {
|
|
227
|
+
state = read();
|
|
228
|
+
} catch {
|
|
229
|
+
// A corrupt expose-state.json shouldn't brick the wizard. Treat it
|
|
230
|
+
// as "no live exposure" and let the operator answer the step.
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
if (!state) return undefined;
|
|
234
|
+
// `ExposeLayer` ⊆ `SetupExposeMode` ("tailnet" | "public").
|
|
235
|
+
return state.layer;
|
|
236
|
+
}
|
|
237
|
+
|
|
210
238
|
/**
|
|
211
239
|
* Read DB + services.json to decide which step the wizard should render.
|
|
212
240
|
* Idempotent — re-running after partial setup picks up where it left
|
|
213
241
|
* off. Mostly read-only, with one specific write: on Render (or any
|
|
214
|
-
* platform `detectAutoExposeMode` recognizes),
|
|
215
|
-
*
|
|
216
|
-
*
|
|
242
|
+
* platform `detectAutoExposeMode` recognizes), OR when a live tailscale
|
|
243
|
+
* exposure (`expose-state.json`) is already up, the first call auto-
|
|
244
|
+
* seeds `setup_expose_mode` so the wizard skips the expose step.
|
|
245
|
+
* Subsequent calls find the setting present and are read-only.
|
|
217
246
|
*/
|
|
218
247
|
export function deriveWizardState(deps: {
|
|
219
248
|
db: Database;
|
|
@@ -224,6 +253,15 @@ export function deriveWizardState(deps: {
|
|
|
224
253
|
* SetupWizardDeps.env.
|
|
225
254
|
*/
|
|
226
255
|
env?: Record<string, string | undefined>;
|
|
256
|
+
/**
|
|
257
|
+
* Optional injected reader for the live exposure state
|
|
258
|
+
* (`~/.parachute/expose-state.json`). Defaults to the real
|
|
259
|
+
* `readExposeState`. Mirrors the `init.ts` seam (`readExposeStateFn`)
|
|
260
|
+
* so tests can drive the "a tailnet layer is already live" branch
|
|
261
|
+
* without writing a real state file. When `setup_expose_mode` is
|
|
262
|
+
* unset, the live exposure layer auto-seeds the setting (see below).
|
|
263
|
+
*/
|
|
264
|
+
readExposeStateFn?: () => ExposeState | undefined;
|
|
227
265
|
}): DerivedWizardState {
|
|
228
266
|
const hasAdmin = userCount(deps.db) > 0;
|
|
229
267
|
// The wizard's first-vault provisioning uses the curated `vault` short,
|
|
@@ -252,6 +290,23 @@ export function deriveWizardState(deps: {
|
|
|
252
290
|
) {
|
|
253
291
|
setSetting(deps.db, "setup_expose_mode", "public");
|
|
254
292
|
}
|
|
293
|
+
// hub#406 team-onboarding bug: `setup_expose_mode` (the wizard's
|
|
294
|
+
// answer) and `expose-state.json` (the live tailscale exposure) are
|
|
295
|
+
// orthogonal axes. An operator who ran `parachute expose tailnet`
|
|
296
|
+
// before opening the wizard has a live tailnet layer but no
|
|
297
|
+
// `setup_expose_mode` setting — so the wizard re-asked "how will this
|
|
298
|
+
// hub be reached?" even though tailnet was already up. Auto-seed the
|
|
299
|
+
// setting from the live exposure layer (tailnet→"tailnet",
|
|
300
|
+
// public→"public") so the answered-by-action case is treated as
|
|
301
|
+
// satisfied, mirroring the Render/Fly auto-seed above. Reading the
|
|
302
|
+
// live state is injected for testability (defaults to the real
|
|
303
|
+
// reader); a malformed/missing file falls through to "still ask."
|
|
304
|
+
if (getSetting(deps.db, "setup_expose_mode") === undefined) {
|
|
305
|
+
const seeded = exposeModeFromLiveState(deps.readExposeStateFn ?? readExposeState);
|
|
306
|
+
if (seeded !== undefined) {
|
|
307
|
+
setSetting(deps.db, "setup_expose_mode", seeded);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
255
310
|
const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
|
|
256
311
|
let step: WizardStep;
|
|
257
312
|
// Note: `"account"` is a visual-only step in the progress header —
|
|
@@ -314,6 +369,14 @@ export interface SetupWizardDeps {
|
|
|
314
369
|
* without mutating the real process env.
|
|
315
370
|
*/
|
|
316
371
|
env?: Record<string, string | undefined>;
|
|
372
|
+
/**
|
|
373
|
+
* Test seam: inject the live-exposure reader `deriveWizardState`
|
|
374
|
+
* consults to auto-seed `setup_expose_mode` from a live
|
|
375
|
+
* `parachute expose tailnet|public` (hub#406). Production omits this
|
|
376
|
+
* and the real `readExposeState` is used. Mirrors `init.ts`'s
|
|
377
|
+
* `readExposeStateFn` seam.
|
|
378
|
+
*/
|
|
379
|
+
readExposeStateFn?: () => ExposeState | undefined;
|
|
317
380
|
}
|
|
318
381
|
|
|
319
382
|
/**
|
|
@@ -446,7 +509,11 @@ export interface RenderAccountStepProps {
|
|
|
446
509
|
export function renderAccountStep(props: RenderAccountStepProps): string {
|
|
447
510
|
const { csrfToken, errorMessage, username, requireBootstrapToken, bootstrapToken } = props;
|
|
448
511
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
449
|
-
|
|
512
|
+
// Pre-fill "owner" on a fresh render (no prior submission) so the web wizard's
|
|
513
|
+
// default matches the CLI paths (`set-password`, `setup-wizard`) + the
|
|
514
|
+
// operator.token convention. Operators can still type any name. On a
|
|
515
|
+
// validation-failure re-render we echo back what they typed instead.
|
|
516
|
+
const usernameAttr = ` value="${escapeAttr(username ?? "owner")}"`;
|
|
450
517
|
const tokenAttr = bootstrapToken ? ` value="${escapeAttr(bootstrapToken)}"` : "";
|
|
451
518
|
// Bootstrap-token field comes FIRST when required. An operator who
|
|
452
519
|
// missed the log line is stopped here rather than after filling
|
|
@@ -1232,9 +1299,12 @@ function renderMcpTile(
|
|
|
1232
1299
|
<h2>Connect Claude Code (MCP)</h2>
|
|
1233
1300
|
<p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
|
|
1234
1301
|
<pre>${escapeHtml(bareCmd)}</pre>
|
|
1235
|
-
<p class="fine">
|
|
1236
|
-
|
|
1237
|
-
|
|
1302
|
+
<p class="fine">No token needed — the command triggers browser OAuth on
|
|
1303
|
+
first use (you sign in to this hub and approve access). For headless
|
|
1304
|
+
clients that can't do the browser flow, mint a hub token at
|
|
1305
|
+
<a href="/admin/tokens"><code>/admin/tokens</code></a> (or with
|
|
1306
|
+
<code>parachute auth mint-token</code>) and append
|
|
1307
|
+
<code>--header "Authorization: Bearer <token>"</code>.</p>
|
|
1238
1308
|
</div>`;
|
|
1239
1309
|
}
|
|
1240
1310
|
|
package/src/tailscale/run.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
}
|