@seekrit/cli 0.24.0 → 0.26.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
@@ -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",
@@ -143,6 +149,197 @@ const SUBSCRIPTION_STATUSES = [
143
149
  "canceled",
144
150
  "paused"
145
151
  ];
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
198
+ //#region ../../packages/core/src/interpolate.ts
199
+ /**
200
+ * Secret references: `${OTHER_SECRET}` inside a secret value.
201
+ *
202
+ * This is the **canonical specification** of the expansion. It is pure string
203
+ * work over an already-decrypted variable set, so it runs wherever plaintext
204
+ * legitimately exists — the CLI, the browser, the language SDKs, and the Rust
205
+ * clients (`crates/seekrit-core/src/interpolate.rs` mirrors it, pinned by the
206
+ * shared golden fixture in `apps/run/testdata/vectors.json`).
207
+ *
208
+ * Expansion happens at **read time**, on the client, never on write and never
209
+ * on the server: the API only ever holds the ciphertext of the literal
210
+ * `${OTHER_SECRET}` text, so referencing costs nothing against the
211
+ * zero-knowledge invariant. It also means a reference stays live — rotating
212
+ * `DB_PASSWORD` updates every value that references it, with no re-encryption.
213
+ *
214
+ * The rules, in full:
215
+ *
216
+ * - `${NAME}` is replaced with the value of `NAME` in the *same fully-merged
217
+ * set* — after group → app-env → `.env` layering, so a reference always sees
218
+ * the value that layer precedence actually selected.
219
+ * - `NAME` must be a valid secret name (`[A-Za-z_][A-Za-z0-9_]*`, matching
220
+ * `secretNameSchema`). Anything else — `${1}`, `${FOO:-bar}`, `${a.b}` — is
221
+ * left exactly as written, so shell and CI template syntax passes through
222
+ * untouched.
223
+ * - A reference to a name that is not in the set is **left literal** and
224
+ * reported in {@link InterpolationResult.unresolved}. Erroring would mean a
225
+ * stored value that happens to contain `${GITHUB_SHA}` could break a whole
226
+ * environment's resolve; leaving it alone is the safe default, and the report
227
+ * is there to surface typos (`seekrit run --explain` prints it).
228
+ * - Expansion is recursive: a referenced value may itself contain references.
229
+ * - `$${NAME}` is an escape producing the literal text `${NAME}`. A `$$` not
230
+ * followed by `{` is ordinary text (passwords full of `$` are safe).
231
+ * - A reference **cycle** throws {@link InterpolationError}. Unlike an unknown
232
+ * name, a cycle can only be a configuration mistake — every name in it
233
+ * exists — and there is no value that could be correct to emit.
234
+ *
235
+ * `process.env` is deliberately *not* a reference source: `seekrit run` layers
236
+ * the live shell on top of the resolved set afterwards, and letting a stored
237
+ * secret pull in arbitrary host environment variables would be a surprising
238
+ * (and machine-dependent) way to change a secret's value.
239
+ */
240
+ /** A reference name — the same grammar as `secretNameSchema`. */
241
+ const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
242
+ /**
243
+ * Cap on a single expanded value. Nested references can multiply length
244
+ * (`A=${B}${B}`, `B=${C}${C}`, …), which memoization makes fast but does not
245
+ * make small. A megabyte is far above any real secret and far below anything
246
+ * that would exhaust a container.
247
+ */
248
+ const MAX_EXPANDED_LENGTH = 1048576;
249
+ /**
250
+ * Split a value into literal runs and references — the single tokenizer every
251
+ * rule above is expressed in terms of, so expansion and inspection can never
252
+ * disagree about what counts as a reference.
253
+ */
254
+ function* scan(text) {
255
+ let i = 0;
256
+ while (i < text.length) {
257
+ const dollar = text.indexOf("$", i);
258
+ if (dollar === -1) {
259
+ yield { literal: text.slice(i) };
260
+ return;
261
+ }
262
+ if (dollar > i) yield { literal: text.slice(i, dollar) };
263
+ if (text[dollar + 1] === "$" && text[dollar + 2] === "{") {
264
+ yield { literal: "${" };
265
+ i = dollar + 3;
266
+ continue;
267
+ }
268
+ const close = text[dollar + 1] === "{" ? text.indexOf("}", dollar + 2) : -1;
269
+ const reference = close === -1 ? null : text.slice(dollar + 2, close);
270
+ if (reference !== null && REFERENCE_NAME.test(reference)) {
271
+ yield {
272
+ reference,
273
+ raw: text.slice(dollar, close + 1)
274
+ };
275
+ i = close + 1;
276
+ continue;
277
+ }
278
+ yield { literal: "$" };
279
+ i = dollar + 1;
280
+ }
281
+ }
282
+ var InterpolationError = class extends Error {
283
+ code;
284
+ /**
285
+ * For `cycle`, the reference chain that closed on itself, starting and ending
286
+ * on the same name (`["A", "B", "A"]`). For `too_large`, the single name
287
+ * whose expansion blew the cap.
288
+ */
289
+ chain;
290
+ constructor(code, message, chain) {
291
+ super(message);
292
+ this.name = "InterpolationError";
293
+ this.code = code;
294
+ this.chain = chain;
295
+ }
296
+ };
297
+ /**
298
+ * Expand `${NAME}` references throughout a decrypted variable set.
299
+ *
300
+ * Pure: the input is never mutated. Throws {@link InterpolationError} on a
301
+ * reference cycle (see the module comment for the complete rule set).
302
+ */
303
+ function interpolateSecrets(values) {
304
+ const resolved = /* @__PURE__ */ new Map();
305
+ const unresolved = /* @__PURE__ */ new Set();
306
+ const expanded = [];
307
+ /** The chain currently being expanded — the cycle detector. */
308
+ const stack = [];
309
+ /** Expand one present name's value, memoized so each is expanded once. */
310
+ function resolve(name) {
311
+ const cached = resolved.get(name);
312
+ if (cached !== void 0) return cached;
313
+ const cycleAt = stack.indexOf(name);
314
+ if (cycleAt !== -1) {
315
+ const chain = [...stack.slice(cycleAt), name];
316
+ throw new InterpolationError("cycle", `secret reference cycle: ${chain.join(" → ")}`, chain);
317
+ }
318
+ stack.push(name);
319
+ let out = "";
320
+ for (const segment of scan(values[name])) if ("literal" in segment) out += segment.literal;
321
+ else if (Object.hasOwn(values, segment.reference)) out += resolve(segment.reference);
322
+ else {
323
+ unresolved.add(segment.reference);
324
+ out += segment.raw;
325
+ }
326
+ stack.pop();
327
+ if (out.length > MAX_EXPANDED_LENGTH) throw new InterpolationError("too_large", `${name} expands to more than ${MAX_EXPANDED_LENGTH} bytes — check its references`, [name]);
328
+ resolved.set(name, out);
329
+ return out;
330
+ }
331
+ const result = {};
332
+ for (const name of Object.keys(values)) {
333
+ const value = resolve(name);
334
+ result[name] = value;
335
+ if (value !== values[name]) expanded.push(name);
336
+ }
337
+ return {
338
+ values: result,
339
+ expanded,
340
+ unresolved: [...unresolved].sort()
341
+ };
342
+ }
146
343
  z.enum([
147
344
  "postgres",
148
345
  "mysql",
@@ -718,11 +915,22 @@ z.object({
718
915
  */
719
916
  encryptedPrivateKey: z.string().min(1)
720
917
  });
721
- z.object({
918
+ const grantEnvironmentKeySchema = z.object({
722
919
  principalType: principalTypeSchema,
723
920
  principalId: z.string().min(1),
724
921
  wrappedDek: z.string().min(1)
725
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
+ });
726
934
  z.object({
727
935
  name: nameSchema,
728
936
  tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
@@ -1978,7 +2186,7 @@ function isServiceToken(value) {
1978
2186
  }
1979
2187
  //#endregion
1980
2188
  //#region package.json
1981
- var version = "0.24.0";
2189
+ var version = "0.26.0";
1982
2190
  //#endregion
1983
2191
  //#region ../../packages/api-client/src/index.ts
1984
2192
  var SeekritApiError = class extends Error {
@@ -2107,6 +2315,28 @@ var SeekritClient = class {
2107
2315
  deleteEnv(orgId, envId) {
2108
2316
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
2109
2317
  }
2318
+ /**
2319
+ * The public keys of an environment's grant-holders, so a client can wrap a
2320
+ * new DEK to each of them (see `createBranch`). No key material is returned.
2321
+ */
2322
+ listGrantees(orgId, envId) {
2323
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/grantees`);
2324
+ }
2325
+ listBranches(orgId, envId) {
2326
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/branches`);
2327
+ }
2328
+ /** Every branch in an application, across all its environments. */
2329
+ listAppBranches(orgId, appId) {
2330
+ return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/branches`);
2331
+ }
2332
+ /** Fork `envId` into an ephemeral branch. `envId` is the parent, not the branch. */
2333
+ createBranch(orgId, envId, input) {
2334
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/branches`, input);
2335
+ }
2336
+ /** Branches are environments, so tearing one down is `deleteEnv`. */
2337
+ deleteBranch(orgId, branchId) {
2338
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${branchId}`);
2339
+ }
2110
2340
  listGroups(orgId) {
2111
2341
  return this.request("GET", `/v1/orgs/${orgId}/groups`);
2112
2342
  }
@@ -2142,6 +2372,7 @@ var SeekritClient = class {
2142
2372
  resolve(query = {}) {
2143
2373
  const params = new URLSearchParams();
2144
2374
  if (query.env) params.set("env", query.env);
2375
+ if (query.branch) params.set("branch", query.branch);
2145
2376
  for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
2146
2377
  const qs = params.size > 0 ? `?${params}` : "";
2147
2378
  return this.request("GET", `/v1/resolve${qs}`);
@@ -2535,12 +2766,14 @@ async function resolveOrg(ctx, orgSlug) {
2535
2766
  }
2536
2767
  /**
2537
2768
  * Resolve an environment to operate on — an application env (`--app --env`,
2538
- * or the config's app + `--env`) or a group env (`--group --env`).
2769
+ * or the config's app + `--env`), a branch of one (`--branch`), or a group env
2770
+ * (`--group --env`).
2539
2771
  */
2540
2772
  async function resolveEnvTarget(ctx, opts) {
2541
2773
  const org = await resolveOrg(ctx, opts.org);
2542
2774
  if (!opts.env) fail("specify --env");
2543
2775
  if (opts.group) {
2776
+ if (opts.branch) fail("--branch applies to application environments, not groups");
2544
2777
  const { groups } = await ctx.client.listGroups(org.id);
2545
2778
  const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
2546
2779
  if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
@@ -2553,34 +2786,59 @@ async function resolveEnvTarget(ctx, opts) {
2553
2786
  label: `${group.slug}@${env.slug}`
2554
2787
  };
2555
2788
  }
2556
- const appSlug = opts.app ?? findProjectConfig()?.app;
2557
- if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
2558
- const { apps } = await ctx.client.listApps(org.id);
2559
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
2560
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
2789
+ const app = await resolveApp(ctx, opts);
2561
2790
  const { environments } = await ctx.client.listEnvs(org.id, app.id);
2562
2791
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
2563
2792
  if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
2793
+ if (opts.branch) {
2794
+ const branch = await resolveBranch(ctx, app, opts.branch);
2795
+ return {
2796
+ orgId: org.id,
2797
+ envId: branch.id,
2798
+ label: `${app.slug}/${env.slug}#${branch.slug}`
2799
+ };
2800
+ }
2564
2801
  return {
2565
2802
  orgId: org.id,
2566
2803
  envId: env.id,
2567
2804
  label: `${app.slug}/${env.slug}`
2568
2805
  };
2569
2806
  }
2570
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
2571
- async function resolveAppEnv(ctx, opts) {
2807
+ /** Resolve the target application from a flag or the committed config. */
2808
+ async function resolveApp(ctx, opts) {
2572
2809
  const org = await resolveOrg(ctx, opts.org);
2573
2810
  const appSlug = opts.app ?? findProjectConfig()?.app;
2574
2811
  if (!appSlug) fail("specify --app (or run `seekrit init`)");
2575
- if (!opts.env) fail("specify --env");
2576
2812
  const { apps } = await ctx.client.listApps(org.id);
2577
2813
  const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
2578
2814
  if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
2579
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
2815
+ return {
2816
+ orgId: org.id,
2817
+ orgSlug: org.slug,
2818
+ id: app.id,
2819
+ slug: app.slug
2820
+ };
2821
+ }
2822
+ /**
2823
+ * Find a branch by slug (or id) anywhere in an application. Branch slugs share
2824
+ * the application's environment namespace, so one lookup is unambiguous — no
2825
+ * need to name the parent environment.
2826
+ */
2827
+ async function resolveBranch(ctx, app, branchRef) {
2828
+ const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
2829
+ const branch = branches.find((b) => b.slug === branchRef || b.id === branchRef);
2830
+ if (!branch) fail(`no branch "${branchRef}" in ${app.slug}`);
2831
+ return branch;
2832
+ }
2833
+ /** Resolve an application environment, keeping ids + slugs (for token binding). */
2834
+ async function resolveAppEnv(ctx, opts) {
2835
+ if (!opts.env) fail("specify --env");
2836
+ const app = await resolveApp(ctx, opts);
2837
+ const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
2580
2838
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
2581
2839
  if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
2582
2840
  return {
2583
- orgId: org.id,
2841
+ orgId: app.orgId,
2584
2842
  appId: app.id,
2585
2843
  appSlug: app.slug,
2586
2844
  envId: env.id,
@@ -2742,271 +3000,79 @@ function registerAwsCommands(program) {
2742
3000
  });
2743
3001
  }
2744
3002
  //#endregion
2745
- //#region src/dotenv.ts
2746
- /**
2747
- * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
2748
- * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
2749
- * escapes; unquoted values drop trailing ` # comments`). Multiline values are
2750
- * not supported — keep those in seekrit itself.
2751
- */
2752
- function parseDotenv(content) {
2753
- const out = {};
2754
- for (const raw of content.split(/\r?\n/)) {
2755
- let line = raw.trim();
2756
- if (!line || line.startsWith("#")) continue;
2757
- if (line.startsWith("export ")) line = line.slice(7).trimStart();
2758
- const eq = line.indexOf("=");
2759
- if (eq === -1) continue;
2760
- const key = line.slice(0, eq).trim();
2761
- if (!key) continue;
2762
- let value = line.slice(eq + 1).trim();
2763
- const quote = value[0];
2764
- if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
2765
- value = value.slice(1, -1);
2766
- if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2767
- } else {
2768
- const comment = value.indexOf(" #");
2769
- if (comment !== -1) value = value.slice(0, comment).trim();
2770
- }
2771
- out[key] = value;
2772
- }
2773
- return out;
2774
- }
2775
- //#endregion
2776
- //#region src/format.ts
2777
- function needsQuoting(value) {
2778
- return /[\s"'`$\\#]/.test(value) || value === "";
2779
- }
2780
- function dotenvQuote(value) {
2781
- if (!needsQuoting(value)) return value;
2782
- return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
2783
- }
2784
- function shellQuote(value) {
2785
- return `'${value.replaceAll("'", `'\\''`)}'`;
3003
+ //#region src/kms.ts
3004
+ /** Collect a repeatable option into a list. */
3005
+ function collect$6(value, acc = []) {
3006
+ acc.push(value);
3007
+ return acc;
2786
3008
  }
2787
- function formatSecrets(values, format) {
2788
- const names = Object.keys(values).sort();
2789
- switch (format) {
2790
- case "json": return JSON.stringify(values, names, 2);
2791
- case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
2792
- case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
3009
+ /** The calling principal's identity + public key (for a self-grant). */
3010
+ async function kmsCallerIdentity(ctx) {
3011
+ if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
3012
+ const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
3013
+ const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
3014
+ return {
3015
+ principalType: "service_token",
3016
+ principalId: tokenId,
3017
+ publicKeyJwk: JSON.stringify(pub)
3018
+ };
2793
3019
  }
3020
+ const { user } = await ctx.client.me();
3021
+ if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
3022
+ return {
3023
+ principalType: "user",
3024
+ principalId: user.id,
3025
+ publicKeyJwk: user.publicKeyJwk
3026
+ };
2794
3027
  }
2795
- //#endregion
2796
- //#region src/gcp.ts
2797
- /**
2798
- * `seekrit gcp` temporary GCP credentials via IAM Credentials
2799
- * `generateAccessToken` (Vault-style dynamic secrets, the tier-2 sibling of
2800
- * `seekrit aws`).
2801
- *
2802
- * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
2803
- * keypair on THIS machine and sends only the public key; GCP mints the token and
2804
- * the broker returns it wrapped to that key, so the control plane only ever
2805
- * relays ciphertext and only this machine can unwrap it. Registering a target
2806
- * wraps the service-account key JSON to the broker's public key locally, so the
2807
- * control plane never sees it either — the source service account needs only
2808
- * `roles/iam.serviceAccountTokenCreator` on the target.
2809
- */
2810
- /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
2811
- function parseTtlSeconds$5(input) {
2812
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
2813
- if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
2814
- return Number(m[1]) * ({
2815
- s: 1,
2816
- m: 60,
2817
- h: 3600,
2818
- d: 86400
2819
- }[m[2] || "s"] ?? 1);
3028
+ /** Look up an org member (by email) or service token (by id) as a grant recipient. */
3029
+ async function kmsResolveRecipient(ctx, orgId, who) {
3030
+ if (who.user) {
3031
+ const { members } = await ctx.client.listMembers(orgId);
3032
+ const m = members.find((x) => x.email === who.user);
3033
+ if (!m) fail(`no member ${who.user}`);
3034
+ if (!m.publicKeyJwk) fail(`${who.user} has not completed key setup`);
3035
+ return {
3036
+ principalType: "user",
3037
+ principalId: m.userId,
3038
+ publicKeyJwk: m.publicKeyJwk
3039
+ };
3040
+ }
3041
+ if (who.token) {
3042
+ const { tokens } = await ctx.client.listTokens(orgId);
3043
+ const t = tokens.find((x) => x.id === who.token);
3044
+ if (!t) fail(`no service token ${who.token}`);
3045
+ return {
3046
+ principalType: "service_token",
3047
+ principalId: t.id,
3048
+ publicKeyJwk: t.publicKeyJwk
3049
+ };
3050
+ }
3051
+ fail("specify --user <email> or --token <id>");
2820
3052
  }
2821
- /** Collect a repeatable flag (e.g. --scope) into a list. */
2822
- function collectList$1(value, acc = []) {
2823
- acc.push(value);
2824
- return acc;
3053
+ async function kmsResolveKey(ctx, orgId, ref) {
3054
+ const { keys } = await ctx.client.listKmsKeys(orgId);
3055
+ const key = keys.find((k) => k.id === ref || k.name === ref);
3056
+ if (!key) fail(`no KMS key "${ref}"`);
3057
+ return key;
2825
3058
  }
2826
- /**
2827
- * The service-account key JSON the broker impersonates with. From --key-file or
2828
- * GOOGLE_APPLICATION_CREDENTIALS. Never leaves this machine unwrapped — it is
2829
- * wrapped to the broker key before upload.
2830
- */
2831
- function resolveServiceAccountKey(opts) {
2832
- const path = opts.keyFile ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
2833
- 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)");
2834
- const raw = readFileSync(path, "utf8").trim();
2835
- try {
2836
- const parsed = JSON.parse(raw);
2837
- 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)`);
2838
- } catch {
2839
- fail(`${path} is not valid JSON`);
2840
- }
2841
- return raw;
3059
+ /** Recover a key's material for one version (default: current), for the caller. */
3060
+ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
3061
+ const mat = await ctx.client.getMyKmsKey(orgId, keyId);
3062
+ const v = version ?? mat.currentVersion;
3063
+ const grant = mat.grants.find((g) => g.version === v);
3064
+ if (!grant) fail(`no grant for version ${v} of this key`);
3065
+ return {
3066
+ material: await unwrapDek(grant.wrappedKey, await getPrivateKey(ctx)),
3067
+ version: v,
3068
+ currentVersion: mat.currentVersion
3069
+ };
2842
3070
  }
2843
- function registerGcpCommands(program) {
2844
- const gcp = program.command("gcp").description("temporary GCP credentials (IAM generateAccessToken, zero-knowledge)");
2845
- const target = gcp.command("target").description("manage GCP service-account targets");
2846
- 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) => {
2847
- const ctx = buildContext();
2848
- const org = await resolveOrg(ctx, options.org);
2849
- const config = {
2850
- provider: "gcp",
2851
- executor: "in_do",
2852
- serviceAccount: options.serviceAccount,
2853
- ...options.scope?.length ? { scopes: options.scope } : {},
2854
- ...options.delegate?.length ? { delegates: options.delegate } : {},
2855
- ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
2856
- };
2857
- const keyJson = resolveServiceAccountKey(options);
2858
- const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
2859
- const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(keyJson), publicKeyJwk);
2860
- const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
2861
- name: options.name,
2862
- config,
2863
- wrappedAdminSecret
2864
- });
2865
- console.error(`registered GCP target ${created.name} (${created.id})`);
2866
- console.error("\nGrant the source SA the token-creator role, then `seekrit gcp lease`:\n");
2867
- console.log(gcpSetupInstructions(config));
2868
- });
2869
- target.command("list").description("list GCP service-account targets").option("--org <slug>").action(async (options) => {
2870
- const ctx = buildContext();
2871
- const org = await resolveOrg(ctx, options.org);
2872
- const { targets } = await ctx.client.listLeaseTargets(org.id);
2873
- for (const t of targets) {
2874
- const cfg = t.config;
2875
- if (cfg.provider !== "gcp") continue;
2876
- console.log(`${t.id}\t${t.name}\t${cfg.serviceAccount}`);
2877
- }
2878
- });
2879
- target.command("setup <targetId>").description("reprint the IAM setup for a GCP target").option("--org <slug>").action(async (targetId, options) => {
2880
- const ctx = buildContext();
2881
- const org = await resolveOrg(ctx, options.org);
2882
- const { targets } = await ctx.client.listLeaseTargets(org.id);
2883
- const t = targets.find((x) => x.id === targetId || x.name === targetId);
2884
- if (!t) fail(`no target "${targetId}" in ${org.slug}`);
2885
- const cfg = t.config;
2886
- if (cfg.provider !== "gcp") fail("not a gcp target (see `seekrit aws`/`seekrit ssh`)");
2887
- console.log(gcpSetupInstructions(cfg));
2888
- });
2889
- target.command("rm <targetId>").description("delete a GCP service-account target").option("--org <slug>").action(async (targetId, options) => {
2890
- const ctx = buildContext();
2891
- const org = await resolveOrg(ctx, options.org);
2892
- await ctx.client.deleteLeaseTarget(org.id, targetId);
2893
- console.error(`deleted ${targetId}`);
2894
- });
2895
- 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) => {
2896
- const ctx = buildContext();
2897
- const org = await resolveOrg(ctx, options.org);
2898
- const { targets } = await ctx.client.listLeaseTargets(org.id);
2899
- const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
2900
- if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
2901
- if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
2902
- const ttlSeconds = parseTtlSeconds$5(options.ttl);
2903
- if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
2904
- if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
2905
- const recipient = await generateGcpRecipientKeyPair();
2906
- const { gcp: leased } = await ctx.client.mintLease(org.id, {
2907
- provider: "gcp",
2908
- targetId: t.id,
2909
- recipientPublicKey: recipient.publicKeyJwk,
2910
- ttlSeconds
2911
- });
2912
- const cred = await unwrapGcpCredential(leased.wrappedCredential, recipient.privateKeyJwk);
2913
- console.error(`leased ${cred.serviceAccount} — expires ${cred.expiration}`);
2914
- if (options.json) console.log(JSON.stringify(cred, null, 2));
2915
- else {
2916
- console.log(`export CLOUDSDK_AUTH_ACCESS_TOKEN=${cred.accessToken}`);
2917
- console.log(`export GOOGLE_OAUTH_ACCESS_TOKEN=${cred.accessToken}`);
2918
- }
2919
- });
2920
- gcp.command("leases").description("list GCP leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
2921
- const ctx = buildContext();
2922
- const org = await resolveOrg(ctx, options.org);
2923
- const { leases } = await ctx.client.listLeases(org.id);
2924
- for (const l of leases) {
2925
- if (l.provider !== "gcp") continue;
2926
- console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
2927
- }
2928
- });
2929
- 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) => {
2930
- const ctx = buildContext();
2931
- const org = await resolveOrg(ctx, options.org);
2932
- await ctx.client.revokeLease(org.id, leaseId);
2933
- console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
2934
- });
2935
- }
2936
- //#endregion
2937
- //#region src/kms.ts
2938
- /** Collect a repeatable option into a list. */
2939
- function collect$6(value, acc = []) {
2940
- acc.push(value);
2941
- return acc;
2942
- }
2943
- /** The calling principal's identity + public key (for a self-grant). */
2944
- async function kmsCallerIdentity(ctx) {
2945
- if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
2946
- const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
2947
- const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
2948
- return {
2949
- principalType: "service_token",
2950
- principalId: tokenId,
2951
- publicKeyJwk: JSON.stringify(pub)
2952
- };
2953
- }
2954
- const { user } = await ctx.client.me();
2955
- if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
2956
- return {
2957
- principalType: "user",
2958
- principalId: user.id,
2959
- publicKeyJwk: user.publicKeyJwk
2960
- };
2961
- }
2962
- /** Look up an org member (by email) or service token (by id) as a grant recipient. */
2963
- async function kmsResolveRecipient(ctx, orgId, who) {
2964
- if (who.user) {
2965
- const { members } = await ctx.client.listMembers(orgId);
2966
- const m = members.find((x) => x.email === who.user);
2967
- if (!m) fail(`no member ${who.user}`);
2968
- if (!m.publicKeyJwk) fail(`${who.user} has not completed key setup`);
2969
- return {
2970
- principalType: "user",
2971
- principalId: m.userId,
2972
- publicKeyJwk: m.publicKeyJwk
2973
- };
2974
- }
2975
- if (who.token) {
2976
- const { tokens } = await ctx.client.listTokens(orgId);
2977
- const t = tokens.find((x) => x.id === who.token);
2978
- if (!t) fail(`no service token ${who.token}`);
2979
- return {
2980
- principalType: "service_token",
2981
- principalId: t.id,
2982
- publicKeyJwk: t.publicKeyJwk
2983
- };
2984
- }
2985
- fail("specify --user <email> or --token <id>");
2986
- }
2987
- async function kmsResolveKey(ctx, orgId, ref) {
2988
- const { keys } = await ctx.client.listKmsKeys(orgId);
2989
- const key = keys.find((k) => k.id === ref || k.name === ref);
2990
- if (!key) fail(`no KMS key "${ref}"`);
2991
- return key;
2992
- }
2993
- /** Recover a key's material for one version (default: current), for the caller. */
2994
- async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
2995
- const mat = await ctx.client.getMyKmsKey(orgId, keyId);
2996
- const v = version ?? mat.currentVersion;
2997
- const grant = mat.grants.find((g) => g.version === v);
2998
- if (!grant) fail(`no grant for version ${v} of this key`);
2999
- return {
3000
- material: await unwrapDek(grant.wrappedKey, await getPrivateKey(ctx)),
3001
- version: v,
3002
- currentVersion: mat.currentVersion
3003
- };
3004
- }
3005
- function registerKmsCommands(program) {
3006
- const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
3007
- 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) => {
3008
- if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
3009
- if (options.app && options.group) fail("pass at most one of --app or --group");
3071
+ function registerKmsCommands(program) {
3072
+ const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
3073
+ 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) => {
3074
+ if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
3075
+ if (options.app && options.group) fail("pass at most one of --app or --group");
3010
3076
  const ctx = buildContext();
3011
3077
  const org = await resolveOrg(ctx, options.org);
3012
3078
  let toWrap;
@@ -3191,33 +3257,516 @@ function registerKmsCommands(program) {
3191
3257
  const { material } = await kmsRecoverMaterial(ctx, org.id, key.id, ref.version);
3192
3258
  console.log(toBase64(await decryptDataKey(material, wrapped)));
3193
3259
  });
3194
- kms.command("sign").description("sign stdin with a signing key (prints an sg1 signature)").requiredOption("--key <name>", "key name or id").option("--org <slug>").action(async (options) => {
3260
+ kms.command("sign").description("sign stdin with a signing key (prints an sg1 signature)").requiredOption("--key <name>", "key name or id").option("--org <slug>").action(async (options) => {
3261
+ const ctx = buildContext();
3262
+ const org = await resolveOrg(ctx, options.org);
3263
+ const key = await kmsResolveKey(ctx, org.id, options.key);
3264
+ if (key.purpose !== "sign") fail(`${key.name} is a ${key.purpose} key`);
3265
+ const { material, currentVersion } = await kmsRecoverMaterial(ctx, org.id, key.id);
3266
+ const message = await readStdin();
3267
+ const privateKey = await importSigningKey(material);
3268
+ console.log(await signMessage(privateKey, {
3269
+ keyId: key.id,
3270
+ version: currentVersion
3271
+ }, message));
3272
+ });
3273
+ kms.command("verify").description("verify an sg1 signature over stdin (exit 0 = valid)").requiredOption("--key <name>", "key name or id").requiredOption("--signature <sg1>", "the signature blob").option("--org <slug>").action(async (options) => {
3274
+ const ctx = buildContext();
3275
+ const org = await resolveOrg(ctx, options.org);
3276
+ const key = await kmsResolveKey(ctx, org.id, options.key);
3277
+ const ref = signatureKeyRef(options.signature);
3278
+ const { versions } = await ctx.client.getKmsPublicKeys(org.id, key.id);
3279
+ const pub = versions.find((v) => v.version === ref.version)?.publicKeyJwk;
3280
+ if (!pub) fail(`no published public key for version ${ref.version}`);
3281
+ const message = await readStdin();
3282
+ if (await verifyMessage(await importVerifyingKey(pub), options.signature, message)) console.error("valid");
3283
+ else {
3284
+ console.error("INVALID");
3285
+ process.exitCode = 1;
3286
+ }
3287
+ });
3288
+ }
3289
+ //#endregion
3290
+ //#region src/recovery.ts
3291
+ /** Collect a repeatable option into a list. */
3292
+ function collect$5(value, acc = []) {
3293
+ acc.push(value);
3294
+ return acc;
3295
+ }
3296
+ /** Resolve a custodian reference: a `skt_…` token id, otherwise a member email. */
3297
+ function resolveCustodian(ctx, orgId, ref) {
3298
+ return ref.startsWith("skt_") ? kmsResolveRecipient(ctx, orgId, { token: ref }) : kmsResolveRecipient(ctx, orgId, { user: ref });
3299
+ }
3300
+ /**
3301
+ * The env DEK additionally wrapped to the org recovery key, when recovery is
3302
+ * enabled — so a newly created environment is recovery-protected from birth.
3303
+ * Returns undefined when recovery is off (the env is backfilled by `recovery
3304
+ * sync` later).
3305
+ */
3306
+ async function recoveryWrapForNewEnv(ctx, orgId, dek) {
3307
+ let recoveryPublicKeyJwk;
3308
+ try {
3309
+ const { recovery } = await ctx.client.getRecovery(orgId);
3310
+ recoveryPublicKeyJwk = recovery.enabled ? recovery.recoveryPublicKeyJwk : null;
3311
+ } catch (e) {
3312
+ if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) return void 0;
3313
+ throw e;
3314
+ }
3315
+ if (!recoveryPublicKeyJwk) return void 0;
3316
+ return wrapDek(dek, recoveryPublicKeyJwk);
3317
+ }
3318
+ /**
3319
+ * Wrap every environment the caller can decrypt but that lacks a recovery grant,
3320
+ * and upload the grants. Idempotent — safe to re-run and to run from several
3321
+ * admins to complete coverage.
3322
+ */
3323
+ async function syncRecoveryGrants(ctx, orgId) {
3324
+ const { recovery } = await ctx.client.getRecovery(orgId);
3325
+ if (!recovery.enabled || !recovery.recoveryPublicKeyJwk) fail("recovery is not enabled");
3326
+ const recoveryPublicKeyJwk = recovery.recoveryPublicKeyJwk;
3327
+ const privateKey = await getPrivateKey(ctx);
3328
+ const grants = [];
3329
+ let skipped = 0;
3330
+ for (const environmentId of recovery.coverage.unprotectedEnvIds) {
3331
+ let wrappedDek;
3332
+ try {
3333
+ ({wrappedDek} = await ctx.client.getMyEnvKey(orgId, environmentId));
3334
+ } catch (e) {
3335
+ if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) {
3336
+ skipped++;
3337
+ continue;
3338
+ }
3339
+ throw e;
3340
+ }
3341
+ const dek = await unwrapDek(wrappedDek, privateKey);
3342
+ grants.push({
3343
+ environmentId,
3344
+ wrappedDek: await wrapDek(dek, recoveryPublicKeyJwk)
3345
+ });
3346
+ }
3347
+ if (grants.length > 0) await ctx.client.uploadRecoveryGrants(orgId, { grants });
3348
+ return {
3349
+ wrapped: grants.length,
3350
+ skipped
3351
+ };
3352
+ }
3353
+ /** Generate + split a fresh recovery key across the given custodians. */
3354
+ async function buildRecoveryConfig(ctx, orgId, thresholdRaw, custodianRefs) {
3355
+ const threshold = Number.parseInt(thresholdRaw, 10);
3356
+ if (!Number.isInteger(threshold) || threshold < 1) fail("--threshold must be a positive integer");
3357
+ if (custodianRefs.length === 0) fail("pass at least one --custodian <email|skt_id>");
3358
+ if (threshold > custodianRefs.length) fail("--threshold cannot exceed the number of custodians");
3359
+ const custodians = await Promise.all(custodianRefs.map((ref) => resolveCustodian(ctx, orgId, ref)));
3360
+ const recovery = await generateRecoveryKey();
3361
+ const shares = await splitRecoveryKey(recovery.privateKeyJwk, threshold, custodians);
3362
+ return {
3363
+ recoveryPublicKeyJwk: recovery.publicKeyJwk,
3364
+ threshold,
3365
+ shares: shares.map((s) => ({
3366
+ principalType: s.principalType,
3367
+ principalId: s.principalId,
3368
+ shareIndex: s.shareIndex,
3369
+ wrappedShare: s.wrappedShare
3370
+ }))
3371
+ };
3372
+ }
3373
+ function registerRecoveryCommands(program) {
3374
+ const recovery = program.command("recovery").description("customer-controlled M-of-N recovery (zero-knowledge)");
3375
+ recovery.command("status").description("show recovery configuration and environment coverage").option("--org <slug>").action(async (options) => {
3376
+ const ctx = buildContext();
3377
+ const org = await resolveOrg(ctx, options.org);
3378
+ const { recovery: status } = await ctx.client.getRecovery(org.id);
3379
+ if (!status.enabled) {
3380
+ console.log("recovery: disabled");
3381
+ return;
3382
+ }
3383
+ console.log(`recovery: enabled (${status.threshold}-of-${status.shareCount})`);
3384
+ console.log(`coverage: ${status.coverage.protected}/${status.coverage.total} environments protected`);
3385
+ console.log("custodians:");
3386
+ for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
3387
+ if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
3388
+ });
3389
+ 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) => {
3390
+ const ctx = buildContext();
3391
+ const org = await resolveOrg(ctx, options.org);
3392
+ const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
3393
+ await ctx.client.configureRecovery(org.id, {
3394
+ ...config,
3395
+ grants: []
3396
+ });
3397
+ console.error(`recovery enabled: ${config.threshold}-of-${config.shares.length}`);
3398
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
3399
+ console.error(`recovery-protected ${wrapped} environment(s) you can decrypt`);
3400
+ if (skipped > 0) console.error(`${skipped} environment(s) need another admin to run \`seekrit recovery sync\``);
3401
+ });
3402
+ recovery.command("sync").description("recovery-protect environments you can decrypt but that aren't yet covered").option("--org <slug>").action(async (options) => {
3403
+ const ctx = buildContext();
3404
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
3405
+ console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
3406
+ });
3407
+ 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) => {
3408
+ const ctx = buildContext();
3409
+ const org = await resolveOrg(ctx, options.org);
3410
+ const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
3411
+ await ctx.client.rotateRecovery(org.id, {
3412
+ ...config,
3413
+ grants: []
3414
+ });
3415
+ console.error(`recovery rotated: ${config.threshold}-of-${config.shares.length}`);
3416
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
3417
+ console.error(`re-wrapped ${wrapped} environment(s) you can decrypt to the new recovery key`);
3418
+ if (skipped > 0) console.error(`${skipped} environment(s) still need another admin to run \`seekrit recovery sync\``);
3419
+ });
3420
+ recovery.command("disable").description("disable recovery and remove all recovery grants").option("--org <slug>").action(async (options) => {
3421
+ const ctx = buildContext();
3422
+ const org = await resolveOrg(ctx, options.org);
3423
+ await ctx.client.disableRecovery(org.id);
3424
+ console.error("recovery disabled; recovery grants removed");
3425
+ });
3426
+ 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) => {
3427
+ const ctx = buildContext();
3428
+ const org = await resolveOrg(ctx, options.org);
3429
+ const target = options.targetUser || options.targetToken ? await kmsResolveRecipient(ctx, org.id, {
3430
+ user: options.targetUser,
3431
+ token: options.targetToken
3432
+ }) : await kmsCallerIdentity(ctx);
3433
+ const { request } = await ctx.client.createRecoveryRequest(org.id, {
3434
+ targetPublicKeyJwk: target.publicKeyJwk,
3435
+ targetType: target.principalType,
3436
+ targetId: target.principalId,
3437
+ reason: options.reason
3438
+ });
3439
+ console.error(`recovery request ${request.id} created (needs ${request.threshold} custodians)`);
3440
+ console.error(` custodians run: seekrit recovery approve ${request.id}`);
3441
+ console.error(` then the target: seekrit recovery complete ${request.id}`);
3442
+ });
3443
+ recovery.command("approve").description("as a custodian, contribute your share to a recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3444
+ const ctx = buildContext();
3445
+ const org = await resolveOrg(ctx, options.org);
3446
+ const { request } = await ctx.client.getRecoveryRequest(org.id, requestId);
3447
+ const myShare = await ctx.client.getMyRecoveryShare(org.id);
3448
+ const privateKey = await getPrivateKey(ctx);
3449
+ const contributedShare = await rewrapRecoveryShare(await unwrapRecoveryShare(myShare.wrappedShare, privateKey), request.targetPublicKeyJwk);
3450
+ const res = await ctx.client.contributeRecoveryShare(org.id, requestId, {
3451
+ shareIndex: myShare.shareIndex,
3452
+ contributedShare
3453
+ });
3454
+ console.error(`contributed share #${myShare.shareIndex}: ${res.contributed}/${res.threshold} collected${res.quorumReached ? " — quorum reached" : ""}`);
3455
+ });
3456
+ recovery.command("complete").description("as the recovery target, reconstruct the key and restore your access").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3457
+ const ctx = buildContext();
3458
+ const org = await resolveOrg(ctx, options.org);
3459
+ const { request, contributions, quorumReached } = await ctx.client.getRecoveryRequest(org.id, requestId);
3460
+ if (!quorumReached) fail(`only ${contributions.length}/${request.threshold} custodians have contributed`);
3461
+ const me = await kmsCallerIdentity(ctx);
3462
+ const targetPrivateKey = await getPrivateKey(ctx);
3463
+ const recoveryPrivateKey = await combineRecoveryShares(await Promise.all(contributions.map((cont) => unwrapRecoveryShare(cont.contributedShare, targetPrivateKey))));
3464
+ const { grants: recoveryEnvKeys } = await ctx.client.getRecoveryEnvKeys(org.id);
3465
+ const restored = [];
3466
+ for (const g of recoveryEnvKeys) {
3467
+ const dek = await unwrapDek(g.wrappedDek, recoveryPrivateKey);
3468
+ restored.push({
3469
+ environmentId: g.environmentId,
3470
+ wrappedDek: await wrapDek(dek, me.publicKeyJwk)
3471
+ });
3472
+ }
3473
+ await ctx.client.completeRecoveryRequest(org.id, requestId, {
3474
+ principalType: me.principalType,
3475
+ principalId: me.principalId,
3476
+ grants: restored
3477
+ });
3478
+ console.error(`recovery complete: restored access to ${restored.length} environment(s)`);
3479
+ });
3480
+ recovery.command("cancel").description("cancel an open recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3481
+ const ctx = buildContext();
3482
+ const org = await resolveOrg(ctx, options.org);
3483
+ await ctx.client.cancelRecoveryRequest(org.id, requestId);
3484
+ console.error(`recovery request ${requestId} canceled`);
3485
+ });
3486
+ }
3487
+ //#endregion
3488
+ //#region src/branches.ts
3489
+ /**
3490
+ * Resolve `--from` to the environment being branched.
3491
+ *
3492
+ * Not just `resolveAppEnv`: branch slugs live in the same namespace but are
3493
+ * excluded from the environment list, so naming one lands on "no environment
3494
+ * …". Branching a branch is a real thing people will try (depth is capped at
3495
+ * one), and it deserves an error that says so.
3496
+ */
3497
+ async function resolveBranchParent(ctx, opts) {
3498
+ const app = await resolveApp(ctx, opts);
3499
+ const { environments } = await ctx.client.listEnvs(app.orgId, app.id);
3500
+ const env = environments.find((e) => e.slug === opts.from || e.id === opts.from);
3501
+ if (env) return {
3502
+ orgId: app.orgId,
3503
+ appId: app.id,
3504
+ appSlug: app.slug,
3505
+ envId: env.id,
3506
+ envSlug: env.slug
3507
+ };
3508
+ const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
3509
+ 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`);
3510
+ fail(`no environment "${opts.from}" in ${app.slug}`);
3511
+ }
3512
+ /**
3513
+ * Branch (ephemeral) configs: `seekrit branch create pr-142 --from dev`.
3514
+ *
3515
+ * A branch overlays its parent instead of copying it, so creating one encrypts
3516
+ * nothing — it mints a data key for the branch's own overrides and wraps that
3517
+ * key to whoever should read them. Everything the branch inherits stays where
3518
+ * it is, and stays live.
3519
+ */
3520
+ function registerBranchCommands(program) {
3521
+ const branch = program.command("branch").description("ephemeral per-PR / preview configs layered on an environment");
3522
+ 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) => {
3523
+ const ctx = buildContext();
3524
+ const parent = await resolveBranchParent(ctx, {
3525
+ org: options.org,
3526
+ app: options.app,
3527
+ from: options.from
3528
+ });
3529
+ const parsedTtl = parseBranchTtl(options.ttl);
3530
+ if (parsedTtl === null) fail(`invalid --ttl "${options.ttl}" (try 12h, 7d, 2w, or never)`);
3531
+ const ttlSeconds = Number.isFinite(parsedTtl) ? parsedTtl : null;
3532
+ const me = await kmsCallerIdentity(ctx);
3533
+ const dek = generateDek();
3534
+ const wrappedDek = await wrapDek(dek, me.publicKeyJwk);
3535
+ const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, parent.orgId, dek);
3536
+ const grants = [];
3537
+ if (options.share !== false) {
3538
+ const { grantees } = await ctx.client.listGrantees(parent.orgId, parent.envId);
3539
+ for (const grantee of grantees) {
3540
+ if (grantee.principalType === me.principalType && grantee.principalId === me.principalId) continue;
3541
+ grants.push({
3542
+ principalType: grantee.principalType,
3543
+ principalId: grantee.principalId,
3544
+ wrappedDek: await wrapDek(dek, grantee.publicKeyJwk)
3545
+ });
3546
+ }
3547
+ }
3548
+ const created = await ctx.client.createBranch(parent.orgId, parent.envId, {
3549
+ slug,
3550
+ name: options.name,
3551
+ ttlSeconds,
3552
+ wrappedDek,
3553
+ recoveryWrappedDek,
3554
+ grants
3555
+ });
3556
+ console.error(`created branch ${parent.appSlug}/${parent.envSlug}#${created.branch.slug} (${created.branch.id})`);
3557
+ console.error(created.branch.expiresAt ? `expires ${created.branch.expiresAt}` : "no expiry — delete it explicitly when the PR closes");
3558
+ if (grants.length > 0) console.error(`shared with ${grants.length} other reader(s)`);
3559
+ });
3560
+ 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) => {
3561
+ const ctx = buildContext();
3562
+ if (options.env) {
3563
+ const parent = await resolveAppEnv(ctx, options);
3564
+ const { branches } = await ctx.client.listBranches(parent.orgId, parent.envId);
3565
+ for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
3566
+ return;
3567
+ }
3568
+ const app = await resolveApp(ctx, options);
3569
+ const { branches } = await ctx.client.listAppBranches(app.orgId, app.id);
3570
+ for (const b of branches) console.log(`${b.slug}\t${b.id}\t${b.expiresAt ?? "never"}`);
3571
+ });
3572
+ 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) => {
3573
+ const ctx = buildContext();
3574
+ const app = await resolveApp(ctx, options);
3575
+ const target = await resolveBranch(ctx, app, slug);
3576
+ await ctx.client.deleteBranch(app.orgId, target.id);
3577
+ console.error(`deleted branch ${app.slug}#${target.slug}`);
3578
+ });
3579
+ }
3580
+ //#endregion
3581
+ //#region src/dotenv.ts
3582
+ /**
3583
+ * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
3584
+ * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
3585
+ * escapes; unquoted values drop trailing ` # comments`). Multiline values are
3586
+ * not supported — keep those in seekrit itself.
3587
+ */
3588
+ function parseDotenv(content) {
3589
+ const out = {};
3590
+ for (const raw of content.split(/\r?\n/)) {
3591
+ let line = raw.trim();
3592
+ if (!line || line.startsWith("#")) continue;
3593
+ if (line.startsWith("export ")) line = line.slice(7).trimStart();
3594
+ const eq = line.indexOf("=");
3595
+ if (eq === -1) continue;
3596
+ const key = line.slice(0, eq).trim();
3597
+ if (!key) continue;
3598
+ let value = line.slice(eq + 1).trim();
3599
+ const quote = value[0];
3600
+ if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
3601
+ value = value.slice(1, -1);
3602
+ if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
3603
+ } else {
3604
+ const comment = value.indexOf(" #");
3605
+ if (comment !== -1) value = value.slice(0, comment).trim();
3606
+ }
3607
+ out[key] = value;
3608
+ }
3609
+ return out;
3610
+ }
3611
+ //#endregion
3612
+ //#region src/format.ts
3613
+ function needsQuoting(value) {
3614
+ return /[\s"'`$\\#]/.test(value) || value === "";
3615
+ }
3616
+ function dotenvQuote(value) {
3617
+ if (!needsQuoting(value)) return value;
3618
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
3619
+ }
3620
+ function shellQuote(value) {
3621
+ return `'${value.replaceAll("'", `'\\''`)}'`;
3622
+ }
3623
+ function formatSecrets(values, format) {
3624
+ const names = Object.keys(values).sort();
3625
+ switch (format) {
3626
+ case "json": return JSON.stringify(values, names, 2);
3627
+ case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
3628
+ case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
3629
+ }
3630
+ }
3631
+ //#endregion
3632
+ //#region src/gcp.ts
3633
+ /**
3634
+ * `seekrit gcp` — temporary GCP credentials via IAM Credentials
3635
+ * `generateAccessToken` (Vault-style dynamic secrets, the tier-2 sibling of
3636
+ * `seekrit aws`).
3637
+ *
3638
+ * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
3639
+ * keypair on THIS machine and sends only the public key; GCP mints the token and
3640
+ * the broker returns it wrapped to that key, so the control plane only ever
3641
+ * relays ciphertext and only this machine can unwrap it. Registering a target
3642
+ * wraps the service-account key JSON to the broker's public key locally, so the
3643
+ * control plane never sees it either — the source service account needs only
3644
+ * `roles/iam.serviceAccountTokenCreator` on the target.
3645
+ */
3646
+ /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
3647
+ function parseTtlSeconds$5(input) {
3648
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
3649
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
3650
+ return Number(m[1]) * ({
3651
+ s: 1,
3652
+ m: 60,
3653
+ h: 3600,
3654
+ d: 86400
3655
+ }[m[2] || "s"] ?? 1);
3656
+ }
3657
+ /** Collect a repeatable flag (e.g. --scope) into a list. */
3658
+ function collectList$1(value, acc = []) {
3659
+ acc.push(value);
3660
+ return acc;
3661
+ }
3662
+ /**
3663
+ * The service-account key JSON the broker impersonates with. From --key-file or
3664
+ * GOOGLE_APPLICATION_CREDENTIALS. Never leaves this machine unwrapped — it is
3665
+ * wrapped to the broker key before upload.
3666
+ */
3667
+ function resolveServiceAccountKey(opts) {
3668
+ const path = opts.keyFile ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
3669
+ 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)");
3670
+ const raw = readFileSync(path, "utf8").trim();
3671
+ try {
3672
+ const parsed = JSON.parse(raw);
3673
+ 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)`);
3674
+ } catch {
3675
+ fail(`${path} is not valid JSON`);
3676
+ }
3677
+ return raw;
3678
+ }
3679
+ function registerGcpCommands(program) {
3680
+ const gcp = program.command("gcp").description("temporary GCP credentials (IAM generateAccessToken, zero-knowledge)");
3681
+ const target = gcp.command("target").description("manage GCP service-account targets");
3682
+ 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) => {
3683
+ const ctx = buildContext();
3684
+ const org = await resolveOrg(ctx, options.org);
3685
+ const config = {
3686
+ provider: "gcp",
3687
+ executor: "in_do",
3688
+ serviceAccount: options.serviceAccount,
3689
+ ...options.scope?.length ? { scopes: options.scope } : {},
3690
+ ...options.delegate?.length ? { delegates: options.delegate } : {},
3691
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
3692
+ };
3693
+ const keyJson = resolveServiceAccountKey(options);
3694
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
3695
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(keyJson), publicKeyJwk);
3696
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
3697
+ name: options.name,
3698
+ config,
3699
+ wrappedAdminSecret
3700
+ });
3701
+ console.error(`registered GCP target ${created.name} (${created.id})`);
3702
+ console.error("\nGrant the source SA the token-creator role, then `seekrit gcp lease`:\n");
3703
+ console.log(gcpSetupInstructions(config));
3704
+ });
3705
+ target.command("list").description("list GCP service-account targets").option("--org <slug>").action(async (options) => {
3706
+ const ctx = buildContext();
3707
+ const org = await resolveOrg(ctx, options.org);
3708
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
3709
+ for (const t of targets) {
3710
+ const cfg = t.config;
3711
+ if (cfg.provider !== "gcp") continue;
3712
+ console.log(`${t.id}\t${t.name}\t${cfg.serviceAccount}`);
3713
+ }
3714
+ });
3715
+ target.command("setup <targetId>").description("reprint the IAM setup for a GCP target").option("--org <slug>").action(async (targetId, options) => {
3716
+ const ctx = buildContext();
3717
+ const org = await resolveOrg(ctx, options.org);
3718
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
3719
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
3720
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
3721
+ const cfg = t.config;
3722
+ if (cfg.provider !== "gcp") fail("not a gcp target (see `seekrit aws`/`seekrit ssh`)");
3723
+ console.log(gcpSetupInstructions(cfg));
3724
+ });
3725
+ target.command("rm <targetId>").description("delete a GCP service-account target").option("--org <slug>").action(async (targetId, options) => {
3726
+ const ctx = buildContext();
3727
+ const org = await resolveOrg(ctx, options.org);
3728
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
3729
+ console.error(`deleted ${targetId}`);
3730
+ });
3731
+ 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) => {
3732
+ const ctx = buildContext();
3733
+ const org = await resolveOrg(ctx, options.org);
3734
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
3735
+ const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
3736
+ if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
3737
+ if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
3738
+ const ttlSeconds = parseTtlSeconds$5(options.ttl);
3739
+ if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
3740
+ if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
3741
+ const recipient = await generateGcpRecipientKeyPair();
3742
+ const { gcp: leased } = await ctx.client.mintLease(org.id, {
3743
+ provider: "gcp",
3744
+ targetId: t.id,
3745
+ recipientPublicKey: recipient.publicKeyJwk,
3746
+ ttlSeconds
3747
+ });
3748
+ const cred = await unwrapGcpCredential(leased.wrappedCredential, recipient.privateKeyJwk);
3749
+ console.error(`leased ${cred.serviceAccount} — expires ${cred.expiration}`);
3750
+ if (options.json) console.log(JSON.stringify(cred, null, 2));
3751
+ else {
3752
+ console.log(`export CLOUDSDK_AUTH_ACCESS_TOKEN=${cred.accessToken}`);
3753
+ console.log(`export GOOGLE_OAUTH_ACCESS_TOKEN=${cred.accessToken}`);
3754
+ }
3755
+ });
3756
+ gcp.command("leases").description("list GCP leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
3195
3757
  const ctx = buildContext();
3196
3758
  const org = await resolveOrg(ctx, options.org);
3197
- const key = await kmsResolveKey(ctx, org.id, options.key);
3198
- if (key.purpose !== "sign") fail(`${key.name} is a ${key.purpose} key`);
3199
- const { material, currentVersion } = await kmsRecoverMaterial(ctx, org.id, key.id);
3200
- const message = await readStdin();
3201
- const privateKey = await importSigningKey(material);
3202
- console.log(await signMessage(privateKey, {
3203
- keyId: key.id,
3204
- version: currentVersion
3205
- }, message));
3759
+ const { leases } = await ctx.client.listLeases(org.id);
3760
+ for (const l of leases) {
3761
+ if (l.provider !== "gcp") continue;
3762
+ console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
3763
+ }
3206
3764
  });
