@openparachute/hub 0.5.14-rc.8 → 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.
Files changed (87) 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-ops.test.ts +45 -0
  8. package/src/__tests__/api-revoke-token.test.ts +384 -0
  9. package/src/__tests__/api-users.test.ts +7 -2
  10. package/src/__tests__/auth.test.ts +157 -30
  11. package/src/__tests__/cli.test.ts +44 -5
  12. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  13. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  14. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  15. package/src/__tests__/expose.test.ts +52 -2
  16. package/src/__tests__/hub-server.test.ts +97 -0
  17. package/src/__tests__/hub.test.ts +85 -6
  18. package/src/__tests__/init.test.ts +102 -1
  19. package/src/__tests__/lifecycle.test.ts +464 -2
  20. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  21. package/src/__tests__/oauth-ui.test.ts +12 -1
  22. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  23. package/src/__tests__/resource-binding.test.ts +97 -0
  24. package/src/__tests__/scope-explanations.test.ts +77 -12
  25. package/src/__tests__/services-manifest.test.ts +122 -4
  26. package/src/__tests__/setup-wizard.test.ts +335 -15
  27. package/src/__tests__/status.test.ts +36 -0
  28. package/src/__tests__/two-factor-flow.test.ts +602 -0
  29. package/src/__tests__/two-factor.test.ts +183 -0
  30. package/src/__tests__/upgrade.test.ts +78 -1
  31. package/src/__tests__/users.test.ts +68 -0
  32. package/src/__tests__/vault-auth-status.test.ts +47 -6
  33. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  34. package/src/account-home-ui.ts +488 -38
  35. package/src/account-vault-token.ts +282 -0
  36. package/src/admin-handlers.ts +159 -4
  37. package/src/admin-login-ui.ts +49 -5
  38. package/src/admin-vaults.ts +48 -15
  39. package/src/api-account.ts +14 -0
  40. package/src/api-mint-token.ts +132 -24
  41. package/src/api-modules-ops.ts +49 -11
  42. package/src/api-revoke-token.ts +107 -21
  43. package/src/api-users.ts +29 -3
  44. package/src/cli.ts +26 -21
  45. package/src/clients.ts +18 -6
  46. package/src/cloudflare/config.ts +10 -4
  47. package/src/cloudflare/detect.ts +39 -44
  48. package/src/commands/auth.ts +165 -24
  49. package/src/commands/expose-2fa-warning.ts +34 -32
  50. package/src/commands/expose-auth-preflight.ts +89 -78
  51. package/src/commands/expose-cloudflare.ts +370 -12
  52. package/src/commands/expose.ts +8 -0
  53. package/src/commands/init.ts +33 -2
  54. package/src/commands/lifecycle.ts +386 -17
  55. package/src/commands/status.ts +22 -0
  56. package/src/commands/upgrade.ts +55 -11
  57. package/src/commands/wizard.ts +8 -4
  58. package/src/env-file.ts +10 -0
  59. package/src/help.ts +3 -1
  60. package/src/hub-db.ts +39 -1
  61. package/src/hub-server.ts +52 -0
  62. package/src/hub.ts +82 -14
  63. package/src/oauth-handlers.ts +298 -21
  64. package/src/oauth-ui.ts +10 -0
  65. package/src/operator-token.ts +151 -0
  66. package/src/pending-login.ts +116 -0
  67. package/src/rate-limit.ts +51 -0
  68. package/src/resource-binding.ts +134 -0
  69. package/src/scope-attenuation.ts +85 -0
  70. package/src/scope-explanations.ts +131 -14
  71. package/src/services-manifest.ts +112 -0
  72. package/src/setup-wizard.ts +77 -7
  73. package/src/tailscale/run.ts +28 -11
  74. package/src/totp.ts +201 -0
  75. package/src/two-factor-handlers.ts +287 -0
  76. package/src/two-factor-store.ts +181 -0
  77. package/src/two-factor-ui.ts +462 -0
  78. package/src/users.ts +58 -0
  79. package/src/vault/auth-status.ts +71 -19
  80. package/src/vault-hub-origin-env.ts +163 -0
  81. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  82. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  83. package/web/ui/dist/index.html +2 -2
  84. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  85. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  86. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  87. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -185,6 +185,39 @@ export interface ServiceEntry {
185
185
  * with display metadata attached.
186
186
  */
187
187
  uis?: Record<string, UiSubUnit>;
188
+ /**
189
+ * Last `parachute start` failure that's still actionable, persisted so a
190
+ * *subsequent* `parachute status` (a separate invocation that only reads
191
+ * this manifest) + the admin SPA can surface it. Today the only writer is
192
+ * the lifecycle start preflight when a startCmd binary is missing from PATH
193
+ * (`@openparachute/depcheck` `MissingDependencyError.toWire()`): the row
194
+ * shows "failed to start — <binary> not installed" with the install info
195
+ * instead of a bare orphan-timeout. Cleared on the next successful start.
196
+ *
197
+ * Stored as the structured `missing_dependency` wire so the SPA can render
198
+ * the dedicated install card; `parachute status` reads `error_description`.
199
+ * Validated as a pass-through object (shape owned by depcheck) rather than
200
+ * re-typed field-by-field here.
201
+ */
202
+ lastStartError?: ServiceEntryStartError;
203
+ }
204
+
205
+ /**
206
+ * Persisted start-failure detail on a ServiceEntry. Mirrors depcheck's
207
+ * `MissingDependencyWire` for the missing-dependency case; `error_type` is
208
+ * left open so a future non-dependency start failure could reuse the field.
209
+ */
210
+ export interface ServiceEntryStartError {
211
+ error_type: string;
212
+ error_description: string;
213
+ /** Present for `error_type: "missing_dependency"`. */
214
+ binary?: string;
215
+ why?: string | null;
216
+ docs_url?: string | null;
217
+ install?: { darwin?: string; linux?: string; generic?: string };
218
+ sysadmin_hint?: string;
219
+ /** ISO timestamp of when the failure was recorded. */
220
+ at?: string;
188
221
  }
