@seekrit/cli 0.25.0 → 0.27.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/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { z } from "zod";
5
5
  import { Command } from "commander";
6
- import { homedir, tmpdir } from "node:os";
6
+ import { homedir, hostname, tmpdir, userInfo } from "node:os";
7
7
  import { dirname, join, parse } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
9
  import { Writable } from "node:stream";
@@ -51,6 +51,12 @@ const ENTITLEMENT_KEYS = Object.keys({
51
51
  description: "Maximum environments under a single application.",
52
52
  default: null
53
53
  },
54
+ "branches.per_app.max": {
55
+ kind: "limit",
56
+ label: "Branch configs per application",
57
+ description: "Maximum ephemeral branch environments under a single application.",
58
+ default: null
59
+ },
54
60
  "secrets.per_env.max": {
55
61
  kind: "limit",
56
62
  label: "Secrets per environment",
@@ -144,6 +150,51 @@ const SUBSCRIPTION_STATUSES = [
144
150
  "paused"
145
151
  ];
146
152
  //#endregion
153
+ //#region ../../packages/core/src/branches.ts
154
+ /**
155
+ * Branch (ephemeral) environments — a per-PR/preview overlay on an existing
156
+ * application environment.
157
+ *
158
+ * A branch is an ordinary environment row with a parent and a TTL. It is an
159
+ * **overlay, not a copy**: resolve returns the parent's layers and then the
160
+ * branch's own on top, so a branch holds only the values that differ and
161
+ * tracks the parent live. Nothing is re-encrypted at creation — a secret's
162
+ * ciphertext is bound to `(environmentId, name)` as AAD, so copying blobs into
163
+ * a new environment could not decrypt anyway, and a snapshot would immediately
164
+ * drift from its base.
165
+ *
166
+ * Two rules keep the read path cheap and predictable, enforced here:
167
+ *
168
+ * - **Depth one.** A branch's parent must not itself be a branch, so resolve
169
+ * never recurses on the hot path.
170
+ * - **Application environments only.** Group environments are pulled in by
171
+ * composition (matched by slug) and have no single parent to overlay.
172
+ */
173
+ /** Longest life a branch may be given. Bounds sprawl even if nobody cleans up. */
174
+ const MAX_BRANCH_TTL_SECONDS = 720 * 60 * 60;
175
+ /**
176
+ * Parse a human TTL — `30m`, `12h`, `7d`, `2w`, or bare seconds — into seconds.
177
+ * Returns null for anything unparseable, so callers can report the input back.
178
+ * `never` / `none` mean "no expiry" and yield `Infinity`, which
179
+ * {@link planBranchCreate} rejects unless passed as an explicit `null`.
180
+ */
181
+ function parseBranchTtl(input) {
182
+ const raw = input.trim().toLowerCase();
183
+ if (raw === "never" || raw === "none") return Number.POSITIVE_INFINITY;
184
+ const match = /^(\d+)\s*(s|m|h|d|w)?$/.exec(raw);
185
+ if (!match) return null;
186
+ const value = Number(match[1]);
187
+ const multiplier = {
188
+ s: 1,
189
+ m: 60,
190
+ h: 3600,
191
+ d: 86400,
192
+ w: 604800
193
+ }[match[2] ?? "s"];
194
+ if (multiplier === void 0) return null;
195
+ return value * multiplier;
196
+ }
197
+ //#endregion
147
198
  //#region ../../packages/core/src/interpolate.ts
148
199
  /**
149
200
  * Secret references: `${OTHER_SECRET}` inside a secret value.
@@ -864,11 +915,22 @@ z.object({
864
915
  */
865
916
  encryptedPrivateKey: z.string().min(1)
866
917
  });