3207
- kms.command("verify").description("verify an sg1 signature over stdin (exit 0 = valid)").requiredOption("--key <name>", "key name or id").requiredOption("--signature <sg1>", "the signature blob").option("--org <slug>").action(async (options) => {
3765
+ 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) => {
3208
3766
  const ctx = buildContext();
3209
3767
  const org = await resolveOrg(ctx, options.org);
3210
- const key = await kmsResolveKey(ctx, org.id, options.key);
3211
- const ref = signatureKeyRef(options.signature);
3212
- const { versions } = await ctx.client.getKmsPublicKeys(org.id, key.id);
3213
- const pub = versions.find((v) => v.version === ref.version)?.publicKeyJwk;
3214
- if (!pub) fail(`no published public key for version ${ref.version}`);
3215
- const message = await readStdin();
3216
- if (await verifyMessage(await importVerifyingKey(pub), options.signature, message)) console.error("valid");
3217
- else {
3218
- console.error("INVALID");
3219
- process.exitCode = 1;
3220
- }
3768
+ await ctx.client.revokeLease(org.id, leaseId);
3769
+ console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
3221
3770
  });
3222
3771
  }
3223
3772
  //#endregion
@@ -3317,7 +3866,7 @@ function parseTtlSeconds$4(input) {
3317
3866
  }[m[2] || "s"] ?? 1);