189
222
 
190
223
  export interface ServicesManifest {
@@ -251,6 +284,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
251
284
  }
252
285
  const uis = e.uis;
253
286
  const validatedUis = validateUis(uis, where);
287
+ const validatedStartError = validateStartError(e.lastStartError, where);
254
288
  const entry: ServiceEntry = { name, port, paths: paths as string[], health, version };
255
289
  if (displayName !== undefined) entry.displayName = displayName;
256
290
  if (tagline !== undefined) entry.tagline = tagline;
@@ -258,9 +292,48 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
258
292
  if (installDir !== undefined) entry.installDir = installDir;
259
293
  if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
260
294
  if (validatedUis !== undefined) entry.uis = validatedUis;
295
+ if (validatedStartError !== undefined) entry.lastStartError = validatedStartError;
261
296
  return entry;
262
297
  }
263
298
 
299
+ /**
300
+ * Validate the optional `lastStartError` field. `undefined` round-trips
301
+ * unchanged. A present value must be an object carrying the two required
302
+ * string fields (`error_type`, `error_description`); the remaining fields
303
+ * (the missing-dependency detail) pass through with light type-narrowing so
304
+ * the SPA install card has them, but we never hard-fail an otherwise-valid
305
+ * row on a malformed start-error — it's diagnostic metadata, not a contract
306
+ * field. A malformed value is dropped rather than thrown.
307
+ */
308
+ function validateStartError(raw: unknown, _where: string): ServiceEntryStartError | undefined {
309
+ if (raw === undefined || raw === null) return undefined;
310
+ if (typeof raw !== "object" || Array.isArray(raw)) return undefined;
311
+ const r = raw as Record<string, unknown>;
312
+ if (typeof r.error_type !== "string" || typeof r.error_description !== "string") {
313
+ return undefined;
314
+ }
315
+ const out: ServiceEntryStartError = {
316
+ error_type: r.error_type,
317
+ error_description: r.error_description,
318
+ };
319
+ if (typeof r.binary === "string") out.binary = r.binary;
320
+ if (typeof r.why === "string" || r.why === null) out.why = r.why as string | null;
321
+ if (typeof r.docs_url === "string" || r.docs_url === null) {
322
+ out.docs_url = r.docs_url as string | null;
323
+ }
324
+ if (r.install && typeof r.install === "object" && !Array.isArray(r.install)) {
325
+ const ins = r.install as Record<string, unknown>;
326
+ const install: { darwin?: string; linux?: string; generic?: string } = {};
327
+ if (typeof ins.darwin === "string") install.darwin = ins.darwin;
328
+ if (typeof ins.linux === "string") install.linux = ins.linux;
329
+ if (typeof ins.generic === "string") install.generic = ins.generic;
330
+ out.install = install;
331
+ }
332
+ if (typeof r.sysadmin_hint === "string") out.sysadmin_hint = r.sysadmin_hint;
333
+ if (typeof r.at === "string") out.at = r.at;
334
+ return out;
335
+ }
336
+
264
337
  /**
265
338
  * Validate the optional `uis` map on a ServiceEntry. `undefined` round-trips
266
339
  * unchanged (the field is optional); a present map must be a plain object
@@ -763,3 +836,42 @@ export function findService(
763
836
  // missed.
764
837
  return readManifestLenient(path).services.find((s) => s.name === name);
765
838
  }
839
+
840
+ /**
841
+ * Persist a `lastStartError` onto the named row so a later `parachute status`
842
+ * + the admin SPA surface it. No-op if the row isn't present (e.g. a service
843
+ * that failed to start before its first self-registration wrote a row — the
844
+ * inline `parachute start` message already told the operator). Lenient read
845
+ * so a malformed sibling row doesn't block recording an unrelated failure.
846
+ */
847
+ export function recordStartError(
848
+ name: string,
849
+ err: ServiceEntryStartError,
850
+ path: string = SERVICES_MANIFEST_PATH,
851
+ ): void {
852
+ if (!existsSync(path)) return;
853
+ const current = readManifestLenient(path);
854
+ const idx = current.services.findIndex((s) => s.name === name);
855
+ if (idx < 0) return;
856
+ const row = current.services[idx];
857
+ if (!row) return;
858
+ current.services[idx] = {
859
+ ...row,
860
+ lastStartError: { ...err, at: err.at ?? new Date().toISOString() },
861
+ };
862
+ writeManifest(current, path);
863
+ }
864
+
865
+ /** Clear a row's persisted `lastStartError` (called on a successful start).
866
+ * No-op when the row is absent or already clean. */
867
+ export function clearStartError(name: string, path: string = SERVICES_MANIFEST_PATH): void {
868
+ if (!existsSync(path)) return;
869
+ const current = readManifestLenient(path);
870
+ const idx = current.services.findIndex((s) => s.name === name);
871
+ if (idx < 0) return;
872
+ const row = current.services[idx];
873
+ if (!row || row.lastStartError === undefined) return;
874
+ const { lastStartError: _drop, ...rest } = row;
875
+ current.services[idx] = rest;
876
+ writeManifest(current, path);
877
+ }
@@ -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), the first call auto-
215
- * seeds `setup_expose_mode = "public"` so the wizard skips the expose
216
- * step. Subsequent calls find the setting present and are read-only.
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
- const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
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">Mint an operator token at
1236
- <a href="/admin/tokens"><code>/admin/tokens</code></a> and append
1237
- <code>--header "Authorization: Bearer pvt_..."</code> on first use.</p>
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 &lt;token&gt;"</code>.</p>
1238
1308
  </div>`;
1239
1309
  }
1240
1310
 
@@ -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
+ }