867
- z.object({
918
+ const grantEnvironmentKeySchema = z.object({
868
919
  principalType: principalTypeSchema,
869
920
  principalId: z.string().min(1),
870
921
  wrappedDek: z.string().min(1)
871
922
  });
923
+ z.object({
924
+ slug: slugSchema,
925
+ /** Display name; defaults to the slug. */
926
+ name: nameSchema.optional(),
927
+ ttlSeconds: z.number().int().min(60).max(MAX_BRANCH_TTL_SECONDS).nullish(),
928
+ /** The branch's own DEK, wrapped to the creator — generated client-side. */
929
+ wrappedDek: z.string().min(1),
930
+ recoveryWrappedDek: z.string().min(1).nullish(),
931
+ /** The same DEK wrapped to each of the parent's existing grant-holders. */
932
+ grants: z.array(grantEnvironmentKeySchema).max(500).default([])
933
+ });
872
934
  z.object({
873
935
  name: nameSchema,
874
936
  tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
@@ -1002,6 +1064,16 @@ z.object({
1002
1064
  expiresAt: z.iso.datetime().nullish()
1003
1065
  });
1004
1066
  z.object({ family: planFamilySchema });
1067
+ z.object({
1068
+ sessionId: z.string().regex(/^skc_[0-9A-Za-z]+$/),
1069
+ /** SHA-256 hash (base64url) of the full session token string. */
1070
+ tokenHash: z.string().min(1).max(128),
1071
+ /** Display-only, e.g. `miles@studio.local`. */
1072
+ deviceLabel: z.string().trim().min(1).max(120),
1073
+ /** Display-only, e.g. `cli/0.4.2`. */
1074
+ client: z.string().trim().max(60).optional()
1075
+ });
1076
+ z.object({ code: z.string().trim().min(1).max(32) });
1005
1077
  z.object({
1006
1078
  cursor: z.string().optional(),
1007
1079
  limit: z.coerce.number().int().min(1).max(200).default(50),
@@ -2077,9 +2149,12 @@ function encodeOpensshPrivateKey(seed, pub, comment) {
2077
2149
  * Format: `skt_<token id>_<private key pkcs8, base64url>`
2078
2150
  */
2079
2151
  const TOKEN_PREFIX = "skt";
2152
+ const CLI_SESSION_PREFIX = "skc";
2080
2153
  const TOKEN_ID_LENGTH = 22;
2081
2154
  const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2082
- function randomTokenId() {
2155
+ /** 32 bytes of entropy for the CLI session secret. */
2156
+ const CLI_SESSION_SECRET_BYTES = 32;
2157
+ function randomTokenId(prefix = TOKEN_PREFIX) {
2083
2158
  let out = "";
2084
2159
  while (out.length < TOKEN_ID_LENGTH) {
2085
2160
  const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
@@ -2088,7 +2163,7 @@ function randomTokenId() {
2088
2163
  if (out.length === TOKEN_ID_LENGTH) break;
2089
2164
  }
2090
2165
  }
2091
- return `${TOKEN_PREFIX}_${out}`;
2166
+ return `${prefix}_${out}`;
2092
2167
  }
2093
2168
  async function hashToken(token) {
2094
2169
  const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
@@ -2122,9 +2197,27 @@ async function parseServiceToken(token) {
2122
2197
  function isServiceToken(value) {
2123
2198
  return value.startsWith(`${TOKEN_PREFIX}_`);
2124
2199
  }
2200
+ async function createCliSessionToken() {
2201
+ const sessionId = randomTokenId(CLI_SESSION_PREFIX);
2202
+ const token = `${sessionId}_${toBase64Url(crypto.getRandomValues(new Uint8Array(CLI_SESSION_SECRET_BYTES)))}`;
2203
+ return {
2204
+ token,
2205
+ sessionId,
2206
+ tokenHash: await hashToken(token)
2207
+ };
2208
+ }
2209
+ /** The public `skc_…` id embedded in a CLI session token. */
2210
+ function parseCliSessionToken(token) {
2211
+ const match = /^(skc_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(token);
2212
+ if (!match) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit CLI session token");
2213
+ return { sessionId: match[1] };
2214
+ }
2215
+ function isCliSessionToken(value) {
2216
+ return value.startsWith(`${CLI_SESSION_PREFIX}_`);
2217
+ }
2125
2218
  //#endregion
2126
2219
  //#region package.json
2127
- var version = "0.25.0";
2220
+ var version = "0.27.0";
2128
2221
  //#endregion
2129
2222
  //#region ../../packages/api-client/src/index.ts
2130
2223
  var SeekritApiError = class extends Error {
@@ -2186,6 +2279,32 @@ var SeekritClient = class {
2186
2279
  getMyNotificationPrefs() {
2187
2280
  return this.request("GET", "/v1/me/notifications");
2188
2281
  }
2282
+ /**
2283
+ * Devices this user has authorized. `currentSessionId` is set when the caller
2284
+ * *is* a CLI session, so it can label (or revoke) itself.
2285
+ */
2286
+ listCliSessions() {
2287
+ return this.request("GET", "/v1/me/cli-sessions");
2288
+ }
2289
+ /** Sign a device out. Its token stops authenticating immediately. */
2290
+ revokeCliSession(sessionId) {
2291
+ return this.request("DELETE", `/v1/me/cli-sessions/${sessionId}`);
2292
+ }
2293
+ /** What a pending login request is asking for — for the approval screen. */
2294
+ getCliLoginRequest(code) {
2295
+ return this.request("GET", `/v1/cli-login/${encodeURIComponent(code)}`);
2296
+ }
2297
+ /**
2298
+ * Authorize a device. Requires a browser session; members with a second
2299
+ * factor must have re-entered it just now, else this rejects with
2300
+ * `mfa_required` (recoverable — prompt for a code and retry).
2301
+ */
2302
+ approveCliLogin(code) {
2303
+ return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/approve`);
2304
+ }
2305
+ denyCliLogin(code) {
2306
+ return this.request("POST", `/v1/cli-login/${encodeURIComponent(code)}/deny`);
2307
+ }
2189
2308
  setMyNotificationPrefs(input) {
2190
2309
  return this.request("PUT", "/v1/me/notifications", input);
2191
2310
  }
@@ -2253,6 +2372,28 @@ var SeekritClient = class {
2253
2372
  deleteEnv(orgId, envId) {
2254
2373
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
2255
2374
  }
2375
+ /**
2376
+ * The public keys of an environment's grant-holders, so a client can wrap a
2377
+ * new DEK to each of them (see `createBranch`). No key material is returned.
2378
+ */
2379
+ listGrantees(orgId, envId) {
2380
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/grantees`);
2381
+ }
2382
+ listBranches(orgId, envId) {
2383
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/branches`);
2384
+ }
2385
+ /** Every branch in an application, across all its environments. */
2386
+ listAppBranches(orgId, appId) {
2387
+ return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/branches`);
2388
+ }
2389
+ /** Fork `envId` into an ephemeral branch. `envId` is the parent, not the branch. */
2390
+ createBranch(orgId, envId, input) {
2391
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/branches`, input);
2392
+ }
2393
+ /** Branches are environments, so tearing one down is `deleteEnv`. */
2394
+ deleteBranch(orgId, branchId) {
2395
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${branchId}`);
2396
+ }
2256
2397
  listGroups(orgId) {
2257
2398
  return this.request("GET", `/v1/orgs/${orgId}/groups`);
2258
2399
  }
@@ -2288,6 +2429,7 @@ var SeekritClient = class {
2288
2429
  resolve(query = {}) {
2289
2430
  const params = new URLSearchParams();
2290
2431
  if (query.env) params.set("env", query.env);
2432
+ if (query.branch) params.set("branch", query.branch);
2291
2433
  for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
2292
2434
  const qs = params.size > 0 ? `?${params}` : "";
2293
2435
  return this.request("GET", `/v1/resolve${qs}`);
@@ -2506,6 +2648,39 @@ var SeekritClient = class {
2506
2648
  return this.request("POST", `/v1/orgs/${orgId}/billing/cancel`);
2507
2649
  }
2508
2650
  };
2651
+ async function unauthenticatedPost(baseUrl, path, body, fetchImpl, client) {
2652
+ const headers = {
2653
+ accept: "application/json",
2654
+ "content-type": "application/json"
2655
+ };
2656
+ if (client) headers["x-seekrit-client"] = client;
2657
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, "")}${path}`, {
2658
+ method: "POST",
2659
+ headers,
2660
+ body: JSON.stringify(body)
2661
+ });
2662
+ if (!res.ok) {
2663
+ const fallback = { error: {
2664
+ code: "internal",
2665
+ message: `HTTP ${res.status}`
2666
+ } };
2667
+ const payload = await res.json().catch(() => fallback);
2668
+ throw new SeekritApiError(res.status, payload.error?.code ?? "internal", payload.error?.message ?? `HTTP ${res.status}`);
2669
+ }
2670
+ return await res.json();
2671
+ }
2672
+ /**
2673
+ * Open a browser-approved login request. `input.tokenHash` is the SHA-256 of a
2674
+ * session token the caller minted locally and keeps — never send the token.
2675
+ */
2676
+ function startCliLogin(baseUrl, input, options = {}) {
2677
+ return unauthenticatedPost(baseUrl, "/v1/cli-login", input, options.fetch ?? ((...args) => fetch(...args)), options.client);
2678
+ }
2679
+ /** Ask whether a human has authorized the request yet. */
2680
+ function pollCliLogin(baseUrl, code, options = {}) {
2681
+ const fetchImpl = options.fetch ?? ((...args) => fetch(...args));
2682
+ return unauthenticatedPost(baseUrl, "/v1/cli-login/poll", { code }, fetchImpl, options.client);
2683
+ }
2509
2684
  const PROJECT_FILE = "seekrit.json";
2510
2685
  function globalConfigPath() {
2511
2686
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
@@ -2515,6 +2690,10 @@ function readGlobalConfig() {
2515
2690
  if (!existsSync(path)) return {};
2516
2691
  return JSON.parse(readFileSync(path, "utf8"));
2517
2692
  }
2693
+ /**
2694
+ * Merge into the saved config. A key set to `undefined` is *removed* (JSON
2695
+ * drops it), which is how the login paths clear a credential they replace.
2696
+ */
2518
2697
  function writeGlobalConfig(update) {
2519
2698
  const path = globalConfigPath();
2520
2699
  const merged = {
@@ -2586,6 +2765,29 @@ function promptHidden(question) {
2586
2765
  });
2587
2766
  });
2588
2767
  }
2768
+ /**
2769
+ * Wait for the user to press Enter (or Ctrl-C). Resolves immediately when stdin
2770
+ * isn't a TTY — a piped or CI invocation has nobody to press a key, and blocking
2771
+ * there would hang `seekrit login` forever.
2772
+ */
2773
+ function promptEnter(question) {
2774
+ if (!process.stdin.isTTY) {
2775
+ process.stderr.write("\n");
2776
+ return Promise.resolve();
2777
+ }
2778
+ process.stderr.write(question);
2779
+ const rl = createInterface({
2780
+ input: process.stdin,
2781
+ output: process.stderr,
2782
+ terminal: true
2783
+ });
2784
+ return new Promise((resolve) => {
2785
+ rl.question("", () => {
2786
+ rl.close();
2787
+ resolve();
2788
+ });
2789
+ });
2790
+ }
2589
2791
  /** Read all of stdin (for `seekrit secrets set NAME -` piping). */
2590
2792
  async function readStdin() {
2591
2793
  const chunks = [];
@@ -2611,7 +2813,7 @@ function tryBuildContext(dotenvVars = {}) {
2611
2813
  const config = readGlobalConfig();
2612
2814
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
2613
2815
  const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
2614
- const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
2816
+ const token = fromEnv("SEEKRIT_TOKEN") ?? config.token ?? config.sessionToken;
2615
2817
  const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
2616
2818
  let auth;
2617
2819
  if (token) auth = {
@@ -2634,7 +2836,7 @@ function tryBuildContext(dotenvVars = {}) {
2634
2836
  }
2635
2837
  function buildContext() {
2636
2838
  const ctx = tryBuildContext();
2637
- if (!ctx) fail("no credentials found — run `seekrit login --token skt_…` (or `--client-id … --client-secret …`), or set SEEKRIT_TOKEN / SEEKRIT_CLIENT_ID + SEEKRIT_CLIENT_SECRET / SEEKRIT_DEV_USER");
2839
+ if (!ctx) fail("no credentials found — run `seekrit login` to sign in through your browser (or `seekrit login --token skt_…` / `--client-id … --client-secret …`), or set SEEKRIT_TOKEN / SEEKRIT_CLIENT_ID + SEEKRIT_CLIENT_SECRET / SEEKRIT_DEV_USER");
2638
2840
  return ctx;
2639
2841
  }
2640
2842
  function isTokenAuth(ctx) {
@@ -2681,12 +2883,14 @@ async function resolveOrg(ctx, orgSlug) {
2681
2883
  }
2682
2884
  /**
2683
2885
  * Resolve an environment to operate on — an application env (`--app --env`,
2684
- * or the config's app + `--env`) or a group env (`--group --env`).
2886
+ * or the config's app + `--env`), a branch of one (`--branch`), or a group env
2887
+ * (`--group --env`).
2685
2888
  */
2686
2889
  async function resolveEnvTarget(ctx, opts) {
2687
2890
  const org = await resolveOrg(ctx, opts.org);
2688
2891
  if (!opts.env) fail("specify --env");
2689
2892
  if (opts.group) {
2893
+ if (opts.branch) fail("--branch applies to application environments, not groups");
2690
2894
  const { groups } = await ctx.client.listGroups(org.id);
2691
2895
  const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
2692
2896
  if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
@@ -2699,34 +2903,59 @@ async function resolveEnvTarget(ctx, opts) {
2699
2903
  label: `${group.slug}@${env.slug}`
2700
2904
  };
2701
2905
  }
2702
- const appSlug = opts.app ?? findProjectConfig()?.app;
2703
- if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
2704
- const { apps } = await ctx.client.listApps(org.id);
2705
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
2706
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
2906
+ const app = await resolveApp(ctx, opts);
2707
2907
  const { environments } = await ctx.client.listEnvs(org.id, app.id);
2708
2908
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
2709
2909
  if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
2910
+ if (opts.branch) {
2911
+ const branch = await resolveBranch(ctx, app, opts.branch);
2912
+ return {
2913
+ orgId: org.id,
2914
+ envId: branch.id,
2915
+ label: `${app.slug}/${env.slug}#${branch.slug}`
2916
+ };
2917
+ }
2710
2918
  return {
2711
2919
  orgId: org.id,
2712
2920
  envId: env.id,
2713
2921
  label: `${app.slug}/${env.slug}`
2714
2922
  };
2715
2923
  }
2716
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
2717
- async function resolveAppEnv(ctx, opts) {
2924
+ /** Resolve the target application from a flag or the committed config. */
2925
+ async function resolveApp(ctx, opts) {
2718
2926
  const org = await resolveOrg(ctx, opts.org);
2719
2927
  const appSlug = opts.app ?? findProjectConfig()?.app;
2720
2928
  if (!appSlug) fail("specify --app (or run `seekrit init`)");
2721
- if (!opts.env) fail("specify --env");
2722
2929
  const { apps } = await ctx.client.listApps(org.id);
2723
2930
  const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
2724
2931
  if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
2725
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
2932
+ return {
2933
+ orgId: org.id,
2934
+ orgSlug: org.slug,
2935
+ id: app.id,
2936
+ slug: app.slug
2937
+ };
2938
+ }
2939
+ /**
2940
+ * Find a branch by slug (or id) anywhere in an application. Branch slugs share
2941
+ * the application's environment namespace, so one lookup is unambiguous — no
2942
+ * need to name the parent environment.
2943
+ */
2944
+ async function resolveBranch(ctx, app, branchRef) {
2945
+ const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
2946
+ const branch = branches.find((b) => b.slug === branchRef || b.id === branchRef);
2947
+ if (!branch) fail(`no branch "${branchRef}" in ${app.slug}`);
2948
+ return branch;
2949
+ }
2950
+ /** Resolve an application environment, keeping ids + slugs (for token binding). */
2951
+ async function resolveAppEnv(ctx, opts) {
2952
+ if (!opts.env) fail("specify --env");
2953
+ const app = await resolveApp(ctx, opts);
2954
+ const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
2726
2955
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
2727
2956
  if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
2728
2957
  return {
2729
- orgId: org.id,
2958
+ orgId: app.orgId,
2730
2959
  appId: app.id,
2731
2960
  appSlug: app.slug,
2732
2961
  envId: env.id,
@@ -2888,271 +3117,79 @@ function registerAwsCommands(program) {
2888
3117
  });
2889
3118
  }
2890
3119
  //#endregion
2891
- //#region src/dotenv.ts
2892
- /**
2893
- * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
2894
- * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
2895
- * escapes; unquoted values drop trailing ` # comments`). Multiline values are
2896
- * not supported — keep those in seekrit itself.
2897
- */
2898
- function parseDotenv(content) {
2899
- const out = {};
2900
- for (const raw of content.split(/\r?\n/)) {
2901
- let line = raw.trim();
2902
- if (!line || line.startsWith("#")) continue;
2903
- if (line.startsWith("export ")) line = line.slice(7).trimStart();
2904
- const eq = line.indexOf("=");
2905
- if (eq === -1) continue;
2906
- const key = line.slice(0, eq).trim();
2907
- if (!key) continue;
2908
- let value = line.slice(eq + 1).trim();
2909
- const quote = value[0];
2910
- if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
2911
- value = value.slice(1, -1);
2912
- if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2913
- } else {
2914
- const comment = value.indexOf(" #");
2915
- if (comment !== -1) value = value.slice(0, comment).trim();
2916
- }
2917
- out[key] = value;
2918
- }
2919
- return out;
2920
- }
2921
- //#endregion
2922
- //#region src/format.ts
2923
- function needsQuoting(value) {
2924
- return /[\s"'`$\\#]/.test(value) || value === "";
2925
- }
2926
- function dotenvQuote(value) {
2927
- if (!needsQuoting(value)) return value;
2928
- return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
2929
- }
2930
- function shellQuote(value) {
2931
- return `'${value.replaceAll("'", `'\\''`)}'`;
3120
+ //#region src/kms.ts
3121
+ /** Collect a repeatable option into a list. */
3122
+ function collect$6(value, acc = []) {
3123
+ acc.push(value);
3124
+ return acc;
2932
3125
  }
2933
- function formatSecrets(values, format) {
2934
- const names = Object.keys(values).sort();
2935
- switch (format) {
2936
- case "json": return JSON.stringify(values, names, 2);
2937
- case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
2938
- case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
3126
+ /** The calling principal's identity + public key (for a self-grant). */
3127
+ async function kmsCallerIdentity(ctx) {
3128
+ if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
3129
+ const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
3130
+ const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
3131
+ return {
3132
+ principalType: "service_token",
3133
+ principalId: tokenId,
3134
+ publicKeyJwk: JSON.stringify(pub)
3135
+ };
2939
3136
  }
3137
+ const { user } = await ctx.client.me();
3138
+ if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
3139
+ return {
3140
+ principalType: "user",
3141
+ principalId: user.id,
3142
+ publicKeyJwk: user.publicKeyJwk
3143
+ };
2940
3144
  }
2941
- //#endregion
2942
- //#region src/gcp.ts
2943
- /**
2944
- * `seekrit gcp` temporary GCP credentials via IAM Credentials
2945
- * `generateAccessToken` (Vault-style dynamic secrets, the tier-2 sibling of
2946
- * `seekrit aws`).
2947
- *
2948
- * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
2949
- * keypair on THIS machine and sends only the public key; GCP mints the token and
2950
- * the broker returns it wrapped to that key, so the control plane only ever
2951
- * relays ciphertext and only this machine can unwrap it. Registering a target
2952
- * wraps the service-account key JSON to the broker's public key locally, so the
2953
- * control plane never sees it either — the source service account needs only
2954
- * `roles/iam.serviceAccountTokenCreator` on the target.
2955
- */
2956
- /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
2957
- function parseTtlSeconds$5(input) {
2958
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
2959
- if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
2960
- return Number(m[1]) * ({
2961
- s: 1,
2962
- m: 60,
2963
- h: 3600,
2964
- d: 86400
2965
- }[m[2] || "s"] ?? 1);
3145
+ /** Look up an org member (by email) or service token (by id) as a grant recipient. */
3146
+ async function kmsResolveRecipient(ctx, orgId, who) {
3147
+ if (who.user) {
3148
+ const { members } = await ctx.client.listMembers(orgId);
3149
+ const m = members.find((x) => x.email === who.user);
3150
+ if (!m) fail(`no member ${who.user}`);
3151
+ if (!m.publicKeyJwk) fail(`${who.user} has not completed key setup`);
3152
+ return {
3153
+ principalType: "user",
3154
+ principalId: m.userId,
3155
+ publicKeyJwk: m.publicKeyJwk
3156
+ };
3157
+ }
3158
+ if (who.token) {
3159
+ const { tokens } = await ctx.client.listTokens(orgId);
3160
+ const t = tokens.find((x) => x.id === who.token);
3161
+ if (!t) fail(`no service token ${who.token}`);
3162
+ return {
3163
+ principalType: "service_token",
3164
+ principalId: t.id,
3165
+ publicKeyJwk: t.publicKeyJwk
3166
+ };
3167
+ }
3168
+ fail("specify --user <email> or --token <id>");
2966
3169
  }
2967
- /** Collect a repeatable flag (e.g. --scope) into a list. */
2968
- function collectList$1(value, acc = []) {
2969
- acc.push(value);
2970
- return acc;
3170
+ async function kmsResolveKey(ctx, orgId, ref) {
3171
+ const { keys } = await ctx.client.listKmsKeys(orgId);
3172
+ const key = keys.find((k) => k.id === ref || k.name === ref);
3173
+ if (!key) fail(`no KMS key "${ref}"`);
3174
+ return key;
2971
3175
  }
2972
- /**
2973
- * The service-account key JSON the broker impersonates with. From --key-file or
2974
- * GOOGLE_APPLICATION_CREDENTIALS. Never leaves this machine unwrapped — it is
2975
- * wrapped to the broker key before upload.
2976
- */
2977
- function resolveServiceAccountKey(opts) {
2978
- const path = opts.keyFile ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
2979
- if (!path) fail("provide the source service-account key JSON via --key-file or GOOGLE_APPLICATION_CREDENTIALS (it needs roles/iam.serviceAccountTokenCreator on the target)");
2980
- const raw = readFileSync(path, "utf8").trim();
2981
- try {
2982
- const parsed = JSON.parse(raw);
2983
- if (typeof parsed.client_email !== "string" || typeof parsed.private_key !== "string") fail(`${path} is not a service-account key JSON (missing client_email/private_key)`);
2984
- } catch {
2985
- fail(`${path} is not valid JSON`);
2986
- }
2987
- return raw;
3176
+ /** Recover a key's material for one version (default: current), for the caller. */
3177
+ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
3178
+ const mat = await ctx.client.getMyKmsKey(orgId, keyId);
3179
+ const v = version ?? mat.currentVersion;
3180
+ const grant = mat.grants.find((g) => g.version === v);
3181
+ if (!grant) fail(`no grant for version ${v} of this key`);
3182
+ return {
3183
+ material: await unwrapDek(grant.wrappedKey, await getPrivateKey(ctx)),
3184
+ version: v,
3185
+ currentVersion: mat.currentVersion
3186
+ };
2988
3187
  }
2989
- function registerGcpCommands(program) {
2990
- const gcp = program.command("gcp").description("temporary GCP credentials (IAM generateAccessToken, zero-knowledge)");
2991
- const target = gcp.command("target").description("manage GCP service-account targets");
2992
- target.command("add").description("register an impersonable service account to issue temporary tokens from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--service-account <email>", "the service account to impersonate, name@project.iam.gserviceaccount.com").option("--org <slug>").option("--scope <scope>", "OAuth scope to grant (repeatable; default cloud-platform)", collectList$1).option("--delegate <email>", "delegation-chain service account (repeatable)", collectList$1).option("--max-ttl <duration>", "clamp requested token lifetime, e.g. 1h").option("--key-file <path>", "source SA key JSON (else GOOGLE_APPLICATION_CREDENTIALS)").action(async (options) => {
2993
- const ctx = buildContext();
2994
- const org = await resolveOrg(ctx, options.org);
2995
- const config = {
2996
- provider: "gcp",
2997
- executor: "in_do",
2998
- serviceAccount: options.serviceAccount,
2999
- ...options.scope?.length ? { scopes: options.scope } : {},
3000
- ...options.delegate?.length ? { delegates: options.delegate } : {},
3001
- ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
3002
- };
3003
- const keyJson = resolveServiceAccountKey(options);
3004
- const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
3005
- const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(keyJson), publicKeyJwk);
3006
- const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
3007
- name: options.name,
3008
- config,
3009
- wrappedAdminSecret
3010
- });
3011
- console.error(`registered GCP target ${created.name} (${created.id})`);
3012
- console.error("\nGrant the source SA the token-creator role, then `seekrit gcp lease`:\n");
3013
- console.log(gcpSetupInstructions(config));
3014
- });
3015
- target.command("list").description("list GCP service-account targets").option("--org <slug>").action(async (options) => {
3016
- const ctx = buildContext();
3017
- const org = await resolveOrg(ctx, options.org);
3018
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3019
- for (const t of targets) {
3020
- const cfg = t.config;
3021
- if (cfg.provider !== "gcp") continue;
3022
- console.log(`${t.id}\t${t.name}\t${cfg.serviceAccount}`);
3023
- }
3024
- });
3025
- target.command("setup <targetId>").description("reprint the IAM setup for a GCP target").option("--org <slug>").action(async (targetId, options) => {
3026
- const ctx = buildContext();
3027
- const org = await resolveOrg(ctx, options.org);
3028
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3029
- const t = targets.find((x) => x.id === targetId || x.name === targetId);
3030
- if (!t) fail(`no target "${targetId}" in ${org.slug}`);
3031
- const cfg = t.config;
3032
- if (cfg.provider !== "gcp") fail("not a gcp target (see `seekrit aws`/`seekrit ssh`)");
3033
- console.log(gcpSetupInstructions(cfg));
3034
- });
3035
- target.command("rm <targetId>").description("delete a GCP service-account target").option("--org <slug>").action(async (targetId, options) => {
3036
- const ctx = buildContext();
3037
- const org = await resolveOrg(ctx, options.org);
3038
- await ctx.client.deleteLeaseTarget(org.id, targetId);
3039
- console.error(`deleted ${targetId}`);
3040
- });
3041
- gcp.command("lease <target>").description("mint a short-lived GCP access token; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "token lifetime, e.g. 15m, 1h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
3042
- const ctx = buildContext();
3043
- const org = await resolveOrg(ctx, options.org);
3044
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3045
- const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
3046
- if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
3047
- if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
3048
- const ttlSeconds = parseTtlSeconds$5(options.ttl);
3049
- if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
3050
- if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
3051
- const recipient = await generateGcpRecipientKeyPair();
3052
- const { gcp: leased } = await ctx.client.mintLease(org.id, {
3053
- provider: "gcp",
3054
- targetId: t.id,
3055
- recipientPublicKey: recipient.publicKeyJwk,
3056
- ttlSeconds
3057
- });
3058
- const cred = await unwrapGcpCredential(leased.wrappedCredential, recipient.privateKeyJwk);
3059
- console.error(`leased ${cred.serviceAccount} — expires ${cred.expiration}`);
3060
- if (options.json) console.log(JSON.stringify(cred, null, 2));
3061
- else {
3062
- console.log(`export CLOUDSDK_AUTH_ACCESS_TOKEN=${cred.accessToken}`);
3063
- console.log(`export GOOGLE_OAUTH_ACCESS_TOKEN=${cred.accessToken}`);
3064
- }
3065
- });
3066
- gcp.command("leases").description("list GCP leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
3067
- const ctx = buildContext();
3068
- const org = await resolveOrg(ctx, options.org);
3069
- const { leases } = await ctx.client.listLeases(org.id);
3070
- for (const l of leases) {
3071
- if (l.provider !== "gcp") continue;
3072
- console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
3073
- }
3074
- });
3075
- gcp.command("revoke <leaseId>").description("mark a lease revoked in the ledger (tokens stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
3076
- const ctx = buildContext();
3077
- const org = await resolveOrg(ctx, options.org);
3078
- await ctx.client.revokeLease(org.id, leaseId);
3079
- console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
3080
- });
3081
- }
3082
- //#endregion
3083
- //#region src/kms.ts
3084
- /** Collect a repeatable option into a list. */
3085
- function collect$6(value, acc = []) {
3086
- acc.push(value);
3087
- return acc;
3088
- }
3089
- /** The calling principal's identity + public key (for a self-grant). */
3090
- async function kmsCallerIdentity(ctx) {
3091
- if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
3092
- const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
3093
- const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
3094
- return {
3095
- principalType: "service_token",
3096
- principalId: tokenId,
3097
- publicKeyJwk: JSON.stringify(pub)
3098
- };
3099
- }
3100
- const { user } = await ctx.client.me();
3101
- if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
3102
- return {
3103
- principalType: "user",
3104
- principalId: user.id,
3105
- publicKeyJwk: user.publicKeyJwk
3106
- };
3107
- }
3108
- /** Look up an org member (by email) or service token (by id) as a grant recipient. */
3109
- async function kmsResolveRecipient(ctx, orgId, who) {
3110
- if (who.user) {
3111
- const { members } = await ctx.client.listMembers(orgId);
3112
- const m = members.find((x) => x.email === who.user);
3113
- if (!m) fail(`no member ${who.user}`);
3114
- if (!m.publicKeyJwk) fail(`${who.user} has not completed key setup`);
3115
- return {
3116
- principalType: "user",
3117
- principalId: m.userId,
3118
- publicKeyJwk: m.publicKeyJwk
3119
- };
3120
- }
3121
- if (who.token) {
3122
- const { tokens } = await ctx.client.listTokens(orgId);
3123
- const t = tokens.find((x) => x.id === who.token);
3124
- if (!t) fail(`no service token ${who.token}`);
3125
- return {
3126
- principalType: "service_token",
3127
- principalId: t.id,
3128
- publicKeyJwk: t.publicKeyJwk
3129
- };
3130
- }
3131
- fail("specify --user <email> or --token <id>");
3132
- }
3133
- async function kmsResolveKey(ctx, orgId, ref) {
3134
- const { keys } = await ctx.client.listKmsKeys(orgId);
3135
- const key = keys.find((k) => k.id === ref || k.name === ref);
3136
- if (!key) fail(`no KMS key "${ref}"`);
3137
- return key;
3138
- }
3139
- /** Recover a key's material for one version (default: current), for the caller. */
3140
- async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
3141
- const mat = await ctx.client.getMyKmsKey(orgId, keyId);
3142
- const v = version ?? mat.currentVersion;
3143
- const grant = mat.grants.find((g) => g.version === v);
3144
- if (!grant) fail(`no grant for version ${v} of this key`);
3145
- return {
3146
- material: await unwrapDek(grant.wrappedKey, await getPrivateKey(ctx)),
3147
- version: v,
3148
- currentVersion: mat.currentVersion
3149
- };
3150
- }
3151
- function registerKmsCommands(program) {
3152
- const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
3153
- kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$6, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$6, []).action(async (options) => {
3154
- if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
3155
- if (options.app && options.group) fail("pass at most one of --app or --group");
3188
+ function registerKmsCommands(program) {
3189
+ const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
3190
+ kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$6, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$6, []).action(async (options) => {
3191
+ if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
3192
+ if (options.app && options.group) fail("pass at most one of --app or --group");
3156
3193
  const ctx = buildContext();
3157
3194
  const org = await resolveOrg(ctx, options.org);
3158
3195
  let toWrap;
@@ -3367,6 +3404,489 @@ function registerKmsCommands(program) {
3367
3404
  });
3368
3405
  }
3369
3406
  //#endregion
3407
+ //#region src/recovery.ts
3408
+ /** Collect a repeatable option into a list. */
3409
+ function collect$5(value, acc = []) {
3410
+ acc.push(value);
3411
+ return acc;
3412
+ }
3413
+ /** Resolve a custodian reference: a `skt_…` token id, otherwise a member email. */
3414
+ function resolveCustodian(ctx, orgId, ref) {
3415
+ return ref.startsWith("skt_") ? kmsResolveRecipient(ctx, orgId, { token: ref }) : kmsResolveRecipient(ctx, orgId, { user: ref });
3416
+ }
3417
+ /**
3418
+ * The env DEK additionally wrapped to the org recovery key, when recovery is
3419
+ * enabled — so a newly created environment is recovery-protected from birth.
3420
+ * Returns undefined when recovery is off (the env is backfilled by `recovery
3421
+ * sync` later).
3422
+ */
3423
+ async function recoveryWrapForNewEnv(ctx, orgId, dek) {
3424
+ let recoveryPublicKeyJwk;
3425
+ try {
3426
+ const { recovery } = await ctx.client.getRecovery(orgId);
3427
+ recoveryPublicKeyJwk = recovery.enabled ? recovery.recoveryPublicKeyJwk : null;
3428
+ } catch (e) {
3429
+ if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) return void 0;
3430
+ throw e;
3431
+ }
3432
+ if (!recoveryPublicKeyJwk) return void 0;
3433
+ return wrapDek(dek, recoveryPublicKeyJwk);
3434
+ }
3435
+ /**
3436
+ * Wrap every environment the caller can decrypt but that lacks a recovery grant,
3437
+ * and upload the grants. Idempotent — safe to re-run and to run from several
3438
+ * admins to complete coverage.
3439
+ */
3440
+ async function syncRecoveryGrants(ctx, orgId) {
3441
+ const { recovery } = await ctx.client.getRecovery(orgId);
3442
+ if (!recovery.enabled || !recovery.recoveryPublicKeyJwk) fail("recovery is not enabled");
3443
+ const recoveryPublicKeyJwk = recovery.recoveryPublicKeyJwk;
3444
+ const privateKey = await getPrivateKey(ctx);
3445
+ const grants = [];
3446
+ let skipped = 0;
3447
+ for (const environmentId of recovery.coverage.unprotectedEnvIds) {
3448
+ let wrappedDek;
3449
+ try {
3450
+ ({wrappedDek} = await ctx.client.getMyEnvKey(orgId, environmentId));
3451
+ } catch (e) {
3452
+ if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) {
3453
+ skipped++;
3454
+ continue;
3455
+ }
3456
+ throw e;
3457
+ }
3458
+ const dek = await unwrapDek(wrappedDek, privateKey);
3459
+ grants.push({
3460
+ environmentId,
3461
+ wrappedDek: await wrapDek(dek, recoveryPublicKeyJwk)
3462
+ });
3463
+ }
3464
+ if (grants.length > 0) await ctx.client.uploadRecoveryGrants(orgId, { grants });
3465
+ return {
3466
+ wrapped: grants.length,
3467
+ skipped
3468
+ };
3469
+ }
3470
+ /** Generate + split a fresh recovery key across the given custodians. */
3471
+ async function buildRecoveryConfig(ctx, orgId, thresholdRaw, custodianRefs) {
3472
+ const threshold = Number.parseInt(thresholdRaw, 10);
3473
+ if (!Number.isInteger(threshold) || threshold < 1) fail("--threshold must be a positive integer");
3474
+ if (custodianRefs.length === 0) fail("pass at least one --custodian <email|skt_id>");
3475
+ if (threshold > custodianRefs.length) fail("--threshold cannot exceed the number of custodians");
3476
+ const custodians = await Promise.all(custodianRefs.map((ref) => resolveCustodian(ctx, orgId, ref)));
3477
+ const recovery = await generateRecoveryKey();
3478
+ const shares = await splitRecoveryKey(recovery.privateKeyJwk, threshold, custodians);
3479
+ return {
3480
+ recoveryPublicKeyJwk: recovery.publicKeyJwk,
3481
+ threshold,
3482
+ shares: shares.map((s) => ({
3483
+ principalType: s.principalType,
3484
+ principalId: s.principalId,
3485
+ shareIndex: s.shareIndex,
3486
+ wrappedShare: s.wrappedShare
3487
+ }))
3488
+ };
3489
+ }
3490
+ function registerRecoveryCommands(program) {
3491
+ const recovery = program.command("recovery").description("customer-controlled M-of-N recovery (zero-knowledge)");
3492
+ recovery.command("status").description("show recovery configuration and environment coverage").option("--org <slug>").action(async (options) => {
3493
+ const ctx = buildContext();
3494
+ const org = await resolveOrg(ctx, options.org);
3495
+ const { recovery: status } = await ctx.client.getRecovery(org.id);
3496
+ if (!status.enabled) {
3497
+ console.log("recovery: disabled");
3498
+ return;
3499
+ }
3500
+ console.log(`recovery: enabled (${status.threshold}-of-${status.shareCount})`);
3501
+ console.log(`coverage: ${status.coverage.protected}/${status.coverage.total} environments protected`);
3502
+ console.log("custodians:");
3503
+ for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
3504
+ if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
3505
+ });
3506
+ recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$5, []).option("--org <slug>").action(async (options) => {
3507
+ const ctx = buildContext();
3508
+ const org = await resolveOrg(ctx, options.org);
3509
+ const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
3510
+ await ctx.client.configureRecovery(org.id, {
3511
+ ...config,
3512
+ grants: []
3513
+ });
3514
+ console.error(`recovery enabled: ${config.threshold}-of-${config.shares.length}`);
3515
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
3516
+ console.error(`recovery-protected ${wrapped} environment(s) you can decrypt`);
3517
+ if (skipped > 0) console.error(`${skipped} environment(s) need another admin to run \`seekrit recovery sync\``);
3518
+ });
3519
+ recovery.command("sync").description("recovery-protect environments you can decrypt but that aren't yet covered").option("--org <slug>").action(async (options) => {
3520
+ const ctx = buildContext();
3521
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
3522
+ console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
3523
+ });
3524
+ recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$5, []).option("--org <slug>").action(async (options) => {
3525
+ const ctx = buildContext();
3526
+ const org = await resolveOrg(ctx, options.org);
3527
+ const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
3528
+ await ctx.client.rotateRecovery(org.id, {
3529
+ ...config,
3530
+ grants: []
3531
+ });
3532
+ console.error(`recovery rotated: ${config.threshold}-of-${config.shares.length}`);
3533
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
3534
+ console.error(`re-wrapped ${wrapped} environment(s) you can decrypt to the new recovery key`);
3535
+ if (skipped > 0) console.error(`${skipped} environment(s) still need another admin to run \`seekrit recovery sync\``);
3536
+ });
3537
+ recovery.command("disable").description("disable recovery and remove all recovery grants").option("--org <slug>").action(async (options) => {
3538
+ const ctx = buildContext();
3539
+ const org = await resolveOrg(ctx, options.org);
3540
+ await ctx.client.disableRecovery(org.id);
3541
+ console.error("recovery disabled; recovery grants removed");
3542
+ });
3543
+ recovery.command("request").description("start a recovery ceremony (defaults to recovering access for yourself)").option("--target-user <email>", "recover access for another member").option("--target-token <id>", "recover access for a service token").option("--reason <text>", "note recorded in the audit trail").option("--org <slug>").action(async (options) => {
3544
+ const ctx = buildContext();
3545
+ const org = await resolveOrg(ctx, options.org);
3546
+ const target = options.targetUser || options.targetToken ? await kmsResolveRecipient(ctx, org.id, {
3547
+ user: options.targetUser,
3548
+ token: options.targetToken
3549
+ }) : await kmsCallerIdentity(ctx);
3550
+ const { request } = await ctx.client.createRecoveryRequest(org.id, {
3551
+ targetPublicKeyJwk: target.publicKeyJwk,
3552
+ targetType: target.principalType,
3553
+ targetId: target.principalId,
3554
+ reason: options.reason
3555
+ });
3556
+ console.error(`recovery request ${request.id} created (needs ${request.threshold} custodians)`);
3557
+ console.error(` custodians run: seekrit recovery approve ${request.id}`);
3558
+ console.error(` then the target: seekrit recovery complete ${request.id}`);
3559
+ });
3560
+ recovery.command("approve").description("as a custodian, contribute your share to a recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3561
+ const ctx = buildContext();
3562
+ const org = await resolveOrg(ctx, options.org);
3563
+ const { request } = await ctx.client.getRecoveryRequest(org.id, requestId);
3564
+ const myShare = await ctx.client.getMyRecoveryShare(org.id);
3565
+ const privateKey = await getPrivateKey(ctx);
3566
+ const contributedShare = await rewrapRecoveryShare(await unwrapRecoveryShare(myShare.wrappedShare, privateKey), request.targetPublicKeyJwk);
3567
+ const res = await ctx.client.contributeRecoveryShare(org.id, requestId, {
3568
+ shareIndex: myShare.shareIndex,
3569
+ contributedShare
3570
+ });
3571
+ console.error(`contributed share #${myShare.shareIndex}: ${res.contributed}/${res.threshold} collected${res.quorumReached ? " — quorum reached" : ""}`);
3572
+ });
3573
+ recovery.command("complete").description("as the recovery target, reconstruct the key and restore your access").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3574
+ const ctx = buildContext();
3575
+ const org = await resolveOrg(ctx, options.org);
3576
+ const { request, contributions, quorumReached } = await ctx.client.getRecoveryRequest(org.id, requestId);
3577
+ if (!quorumReached) fail(`only ${contributions.length}/${request.threshold} custodians have contributed`);
3578
+ const me = await kmsCallerIdentity(ctx);
3579
+ const targetPrivateKey = await getPrivateKey(ctx);
3580
+ const recoveryPrivateKey = await combineRecoveryShares(await Promise.all(contributions.map((cont) => unwrapRecoveryShare(cont.contributedShare, targetPrivateKey))));
3581
+ const { grants: recoveryEnvKeys } = await ctx.client.getRecoveryEnvKeys(org.id);
3582
+ const restored = [];
3583
+ for (const g of recoveryEnvKeys) {
3584
+ const dek = await unwrapDek(g.wrappedDek, recoveryPrivateKey);
3585
+ restored.push({
3586
+ environmentId: g.environmentId,
3587
+ wrappedDek: await wrapDek(dek, me.publicKeyJwk)
3588
+ });
3589
+ }
3590
+ await ctx.client.completeRecoveryRequest(org.id, requestId, {
3591
+ principalType: me.principalType,
3592
+ principalId: me.principalId,
3593
+ grants: restored
3594
+ });
3595
+ console.error(`recovery complete: restored access to ${restored.length} environment(s)`);
3596
+ });
3597
+ recovery.command("cancel").description("cancel an open recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3598
+ const ctx = buildContext();
3599
+ const org = await resolveOrg(ctx, options.org);
3600
+ await ctx.client.cancelRecoveryRequest(org.id, requestId);
3601
+ console.error(`recovery request ${requestId} canceled`);
3602
+ });
3603
+ }
3604
+ //#endregion
3605
+ //#region src/branches.ts
3606
+ /**
3607
+ * Resolve `--from` to the environment being branched.
3608
+ *
3609
+ * Not just `resolveAppEnv`: branch slugs live in the same namespace but are
3610
+ * excluded from the environment list, so naming one lands on "no environment
3611
+ * …". Branching a branch is a real thing people will try (depth is capped at
3612
+ * one), and it deserves an error that says so.
3613
+ */
3614
+ async function resolveBranchParent(ctx, opts) {
3615
+ const app = await resolveApp(ctx, opts);
3616
+ const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
3617
+ const env = environments.find((e) => e.slug === opts.from || e.id === opts.from);
3618
+ if (env) return {
3619
+ orgId: app.orgId,
3620
+ appId: app.id,
3621
+ appSlug: app.slug,
3622
+ envId: env.id,
3623
+ envSlug: env.slug
3624
+ };
3625
+ const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
3626
+ if (branches.some((b) => b.slug === opts.from || b.id === opts.from)) fail(`"${opts.from}" is itself a branch — branches are one level deep, so branch from the environment it overlays`);
3627
+ fail(`no environment "${opts.from}" in ${app.slug}`);
3628
+ }
3629
+ /**
3630
+ * Branch (ephemeral) configs: `seekrit branch create pr-142 --from dev`.
3631
+ *
3632
+ * A branch overlays its parent instead of copying it, so creating one encrypts
3633
+ * nothing — it mints a data key for the branch's own overrides and wraps that
3634
+ * key to whoever should read them. Everything the branch inherits stays where
3635
+ * it is, and stays live.
3636
+ */
3637
+ function registerBranchCommands(program) {
3638
+ const branch = program.command("branch").description("ephemeral per-PR / preview configs layered on an environment");
3639
+ branch.command("create <slug>").description("fork an environment into an ephemeral branch (inherits its secrets)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").requiredOption("--from <env>", "the environment to branch").option("--name <name>", "display name (defaults to the slug)").option("--ttl <duration>", "lifetime: 12h, 7d, 2w, … or `never`", "7d").option("--no-share", "don't give the parent's other readers access to this branch").action(async (slug, options) => {
3640
+ const ctx = buildContext();
3641
+ const parent = await resolveBranchParent(ctx, {
3642
+ org: options.org,
3643
+ app: options.app,
3644
+ from: options.from
3645
+ });
3646
+ const parsedTtl = parseBranchTtl(options.ttl);
3647
+ if (parsedTtl === null) fail(`invalid --ttl "${options.ttl}" (try 12h, 7d, 2w, or never)`);
3648
+ const ttlSeconds = Number.isFinite(parsedTtl) ? parsedTtl : null;
3649
+ const me = await kmsCallerIdentity(ctx);
3650
+ const dek = generateDek();
3651
+ const wrappedDek = await wrapDek(dek, me.publicKeyJwk);
3652
+ const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, parent.orgId, dek);
3653
+ const grants = [];
3654
+ if (options.share !== false) {
3655
+ const { grantees } = await ctx.client.listGrantees(parent.orgId, parent.envId);
3656
+ for (const grantee of grantees) {
3657
+ if (grantee.principalType === me.principalType && grantee.principalId === me.principalId) continue;
3658
+ grants.push({
3659
+ principalType: grantee.principalType,
3660
+ principalId: grantee.principalId,
3661
+ wrappedDek: await wrapDek(dek, grantee.publicKeyJwk)
3662
+ });
3663
+ }
3664
+ }
3665
+ const created = await ctx.client.createBranch(parent.orgId, parent.envId, {
3666
+ slug,
3667
+ name: options.name,
3668
+ ttlSeconds,
3669
+ wrappedDek,
3670
+ recoveryWrappedDek,
3671
+ grants
3672
+ });
3673
+ console.error(`created branch ${parent.appSlug}/${parent.envSlug}#${created.branch.slug} (${created.branch.id})`);
3674
+ console.error(created.branch.expiresAt ? `expires ${created.branch.expiresAt}` : "no expiry — delete it explicitly when the PR closes");
3675
+ if (grants.length > 0) console.error(`shared with ${grants.length} other reader(s)`);
3676
+ });
3677
+ branch.command("list").description("list branches in an application (or of one environment)").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--env <slug>", "only branches of this environment").action(async (options) => {
3678
+ const ctx = buildContext();
3679
+ if (options.env) {
3680
+ const parent = await resolveAppEnv(ctx, options);
3681
+ const { branches } = await ctx.client.listBranches(parent.orgId, parent.envId);
3682
+ for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
3683
+ return;
3684
+ }
3685
+ const app = await resolveApp(ctx, options);
3686
+ const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
3687
+ for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
3688
+ });
3689
+ branch.command("delete <slug>").alias("rm").description("tear down a branch and everything it overrode").option("--org <slug>").option("--app <slug>", "application slug (defaults to seekrit.json)").action(async (slug, options) => {
3690
+ const ctx = buildContext();
3691
+ const app = await resolveApp(ctx, options);
3692
+ const target = await resolveBranch(ctx, app, slug);
3693
+ await ctx.client.deleteBranch(app.orgId, target.id);
3694
+ console.error(`deleted branch ${app.slug}#${target.slug}`);
3695
+ });
3696
+ }
3697
+ //#endregion
3698
+ //#region src/dotenv.ts
3699
+ /**
3700
+ * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
3701
+ * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
3702
+ * escapes; unquoted values drop trailing ` # comments`). Multiline values are
3703
+ * not supported — keep those in seekrit itself.
3704
+ */
3705
+ function parseDotenv(content) {
3706
+ const out = {};
3707
+ for (const raw of content.split(/\r?\n/)) {
3708
+ let line = raw.trim();
3709
+ if (!line || line.startsWith("#")) continue;
3710
+ if (line.startsWith("export ")) line = line.slice(7).trimStart();
3711
+ const eq = line.indexOf("=");
3712
+ if (eq === -1) continue;
3713
+ const key = line.slice(0, eq).trim();
3714
+ if (!key) continue;
3715
+ let value = line.slice(eq + 1).trim();
3716
+ const quote = value[0];
3717
+ if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
3718
+ value = value.slice(1, -1);
3719
+ if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
3720
+ } else {
3721
+ const comment = value.indexOf(" #");
3722
+ if (comment !== -1) value = value.slice(0, comment).trim();
3723
+ }
3724
+ out[key] = value;
3725
+ }
3726
+ return out;
3727
+ }
3728
+ //#endregion
3729
+ //#region src/format.ts
3730
+ function needsQuoting(value) {
3731
+ return /[\s"'`$\\#]/.test(value) || value === "";
3732
+ }
3733
+ function dotenvQuote(value) {
3734
+ if (!needsQuoting(value)) return value;
3735
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
3736
+ }
3737
+ function shellQuote(value) {
3738
+ return `'${value.replaceAll("'", `'\\''`)}'`;
3739
+ }
3740
+ function formatSecrets(values, format) {
3741
+ const names = Object.keys(values).sort();
3742
+ switch (format) {
3743
+ case "json": return JSON.stringify(values, names, 2);
3744
+ case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
3745
+ case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
3746
+ }
3747
+ }
3748
+ //#endregion
3749
+ //#region src/gcp.ts
3750
+ /**
3751
+ * `seekrit gcp` — temporary GCP credentials via IAM Credentials
3752
+ * `generateAccessToken` (Vault-style dynamic secrets, the tier-2 sibling of
3753
+ * `seekrit aws`).
3754
+ *
3755
+ * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
3756
+ * keypair on THIS machine and sends only the public key; GCP mints the token and
3757
+ * the broker returns it wrapped to that key, so the control plane only ever
3758
+ * relays ciphertext and only this machine can unwrap it. Registering a target
3759
+ * wraps the service-account key JSON to the broker's public key locally, so the
3760
+ * control plane never sees it either — the source service account needs only
3761
+ * `roles/iam.serviceAccountTokenCreator` on the target.
3762
+ */
3763
+ /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
3764
+ function parseTtlSeconds$5(input) {
3765
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
3766
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
3767
+ return Number(m[1]) * ({
3768
+ s: 1,
3769
+ m: 60,
3770
+ h: 3600,
3771
+ d: 86400
3772
+ }[m[2] || "s"] ?? 1);
3773
+ }
3774
+ /** Collect a repeatable flag (e.g. --scope) into a list. */
3775
+ function collectList$1(value, acc = []) {
3776
+ acc.push(value);
3777
+ return acc;
3778
+ }
3779
+ /**
3780
+ * The service-account key JSON the broker impersonates with. From --key-file or
3781
+ * GOOGLE_APPLICATION_CREDENTIALS. Never leaves this machine unwrapped — it is
3782
+ * wrapped to the broker key before upload.
3783
+ */
3784
+ function resolveServiceAccountKey(opts) {
3785
+ const path = opts.keyFile ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
3786
+ if (!path) fail("provide the source service-account key JSON via --key-file or GOOGLE_APPLICATION_CREDENTIALS (it needs roles/iam.serviceAccountTokenCreator on the target)");
3787
+ const raw = readFileSync(path, "utf8").trim();
3788
+ try {
3789
+ const parsed = JSON.parse(raw);
3790
+ if (typeof parsed.client_email !== "string" || typeof parsed.private_key !== "string") fail(`${path} is not a service-account key JSON (missing client_email/private_key)`);
3791
+ } catch {
3792
+ fail(`${path} is not valid JSON`);
3793
+ }
3794
+ return raw;
3795
+ }
3796
+ function registerGcpCommands(program) {
3797
+ const gcp = program.command("gcp").description("temporary GCP credentials (IAM generateAccessToken, zero-knowledge)");
3798
+ const target = gcp.command("target").description("manage GCP service-account targets");
3799
+ target.command("add").description("register an impersonable service account to issue temporary tokens from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--service-account <email>", "the service account to impersonate, name@project.iam.gserviceaccount.com").option("--org <slug>").option("--scope <scope>", "OAuth scope to grant (repeatable; default cloud-platform)", collectList$1).option("--delegate <email>", "delegation-chain service account (repeatable)", collectList$1).option("--max-ttl <duration>", "clamp requested token lifetime, e.g. 1h").option("--key-file <path>", "source SA key JSON (else GOOGLE_APPLICATION_CREDENTIALS)").action(async (options) => {
3800
+ const ctx = buildContext();
3801
+ const org = await resolveOrg(ctx, options.org);
3802
+ const config = {
3803
+ provider: "gcp",
3804
+ executor: "in_do",
3805
+ serviceAccount: options.serviceAccount,
3806
+ ...options.scope?.length ? { scopes: options.scope } : {},
3807
+ ...options.delegate?.length ? { delegates: options.delegate } : {},
3808
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
3809
+ };
3810
+ const keyJson = resolveServiceAccountKey(options);
3811
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
3812
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(keyJson), publicKeyJwk);
3813
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
3814
+ name: options.name,
3815
+ config,
3816
+ wrappedAdminSecret
3817
+ });
3818
+ console.error(`registered GCP target ${created.name} (${created.id})`);
3819
+ console.error("\nGrant the source SA the token-creator role, then `seekrit gcp lease`:\n");
3820
+ console.log(gcpSetupInstructions(config));
3821
+ });
3822
+ target.command("list").description("list GCP service-account targets").option("--org <slug>").action(async (options) => {
3823
+ const ctx = buildContext();
3824
+ const org = await resolveOrg(ctx, options.org);
3825
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
3826
+ for (const t of targets) {
3827
+ const cfg = t.config;
3828
+ if (cfg.provider !== "gcp") continue;
3829
+ console.log(`${t.id}\t${t.name}\t${cfg.serviceAccount}`);
3830
+ }
3831
+ });
3832
+ target.command("setup <targetId>").description("reprint the IAM setup for a GCP target").option("--org <slug>").action(async (targetId, options) => {
3833
+ const ctx = buildContext();
3834
+ const org = await resolveOrg(ctx, options.org);
3835
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
3836
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
3837
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
3838
+ const cfg = t.config;
3839
+ if (cfg.provider !== "gcp") fail("not a gcp target (see `seekrit aws`/`seekrit ssh`)");
3840
+ console.log(gcpSetupInstructions(cfg));
3841
+ });
3842
+ target.command("rm <targetId>").description("delete a GCP service-account target").option("--org <slug>").action(async (targetId, options) => {
3843
+ const ctx = buildContext();
3844
+ const org = await resolveOrg(ctx, options.org);
3845
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
3846
+ console.error(`deleted ${targetId}`);
3847
+ });
3848
+ gcp.command("lease <target>").description("mint a short-lived GCP access token; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "token lifetime, e.g. 15m, 1h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
3849
+ const ctx = buildContext();
3850
+ const org = await resolveOrg(ctx, options.org);
3851
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
3852
+ const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
3853
+ if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
3854
+ if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
3855
+ const ttlSeconds = parseTtlSeconds$5(options.ttl);
3856
+ if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
3857
+ if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
3858
+ const recipient = await generateGcpRecipientKeyPair();
3859
+ const { gcp: leased } = await ctx.client.mintLease(org.id, {
3860
+ provider: "gcp",
3861
+ targetId: t.id,
3862
+ recipientPublicKey: recipient.publicKeyJwk,
3863
+ ttlSeconds
3864
+ });
3865
+ const cred = await unwrapGcpCredential(leased.wrappedCredential, recipient.privateKeyJwk);
3866
+ console.error(`leased ${cred.serviceAccount} — expires ${cred.expiration}`);
3867
+ if (options.json) console.log(JSON.stringify(cred, null, 2));
3868
+ else {
3869
+ console.log(`export CLOUDSDK_AUTH_ACCESS_TOKEN=${cred.accessToken}`);
3870
+ console.log(`export GOOGLE_OAUTH_ACCESS_TOKEN=${cred.accessToken}`);
3871
+ }
3872
+ });
3873
+ gcp.command("leases").description("list GCP leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
3874
+ const ctx = buildContext();
3875
+ const org = await resolveOrg(ctx, options.org);
3876
+ const { leases } = await ctx.client.listLeases(org.id);
3877
+ for (const l of leases) {
3878
+ if (l.provider !== "gcp") continue;
3879
+ console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
3880
+ }
3881
+ });
3882
+ gcp.command("revoke <leaseId>").description("mark a lease revoked in the ledger (tokens stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
3883
+ const ctx = buildContext();
3884
+ const org = await resolveOrg(ctx, options.org);
3885
+ await ctx.client.revokeLease(org.id, leaseId);
3886
+ console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
3887
+ });
3888
+ }
3889
+ //#endregion
3370
3890
  //#region src/m2m.ts
3371
3891
  /**
3372
3892
  * Resolve M2M client credentials from (in order) the process environment, a
@@ -3384,12 +3904,16 @@ function readM2mCreds(dotenvVars = {}) {
3384
3904
  clientSecret
3385
3905
  };
3386
3906
  }
3387
- /** True when a service/dev credential is already configured explicitly. */
3907
+ /**
3908
+ * True when a service/session/dev credential is already configured explicitly.
3909
+ * A browser-authorized session counts: a human who ran `seekrit login` must not
3910
+ * be silently swapped onto a machine identity.
3911
+ */
3388
3912
  function hasExplicitCredential(dotenvVars) {
3389
3913
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
3390
3914
  if (fromEnv("SEEKRIT_TOKEN") || fromEnv("SEEKRIT_DEV_USER")) return true;
3391
3915
  const config = readGlobalConfig();
3392
- return Boolean(config.token || config.devUser);
3916
+ return Boolean(config.token || config.sessionToken || config.devUser);
3393
3917
  }
3394
3918
  /** Mint a fresh org-scoped admin token using M2M credentials (keyless). */
3395
3919
  async function mintAdminToken(apiUrl, creds) {
@@ -3463,7 +3987,7 @@ function parseTtlSeconds$4(input) {
3463
3987
  }[m[2] || "s"] ?? 1);
3464
3988
  }
3465
3989
  /** Collect a repeatable option into an array. */
3466
- function collect$5(value, previous) {
3990
+ function collect$4(value, previous) {
3467
3991
  return [...previous, value];
3468
3992
  }
3469
3993
  /** Parse `readWrite@app` → { role, db } for a custom target. */
@@ -3488,7 +4012,7 @@ function resolveAdminUri(uri) {
3488
4012
  function registerMongoCommands(program) {
3489
4013
  const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
3490
4014
  const target = mongo.command("target").description("manage MongoDB targets");
3491
- target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$5, []).option("--auth-source <db>", "authentication database (default admin)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--no-tls", "disable TLS to the cluster (TLS is on by default)").action(async (options) => {
4015
+ target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$4, []).option("--auth-source <db>", "authentication database (default admin)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--no-tls", "disable TLS to the cluster (TLS is on by default)").action(async (options) => {
3492
4016
  const ctx = buildContext();
3493
4017
  const org = await resolveOrg(ctx, options.org);
3494
4018
  const adminUri = resolveAdminUri(options.uri);
@@ -3648,7 +4172,7 @@ function generateUserName$1(prefix = "tmp") {
3648
4172
  function registerMysqlCommands(program) {
3649
4173
  const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
3650
4174
  const target = mysql.command("target").description("manage provisioning targets");
3651
- target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$4, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$4, []).action(async (options) => {
4175
+ target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$3, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$3, []).action(async (options) => {
3652
4176
  const ctx = buildContext();
3653
4177
  const org = await resolveOrg(ctx, options.org);
3654
4178
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3749,7 +4273,7 @@ function registerMysqlCommands(program) {
3749
4273
  });
3750
4274
  }
3751
4275
  /** Collect a repeatable option into an array. */
3752
- function collect$4(value, acc) {
4276
+ function collect$3(value, acc) {
3753
4277
  acc.push(value);
3754
4278
  return acc;
3755
4279
  }
@@ -3786,7 +4310,7 @@ function generateRoleName(prefix = "tmp") {
3786
4310
  function registerPgCommands(program) {
3787
4311
  const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
3788
4312
  const target = pg.command("target").description("manage provisioning targets");
3789
- target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$3, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$3, []).action(async (options) => {
4313
+ target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$2, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$2, []).action(async (options) => {
3790
4314
  const ctx = buildContext();
3791
4315
  const org = await resolveOrg(ctx, options.org);
3792
4316
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3900,208 +4424,10 @@ function registerPgCommands(program) {
3900
4424
  });
3901
4425
  }
3902
4426
  /** Collect a repeatable option into an array. */
3903
- function collect$3(value, acc) {
3904
- acc.push(value);
3905
- return acc;
3906
- }
3907
- //#endregion
3908
- //#region src/recovery.ts
3909
- /** Collect a repeatable option into a list. */
3910
- function collect$2(value, acc = []) {
4427
+ function collect$2(value, acc) {
3911
4428
  acc.push(value);
3912
4429
  return acc;
3913
4430
  }
3914
- /** Resolve a custodian reference: a `skt_…` token id, otherwise a member email. */
3915
- function resolveCustodian(ctx, orgId, ref) {
3916
- return ref.startsWith("skt_") ? kmsResolveRecipient(ctx, orgId, { token: ref }) : kmsResolveRecipient(ctx, orgId, { user: ref });
3917
- }
3918
- /**
3919
- * The env DEK additionally wrapped to the org recovery key, when recovery is
3920
- * enabled — so a newly created environment is recovery-protected from birth.
3921
- * Returns undefined when recovery is off (the env is backfilled by `recovery
3922
- * sync` later).
3923
- */
3924
- async function recoveryWrapForNewEnv(ctx, orgId, dek) {
3925
- let recoveryPublicKeyJwk;
3926
- try {
3927
- const { recovery } = await ctx.client.getRecovery(orgId);
3928
- recoveryPublicKeyJwk = recovery.enabled ? recovery.recoveryPublicKeyJwk : null;
3929
- } catch (e) {
3930
- if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) return void 0;
3931
- throw e;
3932
- }
3933
- if (!recoveryPublicKeyJwk) return void 0;
3934
- return wrapDek(dek, recoveryPublicKeyJwk);
3935
- }
3936
- /**
3937
- * Wrap every environment the caller can decrypt but that lacks a recovery grant,
3938
- * and upload the grants. Idempotent — safe to re-run and to run from several
3939
- * admins to complete coverage.
3940
- */
3941
- async function syncRecoveryGrants(ctx, orgId) {
3942
- const { recovery } = await ctx.client.getRecovery(orgId);
3943
- if (!recovery.enabled || !recovery.recoveryPublicKeyJwk) fail("recovery is not enabled");
3944
- const recoveryPublicKeyJwk = recovery.recoveryPublicKeyJwk;
3945
- const privateKey = await getPrivateKey(ctx);
3946
- const grants = [];
3947
- let skipped = 0;
3948
- for (const environmentId of recovery.coverage.unprotectedEnvIds) {
3949
- let wrappedDek;
3950
- try {
3951
- ({wrappedDek} = await ctx.client.getMyEnvKey(orgId, environmentId));
3952
- } catch (e) {
3953
- if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) {
3954
- skipped++;
3955
- continue;
3956
- }
3957
- throw e;
3958
- }
3959
- const dek = await unwrapDek(wrappedDek, privateKey);
3960
- grants.push({
3961
- environmentId,
3962
- wrappedDek: await wrapDek(dek, recoveryPublicKeyJwk)
3963
- });
3964
- }
3965
- if (grants.length > 0) await ctx.client.uploadRecoveryGrants(orgId, { grants });
3966
- return {
3967
- wrapped: grants.length,
3968
- skipped
3969
- };
3970
- }
3971
- /** Generate + split a fresh recovery key across the given custodians. */
3972
- async function buildRecoveryConfig(ctx, orgId, thresholdRaw, custodianRefs) {
3973
- const threshold = Number.parseInt(thresholdRaw, 10);
3974
- if (!Number.isInteger(threshold) || threshold < 1) fail("--threshold must be a positive integer");
3975
- if (custodianRefs.length === 0) fail("pass at least one --custodian <email|skt_id>");
3976
- if (threshold > custodianRefs.length) fail("--threshold cannot exceed the number of custodians");
3977
- const custodians = await Promise.all(custodianRefs.map((ref) => resolveCustodian(ctx, orgId, ref)));
3978
- const recovery = await generateRecoveryKey();
3979
- const shares = await splitRecoveryKey(recovery.privateKeyJwk, threshold, custodians);
3980
- return {
3981
- recoveryPublicKeyJwk: recovery.publicKeyJwk,
3982
- threshold,
3983
- shares: shares.map((s) => ({
3984
- principalType: s.principalType,
3985
- principalId: s.principalId,
3986
- shareIndex: s.shareIndex,
3987
- wrappedShare: s.wrappedShare
3988
- }))
3989
- };
3990
- }
3991
- function registerRecoveryCommands(program) {
3992
- const recovery = program.command("recovery").description("customer-controlled M-of-N recovery (zero-knowledge)");
3993
- recovery.command("status").description("show recovery configuration and environment coverage").option("--org <slug>").action(async (options) => {
3994
- const ctx = buildContext();
3995
- const org = await resolveOrg(ctx, options.org);
3996
- const { recovery: status } = await ctx.client.getRecovery(org.id);
3997
- if (!status.enabled) {
3998
- console.log("recovery: disabled");
3999
- return;
4000
- }
4001
- console.log(`recovery: enabled (${status.threshold}-of-${status.shareCount})`);
4002
- console.log(`coverage: ${status.coverage.protected}/${status.coverage.total} environments protected`);
4003
- console.log("custodians:");
4004
- for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
4005
- if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
4006
- });
4007
- recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$2, []).option("--org <slug>").action(async (options) => {
4008
- const ctx = buildContext();
4009
- const org = await resolveOrg(ctx, options.org);
4010
- const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
4011
- await ctx.client.configureRecovery(org.id, {
4012
- ...config,
4013
- grants: []
4014
- });
4015
- console.error(`recovery enabled: ${config.threshold}-of-${config.shares.length}`);
4016
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
4017
- console.error(`recovery-protected ${wrapped} environment(s) you can decrypt`);
4018
- if (skipped > 0) console.error(`${skipped} environment(s) need another admin to run \`seekrit recovery sync\``);
4019
- });
4020
- recovery.command("sync").description("recovery-protect environments you can decrypt but that aren't yet covered").option("--org <slug>").action(async (options) => {
4021
- const ctx = buildContext();
4022
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
4023
- console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
4024
- });
4025
- recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$2, []).option("--org <slug>").action(async (options) => {
4026
- const ctx = buildContext();
4027
- const org = await resolveOrg(ctx, options.org);
4028
- const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
4029
- await ctx.client.rotateRecovery(org.id, {
4030
- ...config,
4031
- grants: []
4032
- });
4033
- console.error(`recovery rotated: ${config.threshold}-of-${config.shares.length}`);
4034
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
4035
- console.error(`re-wrapped ${wrapped} environment(s) you can decrypt to the new recovery key`);
4036
- if (skipped > 0) console.error(`${skipped} environment(s) still need another admin to run \`seekrit recovery sync\``);
4037
- });
4038
- recovery.command("disable").description("disable recovery and remove all recovery grants").option("--org <slug>").action(async (options) => {
4039
- const ctx = buildContext();
4040
- const org = await resolveOrg(ctx, options.org);
4041
- await ctx.client.disableRecovery(org.id);
4042
- console.error("recovery disabled; recovery grants removed");
4043
- });
4044
- recovery.command("request").description("start a recovery ceremony (defaults to recovering access for yourself)").option("--target-user <email>", "recover access for another member").option("--target-token <id>", "recover access for a service token").option("--reason <text>", "note recorded in the audit trail").option("--org <slug>").action(async (options) => {
4045
- const ctx = buildContext();
4046
- const org = await resolveOrg(ctx, options.org);
4047
- const target = options.targetUser || options.targetToken ? await kmsResolveRecipient(ctx, org.id, {
4048
- user: options.targetUser,
4049
- token: options.targetToken
4050
- }) : await kmsCallerIdentity(ctx);
4051
- const { request } = await ctx.client.createRecoveryRequest(org.id, {
4052
- targetPublicKeyJwk: target.publicKeyJwk,
4053
- targetType: target.principalType,
4054
- targetId: target.principalId,
4055
- reason: options.reason
4056
- });
4057
- console.error(`recovery request ${request.id} created (needs ${request.threshold} custodians)`);
4058
- console.error(` custodians run: seekrit recovery approve ${request.id}`);
4059
- console.error(` then the target: seekrit recovery complete ${request.id}`);
4060
- });
4061
- recovery.command("approve").description("as a custodian, contribute your share to a recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
4062
- const ctx = buildContext();
4063
- const org = await resolveOrg(ctx, options.org);
4064
- const { request } = await ctx.client.getRecoveryRequest(org.id, requestId);
4065
- const myShare = await ctx.client.getMyRecoveryShare(org.id);
4066
- const privateKey = await getPrivateKey(ctx);
4067
- const contributedShare = await rewrapRecoveryShare(await unwrapRecoveryShare(myShare.wrappedShare, privateKey), request.targetPublicKeyJwk);
4068
- const res = await ctx.client.contributeRecoveryShare(org.id, requestId, {
4069
- shareIndex: myShare.shareIndex,
4070
- contributedShare
4071
- });
4072
- console.error(`contributed share #${myShare.shareIndex}: ${res.contributed}/${res.threshold} collected${res.quorumReached ? " — quorum reached" : ""}`);
4073
- });
4074
- recovery.command("complete").description("as the recovery target, reconstruct the key and restore your access").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
4075
- const ctx = buildContext();
4076
- const org = await resolveOrg(ctx, options.org);
4077
- const { request, contributions, quorumReached } = await ctx.client.getRecoveryRequest(org.id, requestId);
4078
- if (!quorumReached) fail(`only ${contributions.length}/${request.threshold} custodians have contributed`);
4079
- const me = await kmsCallerIdentity(ctx);
4080
- const targetPrivateKey = await getPrivateKey(ctx);
4081
- const recoveryPrivateKey = await combineRecoveryShares(await Promise.all(contributions.map((cont) => unwrapRecoveryShare(cont.contributedShare, targetPrivateKey))));
4082
- const { grants: recoveryEnvKeys } = await ctx.client.getRecoveryEnvKeys(org.id);
4083
- const restored = [];
4084
- for (const g of recoveryEnvKeys) {
4085
- const dek = await unwrapDek(g.wrappedDek, recoveryPrivateKey);
4086
- restored.push({
4087
- environmentId: g.environmentId,
4088
- wrappedDek: await wrapDek(dek, me.publicKeyJwk)
4089
- });
4090
- }
4091
- await ctx.client.completeRecoveryRequest(org.id, requestId, {
4092
- principalType: me.principalType,
4093
- principalId: me.principalId,
4094
- grants: restored
4095
- });
4096
- console.error(`recovery complete: restored access to ${restored.length} environment(s)`);
4097
- });
4098
- recovery.command("cancel").description("cancel an open recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
4099
- const ctx = buildContext();
4100
- const org = await resolveOrg(ctx, options.org);
4101
- await ctx.client.cancelRecoveryRequest(org.id, requestId);
4102
- console.error(`recovery request ${requestId} canceled`);
4103
- });
4104
- }
4105
4431
  //#endregion