3318
3867
  }
3319
3868
  /** Collect a repeatable option into an array. */
3320
- function collect$5(value, previous) {
3869
+ function collect$4(value, previous) {
3321
3870
  return [...previous, value];
3322
3871
  }
3323
3872
  /** Parse `readWrite@app` → { role, db } for a custom target. */
@@ -3342,7 +3891,7 @@ function resolveAdminUri(uri) {
3342
3891
  function registerMongoCommands(program) {
3343
3892
  const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
3344
3893
  const target = mongo.command("target").description("manage MongoDB targets");
3345
- 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) => {
3894
+ 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) => {
3346
3895
  const ctx = buildContext();
3347
3896
  const org = await resolveOrg(ctx, options.org);
3348
3897
  const adminUri = resolveAdminUri(options.uri);
@@ -3502,7 +4051,7 @@ function generateUserName$1(prefix = "tmp") {
3502
4051
  function registerMysqlCommands(program) {
3503
4052
  const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
3504
4053
  const target = mysql.command("target").description("manage provisioning targets");
3505
- 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) => {
4054
+ 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) => {
3506
4055
  const ctx = buildContext();
3507
4056
  const org = await resolveOrg(ctx, options.org);
3508
4057
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3603,7 +4152,7 @@ function registerMysqlCommands(program) {
3603
4152
  });
3604
4153
  }
3605
4154
  /** Collect a repeatable option into an array. */
3606
- function collect$4(value, acc) {
4155
+ function collect$3(value, acc) {
3607
4156
  acc.push(value);
3608
4157
  return acc;
3609
4158
  }
@@ -3640,7 +4189,7 @@ function generateRoleName(prefix = "tmp") {
3640
4189
  function registerPgCommands(program) {
3641
4190
  const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
3642
4191
  const target = pg.command("target").description("manage provisioning targets");
3643
- 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) => {
4192
+ 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) => {
3644
4193
  const ctx = buildContext();
3645
4194
  const org = await resolveOrg(ctx, options.org);
3646
4195
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3754,209 +4303,11 @@ function registerPgCommands(program) {
3754
4303
  });
3755
4304
  }
3756
4305
  /** Collect a repeatable option into an array. */
3757
- function collect$3(value, acc) {
4306
+ function collect$2(value, acc) {
3758
4307
  acc.push(value);
3759
4308
  return acc;
3760
4309
  }
3761
4310
  //#endregion
3762
- //#region src/recovery.ts
3763
- /** Collect a repeatable option into a list. */
3764
- function collect$2(value, acc = []) {
3765
- acc.push(value);
3766
- return acc;
3767
- }
3768
- /** Resolve a custodian reference: a `skt_…` token id, otherwise a member email. */
3769
- function resolveCustodian(ctx, orgId, ref) {
3770
- return ref.startsWith("skt_") ? kmsResolveRecipient(ctx, orgId, { token: ref }) : kmsResolveRecipient(ctx, orgId, { user: ref });
3771
- }
3772
- /**
3773
- * The env DEK additionally wrapped to the org recovery key, when recovery is
3774
- * enabled — so a newly created environment is recovery-protected from birth.
3775
- * Returns undefined when recovery is off (the env is backfilled by `recovery
3776
- * sync` later).
3777
- */
3778
- async function recoveryWrapForNewEnv(ctx, orgId, dek) {
3779
- let recoveryPublicKeyJwk;
3780
- try {
3781
- const { recovery } = await ctx.client.getRecovery(orgId);
3782
- recoveryPublicKeyJwk = recovery.enabled ? recovery.recoveryPublicKeyJwk : null;
3783
- } catch (e) {
3784
- if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) return void 0;
3785
- throw e;
3786
- }
3787
- if (!recoveryPublicKeyJwk) return void 0;
3788
- return wrapDek(dek, recoveryPublicKeyJwk);
3789
- }
3790
- /**
3791
- * Wrap every environment the caller can decrypt but that lacks a recovery grant,
3792
- * and upload the grants. Idempotent — safe to re-run and to run from several
3793
- * admins to complete coverage.
3794
- */
3795
- async function syncRecoveryGrants(ctx, orgId) {
3796
- const { recovery } = await ctx.client.getRecovery(orgId);
3797
- if (!recovery.enabled || !recovery.recoveryPublicKeyJwk) fail("recovery is not enabled");
3798
- const recoveryPublicKeyJwk = recovery.recoveryPublicKeyJwk;
3799
- const privateKey = await getPrivateKey(ctx);
3800
- const grants = [];
3801
- let skipped = 0;
3802
- for (const environmentId of recovery.coverage.unprotectedEnvIds) {
3803
- let wrappedDek;
3804
- try {
3805
- ({wrappedDek} = await ctx.client.getMyEnvKey(orgId, environmentId));
3806
- } catch (e) {
3807
- if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) {
3808
- skipped++;
3809
- continue;
3810
- }
3811
- throw e;
3812
- }
3813
- const dek = await unwrapDek(wrappedDek, privateKey);
3814
- grants.push({
3815
- environmentId,
3816
- wrappedDek: await wrapDek(dek, recoveryPublicKeyJwk)
3817
- });
3818
- }
3819
- if (grants.length > 0) await ctx.client.uploadRecoveryGrants(orgId, { grants });
3820
- return {
3821
- wrapped: grants.length,
3822
- skipped
3823
- };
3824
- }
3825
- /** Generate + split a fresh recovery key across the given custodians. */
3826
- async function buildRecoveryConfig(ctx, orgId, thresholdRaw, custodianRefs) {
3827
- const threshold = Number.parseInt(thresholdRaw, 10);
3828
- if (!Number.isInteger(threshold) || threshold < 1) fail("--threshold must be a positive integer");
3829
- if (custodianRefs.length === 0) fail("pass at least one --custodian <email|skt_id>");
3830
- if (threshold > custodianRefs.length) fail("--threshold cannot exceed the number of custodians");
3831
- const custodians = await Promise.all(custodianRefs.map((ref) => resolveCustodian(ctx, orgId, ref)));
3832
- const recovery = await generateRecoveryKey();
3833
- const shares = await splitRecoveryKey(recovery.privateKeyJwk, threshold, custodians);
3834
- return {
3835
- recoveryPublicKeyJwk: recovery.publicKeyJwk,
3836
- threshold,
3837
- shares: shares.map((s) => ({
3838
- principalType: s.principalType,
3839
- principalId: s.principalId,
3840
- shareIndex: s.shareIndex,
3841
- wrappedShare: s.wrappedShare
3842
- }))
3843
- };
3844
- }
3845
- function registerRecoveryCommands(program) {
3846
- const recovery = program.command("recovery").description("customer-controlled M-of-N recovery (zero-knowledge)");
3847
- recovery.command("status").description("show recovery configuration and environment coverage").option("--org <slug>").action(async (options) => {
3848
- const ctx = buildContext();
3849
- const org = await resolveOrg(ctx, options.org);
3850
- const { recovery: status } = await ctx.client.getRecovery(org.id);
3851
- if (!status.enabled) {
3852
- console.log("recovery: disabled");
3853
- return;
3854
- }
3855
- console.log(`recovery: enabled (${status.threshold}-of-${status.shareCount})`);
3856
- console.log(`coverage: ${status.coverage.protected}/${status.coverage.total} environments protected`);
3857
- console.log("custodians:");
3858
- for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
3859
- if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
3860
- });
3861
- 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) => {
3862
- const ctx = buildContext();
3863
- const org = await resolveOrg(ctx, options.org);
3864
- const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
3865
- await ctx.client.configureRecovery(org.id, {
3866
- ...config,
3867
- grants: []
3868
- });
3869
- console.error(`recovery enabled: ${config.threshold}-of-${config.shares.length}`);
3870
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
3871
- console.error(`recovery-protected ${wrapped} environment(s) you can decrypt`);
3872
- if (skipped > 0) console.error(`${skipped} environment(s) need another admin to run \`seekrit recovery sync\``);
3873
- });
3874
- recovery.command("sync").description("recovery-protect environments you can decrypt but that aren't yet covered").option("--org <slug>").action(async (options) => {
3875
- const ctx = buildContext();
3876
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
3877
- console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
3878
- });
3879
- 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) => {
3880
- const ctx = buildContext();
3881
- const org = await resolveOrg(ctx, options.org);
3882
- const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
3883
- await ctx.client.rotateRecovery(org.id, {
3884
- ...config,
3885
- grants: []
3886
- });
3887
- console.error(`recovery rotated: ${config.threshold}-of-${config.shares.length}`);
3888
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
3889
- console.error(`re-wrapped ${wrapped} environment(s) you can decrypt to the new recovery key`);
3890
- if (skipped > 0) console.error(`${skipped} environment(s) still need another admin to run \`seekrit recovery sync\``);
3891
- });
3892
- recovery.command("disable").description("disable recovery and remove all recovery grants").option("--org <slug>").action(async (options) => {
3893
- const ctx = buildContext();
3894
- const org = await resolveOrg(ctx, options.org);
3895
- await ctx.client.disableRecovery(org.id);
3896
- console.error("recovery disabled; recovery grants removed");
3897
- });
3898
- 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) => {
3899
- const ctx = buildContext();
3900
- const org = await resolveOrg(ctx, options.org);
3901
- const target = options.targetUser || options.targetToken ? await kmsResolveRecipient(ctx, org.id, {
3902
- user: options.targetUser,
3903
- token: options.targetToken
3904
- }) : await kmsCallerIdentity(ctx);
3905
- const { request } = await ctx.client.createRecoveryRequest(org.id, {
3906
- targetPublicKeyJwk: target.publicKeyJwk,
3907
- targetType: target.principalType,
3908
- targetId: target.principalId,
3909
- reason: options.reason
3910
- });
3911
- console.error(`recovery request ${request.id} created (needs ${request.threshold} custodians)`);
3912
- console.error(` custodians run: seekrit recovery approve ${request.id}`);
3913
- console.error(` then the target: seekrit recovery complete ${request.id}`);
3914
- });
3915
- recovery.command("approve").description("as a custodian, contribute your share to a recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3916
- const ctx = buildContext();
3917
- const org = await resolveOrg(ctx, options.org);
3918
- const { request } = await ctx.client.getRecoveryRequest(org.id, requestId);
3919
- const myShare = await ctx.client.getMyRecoveryShare(org.id);
3920
- const privateKey = await getPrivateKey(ctx);
3921
- const contributedShare = await rewrapRecoveryShare(await unwrapRecoveryShare(myShare.wrappedShare, privateKey), request.targetPublicKeyJwk);
3922
- const res = await ctx.client.contributeRecoveryShare(org.id, requestId, {
3923
- shareIndex: myShare.shareIndex,
3924
- contributedShare
3925
- });
3926
- console.error(`contributed share #${myShare.shareIndex}: ${res.contributed}/${res.threshold} collected${res.quorumReached ? " — quorum reached" : ""}`);
3927
- });
3928
- recovery.command("complete").description("as the recovery target, reconstruct the key and restore your access").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3929
- const ctx = buildContext();
3930
- const org = await resolveOrg(ctx, options.org);
3931
- const { request, contributions, quorumReached } = await ctx.client.getRecoveryRequest(org.id, requestId);
3932
- if (!quorumReached) fail(`only ${contributions.length}/${request.threshold} custodians have contributed`);
3933
- const me = await kmsCallerIdentity(ctx);
3934
- const targetPrivateKey = await getPrivateKey(ctx);
3935
- const recoveryPrivateKey = await combineRecoveryShares(await Promise.all(contributions.map((cont) => unwrapRecoveryShare(cont.contributedShare, targetPrivateKey))));
3936
- const { grants: recoveryEnvKeys } = await ctx.client.getRecoveryEnvKeys(org.id);
3937
- const restored = [];
3938
- for (const g of recoveryEnvKeys) {
3939
- const dek = await unwrapDek(g.wrappedDek, recoveryPrivateKey);
3940
- restored.push({
3941
- environmentId: g.environmentId,
3942
- wrappedDek: await wrapDek(dek, me.publicKeyJwk)
3943
- });
3944
- }
3945
- await ctx.client.completeRecoveryRequest(org.id, requestId, {
3946
- principalType: me.principalType,
3947
- principalId: me.principalId,
3948
- grants: restored
3949
- });
3950
- console.error(`recovery complete: restored access to ${restored.length} environment(s)`);
3951
- });
3952
- recovery.command("cancel").description("cancel an open recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3953
- const ctx = buildContext();
3954
- const org = await resolveOrg(ctx, options.org);
3955
- await ctx.client.cancelRecoveryRequest(org.id, requestId);
3956
- console.error(`recovery request ${requestId} canceled`);
3957
- });
3958
- }
3959
- //#endregion
3960
4311
  //#region src/redis.ts
