@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
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * Run `parachute --help` or `parachute <subcommand> --help` for usage.
7
7
  */
8
8
 
9
+ import { MissingDependencyError } from "@openparachute/depcheck";
9
10
  import pkg from "../package.json" with { type: "json" };
10
11
  import { CloudflaredStateError } from "./cloudflare/state.ts";
11
12
  import { auth } from "./commands/auth.ts";
@@ -472,12 +473,21 @@ async function main(argv: string[]): Promise<number> {
472
473
  return 1;
473
474
  }
474
475
  const exposeArgs = flagExtract.rest;
475
- const layer = exposeArgs[0];
476
+ let layer = exposeArgs[0];
476
477
  const mode = exposeArgs[1];
477
478
  if (isHelpFlag(layer)) {
478
479
  console.log(exposeHelp());
479
480
  return 0;
480
481
  }
482
+ // Alias: `parachute expose cloudflare [--domain X] [off]` is shorthand for
483
+ // `parachute expose public --cloudflare …`. Cloudflare is a public-internet
484
+ // provider, so we rewrite the layer to `public` and force the cloudflare
485
+ // flag — the rest of the dispatch (domain prompt, off-path, etc.) is
486
+ // identical to the canonical form.
487
+ if (layer === "cloudflare") {
488
+ layer = "public";
489
+ flagExtract.cloudflare = true;
490
+ }
481
491
  if (layer !== "tailnet" && layer !== "public") {
482
492
  console.error(`parachute expose: unknown layer "${layer ?? ""}"`);
483
493
  console.error("usage: parachute expose tailnet [off]");
@@ -749,26 +759,13 @@ async function main(argv: string[]): Promise<number> {
749
759
  // after `vault` (including --help) is passed through verbatim.
750
760
  if (rest.length === 0) return await dispatchVault(["--help"]);
751
761
 
752
- // `parachute vault tokens create` in a TTY with no scope-narrowing flag
753
- // guided flow. Any of --scope / --read / --permission means the user
754
- // has already decided, so we stay out of the way. Non-TTY always
755
- // bypasses (no way to answer a prompt). Label is orthogonal the
756
- // guided flow prompts for it only if --label wasn't supplied.
757
- const wantsGuidedTokenCreate =
758
- rest[0] === "tokens" &&
759
- rest[1] === "create" &&
760
- isTtyInteractive() &&
761
- !rest.includes("--scope") &&
762
- !rest.includes("--read") &&
763
- !rest.includes("--permission") &&
764
- !isHelpFlag(rest[2]);
765
- if (wantsGuidedTokenCreate) {
766
- const { runVaultTokensCreateInteractive } = await import(
767
- "./commands/vault-tokens-create-interactive.ts"
768
- );
769
- return await runVaultTokensCreateInteractive({ args: rest.slice(2) });
770
- }
771
-
762
+ // Everything under `vault` forwards transparently to `parachute-vault`.
763
+ // `vault tokens create` used to route through a guided interactive
764
+ // wrapper, but the pvt_* DROP (vault#412 / hub#466) removed that vault
765
+ // subcommand it now exits 1 with migration guidance. Access tokens are
766
+ // hub-issued JWTs; mint them with `parachute auth mint-token` or the
767
+ // admin SPA Connect card. We forward verbatim so the operator sees
768
+ // vault's own migration error rather than a hub-side stub.
772
769
  return await dispatchVault(rest);
773
770
  }
774
771
 
@@ -788,6 +785,14 @@ async function run(argv: string[]): Promise<number> {
788
785
  try {
789
786
  return await main(argv);
790
787
  } catch (err) {
788
+ if (err instanceof MissingDependencyError) {
789
+ // A required external binary wasn't on PATH (git / tailscale / tail /
790
+ // …). Print the friendly install block to stderr. interactive:true so
791
+ // the operator at a terminal sees the "ask your sysadmin" trailer; the
792
+ // message was already formatted at construction, so we just emit it.
793
+ console.error(err.message);
794
+ return 1;
795
+ }
791
796
  if (err instanceof ServicesManifestError) {
792
797
  console.error(`services.json is malformed: ${err.message}`);
793
798
  console.error("Fix or remove the file, then re-run.");
package/src/clients.ts CHANGED
@@ -12,12 +12,24 @@
12
12
  * plaintext exactly once. The token endpoint enforces client_secret per
13
13
  * RFC 6749 §3.2.1 (closes #72).
14
14
  *
15
- * Approval gate (closes #74): every row carries a `status` of `pending` or
16
- * `approved`. New self-registrations default to `pending`; only registrations
17
- * that authenticate with an operator token bearing `hub:admin` (the install-
18
- * time path for first-party modules) land as `approved`. The OAuth flow
19
- * rejects `pending` clients at `/oauth/authorize` and `/oauth/token`. An
20
- * operator promotes a pending client via `parachute auth approve-client`.
15
+ * Status column (`pending` | `approved`): every row carries one. New
16
+ * self-registrations default to `pending`; registrations that authenticate
17
+ * with an operator token bearing `hub:admin` (the install-time path for
18
+ * first-party modules) land as `approved`.
19
+ *
20
+ * Single-consent change (2026-05-29): the separate operator "approve this
21
+ * client" gate was retired. The user's OAuth consent IS the authorization —
22
+ * `handleAuthorizeGet` now session-gates a `pending` client: a request
23
+ * carrying a valid session auto-approves the client (status → `approved`,
24
+ * audit-logged) and FALLS THROUGH to the normal consent screen; a session-
25
+ * less request still renders the unauth "App not yet approved" page whose
26
+ * sign-in CTA round-trips back to authorize (after login the user re-enters
27
+ * with a session → auto-approve → consent). The `status` column, the DCR
28
+ * `pending` default, the `/oauth/token` pending rejection, and the
29
+ * `parachute auth approve-client` / SPA approve surfaces all persist but are
30
+ * near-vestigial — kept for defense-in-depth and back-compat. Motivation:
31
+ * Notes/Claude DCR a fresh `client_id` per instance, so a per-client_id
32
+ * approval gate re-prompted the operator constantly.
21
33
  */
22
34
  import type { Database } from "bun:sqlite";
23
35
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -2,8 +2,6 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "../config.ts";
4
4
 
5
- export const CLOUDFLARED_DIR = join(CONFIG_DIR, "cloudflared");
6
-
7
5
  export const DEFAULT_TUNNEL_NAME = "parachute";
8
6
 
9
7
  /**
@@ -16,12 +14,20 @@ export const DEFAULT_TUNNEL_NAME = "parachute";
16
14
  * location change from pre-#32 (`~/.parachute/cloudflared/config.yml`).
17
15
  * Re-running `parachute expose public --cloudflare` regenerates the file
18
16
  * at the new path; the legacy file is left in place but unused.
17
+ *
18
+ * `configDir` overrides the base (`~/.parachute` by default). Tests pass a
19
+ * tmp dir so per-tunnel-derived paths never resolve against the operator's
20
+ * real `CONFIG_DIR` — otherwise running the suite scribbles fixture
21
+ * config.yml + log files into `~/.parachute/cloudflared/<name>/`.
19
22
  */
20
- export function cloudflaredPathsFor(tunnelName: string): {
23
+ export function cloudflaredPathsFor(
24
+ tunnelName: string,
25
+ configDir: string = CONFIG_DIR,
26
+ ): {
21
27
  configPath: string;
22
28
  logPath: string;
23
29
  } {
24
- const dir = join(CLOUDFLARED_DIR, tunnelName);
30
+ const dir = join(configDir, "cloudflared", tunnelName);
25
31
  return {
26
32
  configPath: join(dir, "config.yml"),
27
33
  logPath: join(dir, "cloudflared.log"),
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { isBinaryNotFoundError, lookupDep } from "@openparachute/depcheck";
4
5
  import type { Runner } from "../tailscale/run.ts";
5
6
 
6
7
  export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
@@ -10,30 +11,22 @@ export const DEFAULT_CLOUDFLARED_HOME = join(homedir(), ".cloudflared");
10
11
  * "binary not on PATH" errors — anything else (EACCES from a non-executable
11
12
  * file, corrupted binary, etc.) propagates so we don't silently report
12
13
  * "not installed" when something more specific is wrong.
14
+ *
15
+ * The not-found matcher is `@openparachute/depcheck`'s `isBinaryNotFoundError`
16
+ * — the single source of truth across the ecosystem (this used to be a local
17
+ * copy that drifted from vault's `git-preflight.ts`). Pass the binary name so
18
+ * a not-found message about an unrelated file isn't mis-attributed.
13
19
  */
14
20
  export async function isCloudflaredInstalled(runner: Runner): Promise<boolean> {
15
21
  try {
16
22
  const { code } = await runner(["cloudflared", "--version"]);
17
23
  return code === 0;
18
24
  } catch (err) {
19
- if (isBinaryNotFoundError(err)) return false;
25
+ if (isBinaryNotFoundError(err, "cloudflared")) return false;
20
26
  throw err;
21
27
  }
22
28
  }
23
29
 
24
- function isBinaryNotFoundError(err: unknown): boolean {
25
- if (!err || typeof err !== "object") return false;
26
- const e = err as { code?: unknown; message?: unknown };
27
- if (e.code === "ENOENT") return true;
28
- // Bun.spawn's error shape varies across versions; fall back to message
29
- // string matching so we catch "Executable not found in $PATH" and
30
- // "ENOENT" variants without pinning to one runtime detail.
31
- if (typeof e.message === "string") {
32
- return /ENOENT|not found|No such file/i.test(e.message);
33
- }
34
- return false;
35
- }
36
-
37
30
  /**
38
31
  * `cloudflared tunnel login` drops a cert at `~/.cloudflared/cert.pem` — its
39
32
  * presence is cloudflared's own login marker. Every `cloudflared tunnel
@@ -61,30 +54,37 @@ export function isCloudflaredLoggedIn(cloudflaredHome: string = DEFAULT_CLOUDFLA
61
54
  * other → the binary-download path is still the best generic answer
62
55
  *
63
56
  * The `arch` parameter is the architecture string in `process.arch`
64
- * shape (`x64`, `arm64`, `arm`). Mapped to the suffix cloudflared uses
65
- * in its release artifacts (`amd64`, `arm64`, `arm`). Unknown arches
66
- * fall through to a generic pointer at the releases page.
57
+ * shape (`x64`, `arm64`, `arm`). The static-binary curl recipe + the arch
58
+ * mapping now live in `@openparachute/depcheck`'s `cloudflared` registry
59
+ * entry (`install.linuxBinaryUrl`) the single source of truth shared with
60
+ * the structured `MissingDependencyError` UX. This function keeps its own
61
+ * prose (the surrounding "works across distros" framing the expose flow
62
+ * prints) but derives the URL + arch support from the registry so the two
63
+ * can't drift. A `undefined` recipe (arch with no published artifact) is the
64
+ * signal to fall through to the generic releases pointer rather than
65
+ * fabricating a 404-bound URL.
67
66
  */
68
67
  export function cloudflaredInstallHint(
69
68
  platform: NodeJS.Platform = process.platform,
70
69
  arch: NodeJS.Architecture = process.arch,
71
70
  ): string {
71
+ const releasesUrl = "https://github.com/cloudflare/cloudflared/releases/latest";
72
72
  if (platform === "darwin") {
73
73
  return [
74
74
  "Install cloudflared:",
75
75
  " brew install cloudflared",
76
76
  "",
77
77
  "(or download a static binary from",
78
- " https://github.com/cloudflare/cloudflared/releases/latest)",
78
+ ` ${releasesUrl})`,
79
79
  ].join("\n");
80
80
  }
81
81
  if (platform === "linux") {
82
- const suffix = linuxArtifactSuffix(arch);
83
- if (suffix) {
82
+ const downloadUrl = cloudflaredLinuxDownloadUrl(arch);
83
+ if (downloadUrl) {
84
84
  return [
85
85
  "Install cloudflared (static binary — works across distros):",
86
- ` curl -L -o /usr/local/bin/cloudflared \\`,
87
- ` https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${suffix}`,
86
+ " curl -L -o /usr/local/bin/cloudflared \\",
87
+ ` ${downloadUrl}`,
88
88
  " sudo chmod +x /usr/local/bin/cloudflared",
89
89
  " cloudflared --version",
90
90
  "",
@@ -93,33 +93,28 @@ export function cloudflaredInstallHint(
93
93
  }
94
94
  return [
95
95
  "Install cloudflared from the official binary release:",
96
- " https://github.com/cloudflare/cloudflared/releases/latest",
96
+ ` ${releasesUrl}`,
97
97
  `(pick the linux-* artifact matching your architecture; your arch is "${arch}")`,
98
98
  ].join("\n");
99
99
  }
100
- return [
101
- "Install cloudflared from the official binary release:",
102
- " https://github.com/cloudflare/cloudflared/releases/latest",
103
- ].join("\n");
100
+ return ["Install cloudflared from the official binary release:", ` ${releasesUrl}`].join("\n");
104
101
  }
105
102
 
106
103
  /**
107
- * Map a Node `process.arch` to the suffix Cloudflare uses for its
108
- * cloudflared-linux-* release artifacts. Returns undefined for arches
109
- * that don't have a published artifact (we surface a generic pointer
110
- * in that case instead of fabricating a download URL that 404s).
104
+ * Pull the cloudflared-linux-<suffix> download URL for an arch out of the
105
+ * depcheck registry's static-binary recipe. The registry recipe is a
106
+ * multi-line `curl / chmod / version` block; we extract the single
107
+ * `https://…/cloudflared-linux-<suffix>` line so this function's own prose
108
+ * wraps the canonical URL. Returns undefined when the arch has no published
109
+ * artifact (registry recipe is undefined) — the caller then uses the generic
110
+ * pointer. Keeps the arch→suffix mapping in exactly one place (the registry).
111
111
  */
112
- function linuxArtifactSuffix(arch: NodeJS.Architecture): string | undefined {
113
- switch (arch) {
114
- case "x64":
115
- return "amd64";
116
- case "arm64":
117
- return "arm64";
118
- case "arm":
119
- return "arm";
120
- case "ia32":
121
- return "386";
122
- default:
123
- return undefined;
124
- }
112
+ function cloudflaredLinuxDownloadUrl(arch: NodeJS.Architecture): string | undefined {
113
+ const recipe = lookupDep("cloudflared")?.install.linuxBinaryUrl?.(arch);
114
+ if (!recipe) return undefined;
115
+ const urlLine = recipe
116
+ .split("\n")
117
+ .map((l) => l.trim())
118
+ .find((l) => l.startsWith("https://"));
119
+ return urlLine;
125
120
  }
@@ -13,7 +13,14 @@
13
13
  * - `list-users` — show accounts in `users`.
14
14
  *
15
15
  * Vault-forwarded subcommands (still implemented in `parachute-vault`):
16
- * - `2fa` TOTP enroll/disable/backup-codes.
16
+ * - (none currently). `2fa` used to forward here, but the legacy
17
+ * `parachute-vault 2fa` stub writes vault YAML that does NOT gate hub
18
+ * `/login`. As of hub#473 `parachute auth 2fa` is the REAL hub-login TOTP
19
+ * surface: it reads/writes the hub.db `users` TOTP columns and gates
20
+ * `/login`. Subcommands: `status`, `enroll` (CLI text enroll — prints the
21
+ * otpauth:// URI + base32 secret for manual authenticator entry, then
22
+ * prompts for the confirm code), `disenroll`. The browser path lives at
23
+ * `<hub-origin>/account/2fa` (QR + backup codes).
17
24
  */
18
25
 
19
26
  import { join } from "node:path";
@@ -44,6 +51,13 @@ import {
44
51
  } from "../operator-token.ts";
45
52
  import { isNonRequestableScope } from "../scope-explanations.ts";
46
53
  import { rotateSigningKey } from "../signing-keys.ts";
54
+ import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "../totp.ts";
55
+ import {
56
+ clearEnrollment,
57
+ getTotpState,
58
+ isTotpEnrolled,
59
+ persistEnrollment,
60
+ } from "../two-factor-store.ts";
47
61
  import {
48
62
  SingleUserModeError,
49
63
  UsernameTakenError,
@@ -71,9 +85,10 @@ export const defaultRunner: Runner = {
71
85
  },
72
86
  };
73
87
 
74
- const VAULT_FORWARDED_SUBCOMMANDS = new Set(["2fa"]);
88
+ const VAULT_FORWARDED_SUBCOMMANDS = new Set<string>([]);
75
89
  const HUB_LOCAL_SUBCOMMANDS = new Set([
76
90
  "rotate-key",
91
+ "2fa",
77
92
  "set-password",
78
93
  "list-users",
79
94
  "rotate-operator",
@@ -92,10 +107,14 @@ Usage:
92
107
  parachute auth set-password [--username <name>] [--password <pw>] [--allow-multi]
93
108
  Create or update the hub user's password
94
109
  parachute auth list-users Show registered hub accounts
95
- parachute auth 2fa status Show 2FA state
96
- parachute auth 2fa enroll Enable TOTP 2FA (QR + backup codes)
97
- parachute auth 2fa disable Disable 2FA (requires password)
98
- parachute auth 2fa backup-codes Regenerate backup codes
110
+ parachute auth 2fa [status] Show hub-login 2FA (TOTP) status
111
+ parachute auth 2fa enroll [--username <name>]
112
+ Enroll TOTP for hub login (prints the
113
+ otpauth:// URI + base32 secret for manual
114
+ authenticator entry, then prompts for a
115
+ confirm code; prints backup codes once)
116
+ parachute auth 2fa disenroll [--username <name>]
117
+ Turn off hub-login 2FA for the account
99
118
  parachute auth rotate-key Rotate the hub's JWT signing key
100
119
  parachute auth rotate-operator [--scope-set <set>]
101
120
  Mint a fresh ~/.parachute/operator.token
@@ -128,10 +147,20 @@ The default username on first run is "owner" — override with --username.
128
147
  Single-user mode is the default; pass --allow-multi to add additional
129
148
  accounts beyond the first.
130
149
 
131
- 2fa forwards to \`parachute-vault\` which still implements TOTP storage. If
132
- you see "not found on PATH", install vault first:
150
+ 2fa is real hub-login TOTP (hub#473). It reads/writes the hub.db \`users\` TOTP
151
+ columns and gates \`/login\`: once enrolled, signing in requires a 6-digit code
152
+ from your authenticator app (or a single-use backup code) after your password.
153
+
154
+ \`parachute auth 2fa enroll\` is headless-friendly — it prints the \`otpauth://\`
155
+ URI and the base32 secret as text so you can type them into an authenticator
156
+ manually (no QR scan needed on a server), then prompts for the current 6-digit
157
+ code to confirm. On success it prints 10 single-use backup codes ONCE — save
158
+ them. The browser path (\`<hub-origin>/account/2fa\`) shows a scannable QR code.
133
159
 
134
- parachute install vault
160
+ \`parachute auth 2fa disenroll\` clears the TOTP secret + backup codes.
161
+
162
+ In single-user mode both default to the only hub account; pass --username to
163
+ target a specific account when more than one exists.
135
164
 
136
165
  rotate-key generates a fresh RSA-2048 keypair and retires the previous
137
166
  one. The retired key keeps appearing in /.well-known/jwks.json for 24
@@ -1124,6 +1153,115 @@ async function runRevokeToken(args: readonly string[], deps: AuthDeps): Promise<
1124
1153
  }
1125
1154
  }
1126
1155
 
1156
+ /**
1157
+ * Real hub-login TOTP 2FA CLI (hub#473). `parachute auth 2fa [status|enroll|
1158
+ * disenroll]`. Reads/writes the hub.db `users` TOTP columns — the same store
1159
+ * the `/login` flow and `/account/2fa` browser surface use, so a CLI enroll
1160
+ * gates the exposed hub login exactly like the browser one.
1161
+ *
1162
+ * Headless-first by design: `enroll` prints the `otpauth://` URI + base32
1163
+ * secret as text so the operator can type them into an authenticator app
1164
+ * manually (no QR scan on a server), then prompts for the current code to
1165
+ * confirm before persisting. Browser enroll (QR) lives at
1166
+ * `<hub-origin>/account/2fa`.
1167
+ */
1168
+ async function run2fa(args: readonly string[], deps: AuthDeps): Promise<number> {
1169
+ const flag = extractUsernameFlag(args);
1170
+ if (flag.error) {
1171
+ console.error(`parachute auth 2fa: ${flag.error}`);
1172
+ return 1;
1173
+ }
1174
+ const sub = flag.rest[0] ?? "status";
1175
+ if (flag.rest.length > 1) {
1176
+ console.error(`parachute auth 2fa: unexpected argument "${flag.rest[1]}"`);
1177
+ console.error("usage: parachute auth 2fa [status|enroll|disenroll] [--username <name>]");
1178
+ return 1;
1179
+ }
1180
+ if (sub !== "status" && sub !== "enroll" && sub !== "disenroll") {
1181
+ console.error(`parachute auth 2fa: unknown subcommand "${sub}"`);
1182
+ console.error("usage: parachute auth 2fa [status|enroll|disenroll] [--username <name>]");
1183
+ return 1;
1184
+ }
1185
+
1186
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
1187
+ try {
1188
+ const target = resolveTargetUser(db, flag.username, sub);
1189
+ if ("error" in target) {
1190
+ console.error(`parachute auth 2fa: ${target.error}`);
1191
+ return 1;
1192
+ }
1193
+
1194
+ if (sub === "status") {
1195
+ const state = getTotpState(db, target.id);
1196
+ if (state.secret) {
1197
+ console.log(`Two-factor authentication: ON for "${target.username}".`);
1198
+ if (state.enrolledAt) console.log(` enrolled_at: ${state.enrolledAt}`);
1199
+ console.log(` backup_codes: ${state.backupCodes.length} remaining`);
1200
+ } else {
1201
+ console.log(`Two-factor authentication: OFF for "${target.username}".`);
1202
+ console.log("Run `parachute auth 2fa enroll` to turn it on.");
1203
+ }
1204
+ return 0;
1205
+ }
1206
+
1207
+ if (sub === "disenroll") {
1208
+ if (!isTotpEnrolled(db, target.id)) {
1209
+ console.log(`Two-factor authentication is already off for "${target.username}".`);
1210
+ return 0;
1211
+ }
1212
+ clearEnrollment(db, target.id);
1213
+ console.log(`Turned off two-factor authentication for "${target.username}".`);
1214
+ return 0;
1215
+ }
1216
+
1217
+ // sub === "enroll"
1218
+ if (isTotpEnrolled(db, target.id)) {
1219
+ console.error(
1220
+ `parachute auth 2fa: two-factor is already enabled for "${target.username}". Run \`parachute auth 2fa disenroll\` first to re-enroll.`,
1221
+ );
1222
+ return 1;
1223
+ }
1224
+ const isInteractive = (deps.isInteractive ?? defaultIsInteractive)();
1225
+ if (!isInteractive) {
1226
+ console.error(
1227
+ "parachute auth 2fa enroll: a TTY is required to confirm the enrollment code (run it interactively)",
1228
+ );
1229
+ return 1;
1230
+ }
1231
+ const readLine = deps.readLine ?? defaultReadLine;
1232
+
1233
+ const { secret } = generateTotpSecret(target.username);
1234
+ const otpauthUrl = otpauthUrlFor(secret, target.username);
1235
+ console.log(`Enrolling two-factor authentication for "${target.username}".`);
1236
+ console.log("");
1237
+ console.log("Add this account to your authenticator app. Either scan the otpauth URL");
1238
+ console.log("(paste it into a QR generator), or enter the secret key manually:");
1239
+ console.log("");
1240
+ console.log(` otpauth URL: ${otpauthUrl}`);
1241
+ console.log(` secret key: ${secret}`);
1242
+ console.log("");
1243
+ const code = (await readLine("Enter the 6-digit code from your app to confirm: ")).trim();
1244
+ if (!verifyTotpCode(secret, code)) {
1245
+ console.error(
1246
+ "parachute auth 2fa enroll: that code didn't match — nothing was saved. Check your device clock and try again.",
1247
+ );
1248
+ return 1;
1249
+ }
1250
+ const result = await persistEnrollment(db, target.id, secret);
1251
+ console.log("");
1252
+ console.log("✓ Two-factor authentication is now ON for hub login.");
1253
+ console.log("");
1254
+ console.log("Save these backup codes — each works once if you lose your authenticator:");
1255
+ console.log("");
1256
+ for (const c of result.backupCodes) console.log(` ${c}`);
1257
+ console.log("");
1258
+ console.log("They are shown only once. Store them somewhere safe.");
1259
+ return 0;
1260
+ } finally {
1261
+ db.close();
1262
+ }
1263
+ }
1264
+
1127
1265
  function runListUsers(deps: AuthDeps): number {
1128
1266
  const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
1129
1267
  try {
@@ -1182,6 +1320,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
1182
1320
  return 1;
1183
1321
  }
1184
1322
  }
1323
+ if (sub === "2fa") {
1324
+ try {
1325
+ return await run2fa(args.slice(1), normalized);
1326
+ } catch (err) {
1327
+ const msg = err instanceof Error ? err.message : String(err);
1328
+ console.error(`parachute auth 2fa: ${msg}`);
1329
+ return 1;
1330
+ }
1331
+ }
1185
1332
  if (sub === "list-users") {
1186
1333
  return runListUsers(normalized);
1187
1334
  }
@@ -1250,23 +1397,17 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
1250
1397
  }
1251
1398
  }
1252
1399
 
1253
- if (!VAULT_FORWARDED_SUBCOMMANDS.has(sub)) {
1254
- console.error(`parachute auth: unknown subcommand "${sub}"`);
1255
- console.error("run `parachute auth --help` for usage");
1256
- return 1;
1257
- }
1258
- try {
1400
+ // No subcommands forward to parachute-vault anymore (VAULT_FORWARDED_SUBCOMMANDS
1401
+ // is empty — `2fa` is now hub-local + honest, see #473). Anything that fell
1402
+ // through every hub-local handler above is unknown.
1403
+ if (VAULT_FORWARDED_SUBCOMMANDS.has(sub)) {
1404
+ // Defensive: if a future subcommand is added back to the forward set, route
1405
+ // it. Currently unreachable (the set is empty).
1259
1406
  return await runner.run(["parachute-vault", ...args]);
1260
- } catch (err) {
1261
- const msg = err instanceof Error ? err.message : String(err);
1262
- if (msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found")) {
1263
- console.error("parachute-vault not found on PATH.");
1264
- console.error("Install it with: parachute install vault");
1265
- return 127;
1266
- }
1267
- console.error(`failed to run parachute-vault: ${msg}`);
1268
- return 1;
1269
1407
  }
1408
+ console.error(`parachute auth: unknown subcommand "${sub}"`);
1409
+ console.error("run `parachute auth --help` for usage");
1410
+ return 1;
1270
1411
  }
1271
1412
 
1272
1413
  // Re-exported so `users.ts` consumers can preserve the named-export.
@@ -1,29 +1,23 @@
1
1
  /**
2
- * Public-exposure 2FA-enrollment warning (#186). Lands as the next layer of
3
- * defense after #188's `/login` rate-limit floor: once the operator brings
4
- * up cloudflare or Tailscale Funnel, `/login` is reachable from the public
5
- * internet on every layer admitting traffic. 2FA is the difference between
6
- * "password is the only wall" and "password + something-you-have."
2
+ * Public-exposure security warning (#186). Once the operator brings up
3
+ * cloudflare or Tailscale Funnel, `/login` is reachable from the public
4
+ * internet on every layer admitting traffic. After #188's `/login` rate-limit
5
+ * floor, the owner password is the wall and now (hub#473) hub-login TOTP 2FA
6
+ * is the second wall.
7
+ *
8
+ * 2FA at the hub login layer is real as of hub#473: "password +
9
+ * something-you-have." This warning recommends `parachute auth 2fa enroll`
10
+ * (which now gates hub `/login` for real) when the operator hasn't enrolled.
7
11
  *
8
12
  * Why this is a warning, not a hard gate: hard-gating would surprise operators
9
13
  * mid-flow — they ran `parachute expose public` to expose, not to be told
10
- * "set up 2FA first." A loud, contextual warning + a clear one-line
11
- * remediation is the right shape; the operator decides whether to act now or
12
- * later. The tunnel is up regardless.
13
- *
14
- * Why the source-of-truth is vault's `config.yaml`: 2FA enrollment lives in
15
- * `parachute-vault` (the hub forwards `parachute auth 2fa enroll` to vault —
16
- * see `commands/auth.ts` `VAULT_FORWARDED_SUBCOMMANDS`). The hub's `users`
17
- * table has no TOTP column today; it will gain one when hub-admin login
18
- * verifies TOTP against vault. Until then, "is 2FA enrolled?" maps cleanly
19
- * to "does vault's config.yaml carry a non-empty `totp_secret`?", which is
20
- * exactly what `readVaultAuthStatus().hasTotp` returns.
14
+ * "set up 2FA first." A loud, contextual warning + a clear remediation is the
15
+ * right shape; the operator decides whether to act now or later. The tunnel is
16
+ * up regardless.
21
17
  *
22
- * If vault isn't installed at all (rare for the cloudflare path — it requires
23
- * a vault entry but possible on the tailnet/funnel path): `hasTotp` comes
24
- * back `false` and the warning still fires. The remediation
25
- * `parachute auth 2fa enroll` then surfaces vault's "install vault first"
26
- * error, which is the right next step regardless.
18
+ * The probe consults `readVaultAuthStatus().hasTotp`, which now reflects the
19
+ * hub.db `users.totp_secret` column (real hub-login 2FA) true when any user
20
+ * has enrolled. The warning fires only when no second factor is configured.
27
21
  */
28
22
 
29
23
  import { type VaultAuthStatus, readVaultAuthStatus } from "../vault/auth-status.ts";
@@ -40,17 +34,20 @@ export interface Public2FAWarningOpts {
40
34
  }
41
35
 
42
36
  /**
43
- * `true` when `totp_secret` is present and non-empty in vault's config.yaml,
44
- * `false` otherwise (missing vault, missing config.yaml, empty value).
45
- *
46
- * Source-of-truth note: TOTP storage is the vault's, not the hub's. See the
47
- * module-level doc comment.
37
+ * `true` when a second factor is configured, `false` otherwise. As of hub#473
38
+ * this reflects the hub.db `users.totp_secret` column (real hub-login 2FA),
39
+ * with the legacy vault `config.yaml` `totp_secret` as a fallback for old
40
+ * installs see {@link readVaultAuthStatus} and the module-level doc comment.
48
41
  */
49
42
  export function is2FAEnrolled(
50
- opts: { vaultHome?: string; status?: VaultAuthStatus } = {},
43
+ opts: { vaultHome?: string; hubDbPath?: string; status?: VaultAuthStatus } = {},
51
44
  ): boolean {
52
45
  const status =
53
- opts.status ?? readVaultAuthStatus(opts.vaultHome ? { vaultHome: opts.vaultHome } : {});
46
+ opts.status ??
47
+ readVaultAuthStatus({
48
+ ...(opts.vaultHome ? { vaultHome: opts.vaultHome } : {}),
49
+ ...(opts.hubDbPath ? { hubDbPath: opts.hubDbPath } : {}),
50
+ });
54
51
  return status.hasTotp;
55
52
  }
56
53
 
@@ -71,12 +68,17 @@ export function printPublic2FAWarning(opts: Public2FAWarningOpts): boolean {
71
68
  return false;
72
69
  }
73
70
  log("");
74
- log("⚠ 2FA is not enrolled. /login is now reachable on the public internet");
75
- log(` (${opts.publicUrl}/login). Anyone who guesses your password`);
76
- log(" is in. Strongly recommended:");
71
+ log("⚠ /login is now reachable on the public internet");
72
+ log(` (${opts.publicUrl}/login). Anyone who guesses your password is in.`);
73
+ log("");
74
+ log(" Turn on two-factor authentication — it adds a second wall (a one-time");
75
+ log(" code from your authenticator app) on top of your password:");
77
76
  log("");
78
77
  log(" parachute auth 2fa enroll");
79
78
  log("");
80
- log(" Adds TOTP + backup codes. Takes 30 seconds.");
79
+ log(" (Or set it up in the browser at /account/2fa for a scannable QR code.)");
80
+ log(" Either way, also make sure your owner password is a strong one:");
81
+ log("");
82
+ log(" parachute auth set-password");
81
83
  return true;
82
84
  }