4106
4432
  //#region src/redis.ts
4107
4433
  /**
@@ -4326,6 +4652,7 @@ async function importSecrets(ctx, orgId, envId, entries) {
4326
4652
  async function materializeEnv(ctx, opts) {
4327
4653
  const query = {};
4328
4654
  if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
4655
+ if (opts.branch) query.branch = opts.branch;
4329
4656
  if (!isTokenAuth(ctx)) {
4330
4657
  if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
4331
4658
  query.env = opts.envId;
@@ -4336,7 +4663,10 @@ async function materializeEnv(ctx, opts) {
4336
4663
  const provenance = {};
4337
4664
  for (const layer of layers) {
4338
4665
  const dek = await unwrapDek(layer.wrappedDek, privateKey);
4339
- const label = layer.source === "group" ? `group:${layer.groupSlug}@${layer.slug}` : `app:${scope.appSlug}/${layer.slug}`;
4666
+ let label;
4667
+ if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
4668
+ else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
4669
+ else label = `app:${scope.appSlug}/${layer.slug}`;
4340
4670
  for (const secret of layer.secrets) {
4341
4671
  values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
4342
4672
  provenance[secret.name] = label;
@@ -4526,6 +4856,181 @@ function collect(value, acc) {
4526
4856
  return acc;
4527
4857
  }
4528
4858
  //#endregion
4859
+ //#region src/web-login.ts
4860
+ /**
4861
+ * `seekrit login` — sign in through the browser.
4862
+ *
4863
+ * The credential is born here and never leaves: we mint a CLI session token
4864
+ * locally, register only its SHA-256 hash, and wait for a human to authorize
4865
+ * that hash in the dashboard. When they do, we already hold the token — the
4866
+ * approval round-trip carries nothing secret, so there is no window in which the
4867
+ * API (or anything watching it) could learn our credential.
4868
+ *
4869
+ * The saved session authenticates as *you*, which is why it needs no org, app,
4870
+ * or environment selection: commands see every org you're a member of, and
4871
+ * decryption still runs through your own passphrase-unlocked key. Machines want
4872
+ * the opposite trade — a scoped, key-carrying credential — and keep using
4873
+ * `seekrit login --token skt_…`.
4874
+ */
4875
+ /** Open a URL in the platform's default browser. Best-effort and silent. */
4876
+ function openBrowser(url) {
4877
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
4878
+ "/c",
4879
+ "start",
4880
+ "",
4881
+ url
4882
+ ]] : ["xdg-open", [url]];
4883
+ try {
4884
+ const child = spawn(command, args, {
4885
+ stdio: "ignore",
4886
+ detached: true
4887
+ });
4888
+ child.on("error", () => void 0);
4889
+ child.unref();
4890
+ } catch {}
4891
+ }
4892
+ /** A frame of the waiting spinner, or a static line on a non-TTY. */
4893
+ const SPINNER = [
4894
+ "⠋",
4895
+ "⠙",
4896
+ "⠹",
4897
+ "⠸",
4898
+ "⠼",
4899
+ "⠴",
4900
+ "⠦",
4901
+ "⠧",
4902
+ "⠇",
4903
+ "⠏"
4904
+ ];
4905
+ function startWaitingIndicator(message) {
4906
+ if (!process.stderr.isTTY) {
4907
+ process.stderr.write(`${message}\n`);
4908
+ return () => void 0;
4909
+ }
4910
+ let frame = 0;
4911
+ const timer = setInterval(() => {
4912
+ process.stderr.write(`\r${SPINNER[frame % SPINNER.length]} ${message}`);
4913
+ frame++;
4914
+ }, 80);
4915
+ return () => {
4916
+ clearInterval(timer);
4917
+ process.stderr.write(`\r${" ".repeat(message.length + 2)}\r`);
4918
+ };
4919
+ }
4920
+ function sleep(ms) {
4921
+ return new Promise((resolve) => setTimeout(resolve, ms));
4922
+ }
4923
+ async function runWebLogin(options) {
4924
+ const config = readGlobalConfig();
4925
+ const apiUrl = options.apiUrl ?? process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "https://api.seekrit.dev";
4926
+ const client = `cli/${version}`;
4927
+ const session = await createCliSessionToken();
4928
+ const deviceLabel = `${userInfo().username}@${hostname()}`;
4929
+ const started = await startCliLogin(apiUrl, {
4930
+ sessionId: session.sessionId,
4931
+ tokenHash: session.tokenHash,
4932
+ deviceLabel,
4933
+ client
4934
+ }, { client }).catch((err) => {
4935
+ fail(`couldn't start sign-in: ${err instanceof Error ? err.message : String(err)}`);
4936
+ });
4937
+ console.error(`Sign in to seekrit to authorize this device (${deviceLabel}).\n`);
4938
+ console.error(` ${started.verifyUrl}\n`);
4939
+ console.error(` code: ${started.code} — check it matches the one in your browser\n`);
4940
+ if (options.browser === false) console.error("Open that URL to continue.\n");
4941
+ else {
4942
+ await promptEnter("Press [Enter] to open it in your browser (Ctrl-C to cancel)… ");
4943
+ openBrowser(started.verifyUrl);
4944
+ }
4945
+ const stopWaiting = startWaitingIndicator("Waiting for you to authorize…");
4946
+ try {
4947
+ const deadline = Date.parse(started.requestExpiresAt);
4948
+ while (true) {
4949
+ const result = await pollCliLogin(apiUrl, started.code, { client }).catch(() => null);
4950
+ if (result?.status === "approved") {
4951
+ writeGlobalConfig({
4952
+ sessionToken: session.token,
4953
+ token: void 0,
4954
+ ...options.apiUrl ? { apiUrl: options.apiUrl } : {}
4955
+ });
4956
+ stopWaiting();
4957
+ const who = result.email ?? "your account";
4958
+ console.error(`Signed in as ${who} — this device is authorized for 90 days.`);
4959
+ if (config.token) console.error("(the service token saved here was replaced; SEEKRIT_TOKEN still wins)");
4960
+ await reportOrgs(apiUrl, session.token, client);
4961
+ return;
4962
+ }
4963
+ if (result?.status === "denied") {
4964
+ stopWaiting();
4965
+ fail("sign-in was declined in the browser");
4966
+ }
4967
+ if (result?.status === "expired" || Date.now() > deadline) {
4968
+ stopWaiting();
4969
+ fail("sign-in request expired — run `seekrit login` again");
4970
+ }
4971
+ await sleep(started.pollIntervalSeconds * 1e3);
4972
+ }
4973
+ } finally {
4974
+ stopWaiting();
4975
+ }
4976
+ }
4977
+ /**
4978
+ * Print what the new session can reach, so a successful login ends with proof it
4979
+ * works rather than a bare "ok". Best-effort: a hiccup here doesn't undo a
4980
+ * login that already succeeded.
4981
+ */
4982
+ async function reportOrgs(apiUrl, token, client) {
4983
+ try {
4984
+ const { user, orgs } = await new SeekritClient({
4985
+ baseUrl: apiUrl,
4986
+ auth: {
4987
+ type: "bearer",
4988
+ token
4989
+ },
4990
+ client
4991
+ }).me();
4992
+ for (const org of orgs) console.error(` ${org.slug} (${org.role})`);
4993
+ if (!user.hasKeys) console.error("\nNext: run `seekrit keys setup` to create your encryption keys.");
4994
+ } catch {}
4995
+ }
4996
+ /**
4997
+ * `seekrit logout` — drop the saved credential, and revoke it server-side when
4998
+ * it's a CLI session (the one credential this machine owns outright). A service
4999
+ * token is shared infrastructure that other machines may hold, so it is only
5000
+ * removed locally, never revoked out from under them.
5001
+ */
5002
+ async function runLogout() {
5003
+ const config = readGlobalConfig();
5004
+ const sessionToken = config.sessionToken;
5005
+ if (!sessionToken && !config.token && !config.devUser && !config.clientId) {
5006
+ console.error("not signed in");
5007
+ return;
5008
+ }
5009
+ if (sessionToken) {
5010
+ const api = new SeekritClient({
5011
+ baseUrl: process.env.SEEKRIT_API_URL ?? config.apiUrl ?? "https://api.seekrit.dev",
5012
+ auth: {
5013
+ type: "bearer",
5014
+ token: sessionToken
5015
+ },
5016
+ client: `cli/${version}`
5017
+ });
5018
+ try {
5019
+ const { currentSessionId } = await api.listCliSessions();
5020
+ if (currentSessionId) await api.revokeCliSession(currentSessionId);
5021
+ console.error("signed out — this device is no longer authorized");
5022
+ } catch (err) {
5023
+ console.error(`signed out locally, but couldn't revoke the session: ${err instanceof Error ? err.message : String(err)}`);
5024
+ }
5025
+ }
5026
+ writeGlobalConfig({
5027
+ sessionToken: void 0,
5028
+ token: void 0,
5029
+ devUser: void 0
5030
+ });
5031
+ if (config.clientId) console.error("(machine client credentials are kept — remove them with `seekrit login`)");
5032
+ }
5033
+ //#endregion
4529
5034
  //#region src/index.ts