3961
4312
  /**
3962
4313
  * `seekrit redis` — temporary Redis (6+) credentials (Vault-style dynamic
@@ -4095,11 +4446,41 @@ function collect$1(value, acc) {
4095
4446
  acc.push(value);
4096
4447
  return acc;
4097
4448
  }
4098
- /** Fetch + decrypt every secret in a single environment. */
4099
- async function fetchDecryptedSecrets(ctx, orgId, envId) {
4449
+ /**
4450
+ * Fetch + decrypt every secret in a single environment.
4451
+ *
4452
+ * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
4453
+ * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
4454
+ * scope here — a reference to a secret inherited from a composed group is left
4455
+ * literal, because the group layers aren't fetched. `materializeEnv` is the
4456
+ * fully-layered view.
4457
+ */
4458
+ async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
4100
4459
  const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
4101
4460
  const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
4102
- return Object.fromEntries(entries);
4461
+ return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
4462
+ }
4463
+ /**
4464
+ * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
4465
+ * the CLI's standard fatal exit — it is a config bug with no correct value to
4466
+ * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
4467
+ */
4468
+ function interpolateValues(values, enabled = true) {
4469
+ if (!enabled) return {
4470
+ values,
4471
+ interpolated: [],
4472
+ unresolvedRefs: []
4473
+ };
4474
+ try {
4475
+ const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
4476
+ return {
4477
+ values: expandedValues,
4478
+ interpolated: expanded,
4479
+ unresolvedRefs: unresolved
4480
+ };
4481
+ } catch (err) {
4482
+ return fail(err instanceof Error ? err.message : String(err));
4483
+ }
4103
4484
  }