4530
5035
  /** Collect repeated `--with group=env` flags into a map. */
4531
5036
  function collectKv(value, acc = {}) {
@@ -4543,11 +5048,21 @@ const program = new Command("seekrit").description("End-to-end encrypted secrets
4543
5048
  program.hook("preAction", async () => {
4544
5049
  await ensureM2mAdminToken();
4545
5050
  });
4546
- program.command("login").description("store credentials for the API").option("--token <token>", "service token (skt_…)").option("--client-id <id>", "machine (M2M) client id — auto-mints an admin token").option("--client-secret <secret>", "machine (M2M) client secret").option("--dev-user <email>", "dev-mode identity (local API with AUTH_MODE=dev)").option("--api-url <url>", "API base URL").action((options) => {
5051
+ program.command("login").description("sign in through the browser (or pass a credential to store one directly)").option("--token <token>", "service token (skt_…) — skips the browser").option("--client-id <id>", "machine (M2M) client id — auto-mints an admin token").option("--client-secret <secret>", "machine (M2M) client secret").option("--dev-user <email>", "dev-mode identity (local API with AUTH_MODE=dev)").option("--api-url <url>", "API base URL").option("--no-browser", "print the sign-in URL instead of opening it").action(async (options) => {
4547
5052
  if (options.token && !isServiceToken(options.token)) fail("token must start with skt_");
4548
5053
  if (Boolean(options.clientId) !== Boolean(options.clientSecret)) fail("--client-id and --client-secret must be given together");
5054
+ if (!(options.token || options.clientId || options.devUser)) {
5055
+ await runWebLogin({
5056
+ apiUrl: options.apiUrl,
5057
+ browser: options.browser
5058
+ });
5059
+ return;
5060
+ }
4549
5061
  writeGlobalConfig({
4550
- ...options.token ? { token: options.token } : {},
5062
+ ...options.token ? {
5063
+ token: options.token,
5064
+ sessionToken: void 0
5065
+ } : {},
4551
5066
  ...options.clientId ? { clientId: options.clientId } : {},
4552
5067
  ...options.clientSecret ? { clientSecret: options.clientSecret } : {},
4553
5068
  ...options.devUser ? { devUser: options.devUser } : {},
@@ -4555,6 +5070,9 @@ program.command("login").description("store credentials for the API").option("--
4555
5070
  });
4556
5071
  console.error("credentials saved");
4557
5072
  });
5073
+ program.command("logout").description("forget the saved credentials (revokes a browser-authorized session)").action(async () => {
5074
+ await runLogout();
5075
+ });
4558
5076
  program.command("whoami").description("show the authenticated identity").action(async () => {
4559
5077
  const ctx = buildContext();
4560
5078
  if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
@@ -4571,6 +5089,10 @@ program.command("whoami").description("show the authenticated identity").action(
4571
5089
  const { user, orgs } = await ctx.client.me();
4572
5090
  console.log(`${user.email}${user.hasKeys ? "" : " (key setup pending — run `seekrit keys setup`)"}`);
4573
5091
  for (const org of orgs) console.log(` ${org.slug} (${org.role})`);
5092
+ if (ctx.auth.type === "bearer" && isCliSessionToken(ctx.auth.token)) {
5093
+ const { sessionId } = parseCliSessionToken(ctx.auth.token);
5094
+ console.log(` via CLI session ${sessionId} (revoke it with \`seekrit logout\`)`);
5095
+ }
4574
5096
  });
4575
5097
  program.command("keys").description("manage your encryption keys").command("setup").description("generate your keypair and protect it with a passphrase").action(async () => {
4576
5098
  const ctx = buildContext();
@@ -4702,7 +5224,7 @@ function parseVersion(raw) {
4702
5224
  }
4703
5225
  /** Attach the environment-selection flags shared by every `secrets` command. */
4704
5226
  function withTarget(cmd) {
4705
- return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").requiredOption("--env <slug>", "environment slug");
5227
+ return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").option("--branch <slug>", "operate on a branch of --env").requiredOption("--env <slug>", "environment slug");
4706
5228
  }
4707
5229
  const secrets = program.command("secrets").description("manage secrets in an application or group environment");
4708
5230
  withTarget(secrets.command("list").description("list secret names (no values)")).action(async (options) => {
@@ -4780,6 +5302,7 @@ async function materialize(ctx, options) {
4780
5302
  if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, options)).envId;
4781
5303
  return materializeEnv(ctx, {
4782
5304
  envId,
5305
+ branch: options.branch ?? process.env.SEEKRIT_BRANCH,
4783
5306
  with: options.with,
4784
5307
  envFiles: options.envFile ?? [".env"],
4785
5308
  interpolate: options.interpolate
@@ -4800,7 +5323,11 @@ async function materializeForRun(options) {
4800
5323
  await ensureM2mAdminToken(dotenvVars);
4801
5324
  const ctx = tryBuildContext(dotenvVars);
4802
5325
  if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
4803
- return await materialize(ctx, options);
5326
+ const branch = options.branch ?? process.env.SEEKRIT_BRANCH ?? dotenvVars.SEEKRIT_BRANCH;
5327
+ return await materialize(ctx, {
5328
+ ...options,
5329
+ branch
5330
+ });
4804
5331
  } catch (err) {
4805
5332
  const message = err instanceof Error ? err.message : String(err);
4806
5333
  console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
@@ -4923,7 +5450,7 @@ async function reapStragglers(pids, signal) {
4923
5450
  process.kill(pid, "SIGKILL");
4924
5451
  } catch {}
4925
5452
  }
4926
- program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
5453
+ program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
4927
5454
  const [cmd, ...args] = commandParts;
4928
5455
  if (!cmd) fail("no command given");
4929
5456
  const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
@@ -4975,7 +5502,7 @@ program.command("run").description("run a command with decrypted secrets injecte
4975
5502
  });
4976
5503
  child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
4977
5504
  });
4978
- program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
5505
+ program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
4979
5506
  if (![
4980
5507
  "dotenv",
4981
5508
  "json",
@@ -5095,6 +5622,7 @@ token.command("delete <tokenId>").description("permanently delete a revoked serv
5095
5622
  await ctx.client.deleteToken(orgRef.id, tokenId);
5096
5623
  console.error(`${tokenId} deleted`);
5097
5624
  });
5625
+ registerBranchCommands(program);
5098
5626
  registerPgCommands(program);
5099
5627
  registerMysqlCommands(program);
5100
5628
  registerRedisCommands(program);
@@ -5106,7 +5634,7 @@ registerMongoCommands(program);
5106
5634
  registerKmsCommands(program);
5107
5635
  registerRecoveryCommands(program);
5108
5636
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
5109
- const { runMcpServer } = await import("./mcp-COWsshZZ.js");
5637
+ const { runMcpServer } = await import("./mcp-DLplPOvz.js");
5110
5638
  await runMcpServer();
5111
5639
  });
5112
5640
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -5120,4 +5648,4 @@ program.parseAsync(argv).catch((err) => {
5120
5648
  fail(err instanceof Error ? err.message : String(err));
5121
5649
  });
5122
5650
  //#endregion
5123
- export { generateMysqlCredential as A, generateSigningKeyMaterial as C, signatureKeyRef as D, signMessage as E, kmsEncrypt as F, wrapDek as I, generateDek as L, generateEncryptKeyMaterial as M, kmsBlobKeyRef as N, verifyMessage as O, kmsDecrypt as P, toBase64 as R, parseServiceToken as S, importVerifyingKey as T, setFailThrows as _, ensureM2mAdminToken as a, createServiceToken as b, kmsResolveKey as c, resolveEnvTarget as d, resolveGroup as f, tryBuildContext as g, isTokenAuth as h, materializeEnv as i, generateDataKey as j, generatePostgresCredential as k, kmsResolveRecipient as l, getDek as m, fetchDecryptedSecrets as n, kmsCallerIdentity as o, resolveOrg as p, fetchDecryptedVersion as r, kmsRecoverMaterial as s, encryptAndSetSecret as t, resolveAppEnv as u, writeProjectConfig as v, importSigningKey as w, isServiceToken as x, version as y };
5651
+ export { verifyMessage as A, toBase64 as B, isServiceToken as C, importVerifyingKey as D, importSigningKey as E, kmsBlobKeyRef as F, kmsDecrypt as I, kmsEncrypt as L, generateMysqlCredential as M, generateDataKey as N, signMessage as O, generateEncryptKeyMaterial as P, wrapDek as R, createServiceToken as S, generateSigningKeyMaterial as T, parseBranchTtl as V, isTokenAuth as _, ensureM2mAdminToken as a, writeProjectConfig as b, kmsResolveKey as c, resolveAppEnv as d, resolveBranch as f, getDek as g, resolveOrg as h, materializeEnv as i, generatePostgresCredential as j, signatureKeyRef as k, kmsResolveRecipient as l, resolveGroup as m, fetchDecryptedSecrets as n, kmsCallerIdentity as o, resolveEnvTarget as p, fetchDecryptedVersion as r, kmsRecoverMaterial as s, encryptAndSetSecret as t, resolveApp as u, tryBuildContext as v, parseServiceToken as w, version as x, setFailThrows as y, generateDek as z };