4104
4485
  /**
4105
4486
  * Decrypt one historical version of a secret. Ciphertext is bound to
@@ -4143,10 +4524,14 @@ async function importSecrets(ctx, orgId, envId, entries) {
4143
4524
  * Each layer's DEK is unwrapped once with the principal's private key and its
4144
4525
  * ciphertext decrypted locally. `process.env` is NOT applied here — callers
4145
4526
  * that spawn a process layer it on top so the live shell always wins.
4527
+ *
4528
+ * `${OTHER_SECRET}` references are expanded last, against the merged set, so a
4529
+ * reference always resolves to whichever layer won the name.
4146
4530
  */
4147
4531
  async function materializeEnv(ctx, opts) {
4148
4532
  const query = {};
4149
4533
  if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
4534
+ if (opts.branch) query.branch = opts.branch;
4150
4535
  if (!isTokenAuth(ctx)) {
4151
4536
  if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
4152
4537
  query.env = opts.envId;
@@ -4157,17 +4542,21 @@ async function materializeEnv(ctx, opts) {
4157
4542
  const provenance = {};
4158
4543
  for (const layer of layers) {
4159
4544
  const dek = await unwrapDek(layer.wrappedDek, privateKey);
4160
- const label = layer.source === "group" ? `group:${layer.groupSlug}@${layer.slug}` : `app:${scope.appSlug}/${layer.slug}`;
4545
+ let label;
4546
+ if (layer.source === "group") label = `group:${layer.groupSlug}@${layer.slug}`;
4547
+ else if (layer.source === "branch") label = `branch:${scope.appSlug}#${layer.slug}`;
4548
+ else label = `app:${scope.appSlug}/${layer.slug}`;
4161
4549
  for (const secret of layer.secrets) {
4162
4550
  values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
4163
4551
  provenance[secret.name] = label;
4164
4552
  }
4165
4553
  }
4554
+ const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
4166
4555
  return {
4167
- values,
4556
+ ...interpolateValues(values, opts.interpolate !== false),
4168
4557
  provenance,
4169
4558
  scope,
4170
- loadedEnvFiles: overlayEnvFiles(values, provenance, opts.envFiles)
4559
+ loadedEnvFiles
4171
4560
  };
4172
4561
  }
4173
4562
  /**
@@ -4188,11 +4577,21 @@ function overlayEnvFiles(values, provenance, envFiles) {
4188
4577
  }
4189
4578
  return loaded;
4190
4579
  }
4191
- /** Print a name → source table to stderr (never the secret values). */
4192
- function printExplain(provenance) {
4580
+ /**
4581
+ * Print a name → source table to stderr (never the secret values). Names whose
4582
+ * value had references expanded are marked, and dangling references are called
4583
+ * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
4584
+ * deliberately passed through as literal text.
4585
+ */
4586
+ function printExplain(provenance, refs = {}) {
4587
+ const interpolated = new Set(refs.interpolated ?? []);
4193
4588
  const names = Object.keys(provenance).sort();
4194
4589
  const width = names.reduce((w, n) => Math.max(w, n.length), 0);
4195
- for (const name of names) process.stderr.write(`${name.padEnd(width)} ${provenance[name]}\n`);
4590
+ for (const name of names) {
4591
+ const marker = interpolated.has(name) ? " (interpolated)" : "";
4592
+ process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
4593
+ }
4594
+ if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
4196
4595
  }
4197
4596
  //#endregion
4198
4597
  //#region src/ssh.ts
@@ -4512,7 +4911,7 @@ function parseVersion(raw) {
4512
4911
  }
4513
4912
  /** Attach the environment-selection flags shared by every `secrets` command. */
4514
4913
  function withTarget(cmd) {
4515
- 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");
4914
+ 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");
4516
4915
  }
4517
4916
  const secrets = program.command("secrets").description("manage secrets in an application or group environment");
4518
4917
  withTarget(secrets.command("list").description("list secret names (no values)")).action(async (options) => {
@@ -4521,11 +4920,11 @@ withTarget(secrets.command("list").description("list secret names (no values)"))
4521
4920
  const { secrets: rows } = await ctx.client.listSecrets(orgId, envId);
4522
4921
  for (const row of rows) console.log(`${row.name}\tv${row.version}\t${row.updatedAt}`);
4523
4922
  });
4524
- withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--version <n>", "print an earlier version instead of the current one")).action(async (name, options) => {
4923
+ withTarget(secrets.command("get <name>").description("decrypt and print one secret value").option("--raw", "print the stored value without expanding ${OTHER_SECRET} references").option("--version <n>", "print an earlier version instead of the current one")).action(async (name, options) => {
4525
4924
  const ctx = buildContext();
4526
4925
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
4527
4926
  let value;
4528
- if (options.version === void 0) value = (await fetchDecryptedSecrets(ctx, orgId, envId))[name];
4927
+ if (options.version === void 0) value = (await fetchDecryptedSecrets(ctx, orgId, envId, { raw: options.raw }))[name];
4529
4928
  else value = await fetchDecryptedVersion(ctx, orgId, envId, name, parseVersion(options.version));
4530
4929
  if (value === void 0) fail(`no secret named ${name}`);
4531
4930
  process.stdout.write(value);
@@ -4590,8 +4989,10 @@ async function materialize(ctx, options) {
4590
4989
  if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, options)).envId;
4591
4990
  return materializeEnv(ctx, {
4592
4991
  envId,
4992
+ branch: options.branch ?? process.env.SEEKRIT_BRANCH,
4593
4993
  with: options.with,
4594
- envFiles: options.envFile ?? [".env"]
4994
+ envFiles: options.envFile ?? [".env"],
4995
+ interpolate: options.interpolate
4595
4996
  });
4596
4997
  }
4597
4998
  /**
@@ -4609,7 +5010,11 @@ async function materializeForRun(options) {
4609
5010
  await ensureM2mAdminToken(dotenvVars);
4610
5011
  const ctx = tryBuildContext(dotenvVars);
4611
5012
  if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
4612
- return await materialize(ctx, options);
5013
+ const branch = options.branch ?? process.env.SEEKRIT_BRANCH ?? dotenvVars.SEEKRIT_BRANCH;
5014
+ return await materialize(ctx, {
5015
+ ...options,
5016
+ branch
5017
+ });
4613
5018
  } catch (err) {
4614
5019
  const message = err instanceof Error ? err.message : String(err);
4615
5020
  console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
@@ -4617,7 +5022,7 @@ async function materializeForRun(options) {
4617
5022
  const provenance = {};
4618
5023
  overlayEnvFiles(values, provenance, envFiles);
4619
5024
  return {
4620
- values,
5025
+ ...interpolateValues(values, options.interpolate !== false),
4621
5026
  provenance
4622
5027
  };
4623
5028
  }
@@ -4732,13 +5137,16 @@ async function reapStragglers(pids, signal) {
4732
5137
  process.kill(pid, "SIGKILL");
4733
5138
  } catch {}
4734
5139
  }
4735
- 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)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
5140
+ 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) => {
4736
5141
  const [cmd, ...args] = commandParts;
4737
5142
  if (!cmd) fail("no command given");
4738
- const { values, provenance } = await materializeForRun(options);
5143
+ const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
4739
5144
  if (options.explain) {
4740
5145
  for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
4741
- printExplain(provenance);
5146
+ printExplain(provenance, {
5147
+ interpolated,
5148
+ unresolved: unresolvedRefs
5149
+ });
4742
5150
  }
4743
5151
  const posix = process.platform !== "win32";
4744
5152
  const child = spawn(cmd, args, {
@@ -4781,14 +5189,17 @@ program.command("run").description("run a command with decrypted secrets injecte
4781
5189
  });
4782
5190
  child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
4783
5191
  });
4784
- 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("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
5192
+ 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) => {
4785
5193
  if (![
4786
5194
  "dotenv",
4787
5195
  "json",
4788
5196
  "shell"
4789
5197
  ].includes(options.format)) fail("format must be dotenv, json, or shell");
4790
- const { values, provenance } = await materialize(buildContext(), options);
4791
- if (options.explain) printExplain(provenance);
5198
+ const { values, provenance, interpolated, unresolvedRefs } = await materialize(buildContext(), options);
5199
+ if (options.explain) printExplain(provenance, {
5200
+ interpolated,
5201
+ unresolved: unresolvedRefs
5202
+ });
4792
5203
  console.log(formatSecrets(values, options.format));
4793
5204
  });
4794
5205
  program.command("grant").description("give a member or service token access to an environment's key").option("--org <slug>").option("--app <slug>").option("--group <slug>", "grant a group environment instead of an app").requiredOption("--env <slug>").option("--user <email>", "grant to an org member by email").option("--token <tokenId>", "grant to a service token by id (skt_…)").action(async (options) => {
@@ -4898,6 +5309,7 @@ token.command("delete <tokenId>").description("permanently delete a revoked serv
4898
5309
  await ctx.client.deleteToken(orgRef.id, tokenId);
4899
5310
  console.error(`${tokenId} deleted`);
4900
5311
  });
5312
+ registerBranchCommands(program);
4901
5313
  registerPgCommands(program);
4902
5314
  registerMysqlCommands(program);
4903
5315
  registerRedisCommands(program);
@@ -4909,7 +5321,7 @@ registerMongoCommands(program);
4909
5321
  registerKmsCommands(program);
4910
5322
  registerRecoveryCommands(program);
4911
5323
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
4912
- const { runMcpServer } = await import("./mcp-t-sJ_SvE.js");
5324
+ const { runMcpServer } = await import("./mcp-DLplPOvz.js");
4913
5325
  await runMcpServer();
4914
5326
  });
4915
5327
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -4923,4 +5335,4 @@ program.parseAsync(argv).catch((err) => {
4923
5335
  fail(err instanceof Error ? err.message : String(err));
4924
5336
  });
4925
5337
  //#endregion
4926
- 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 };
5338
+ 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 };