@seekrit/cli 0.25.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",
@@ -144,6 +150,51 @@ const SUBSCRIPTION_STATUSES = [
144
150
  "paused"
145
151
  ];
146
152
  //#endregion
153
+ //#region ../../packages/core/src/branches.ts
154
+ /**
155
+ * Branch (ephemeral) environments — a per-PR/preview overlay on an existing
156
+ * application environment.
157
+ *
158
+ * A branch is an ordinary environment row with a parent and a TTL. It is an
159
+ * **overlay, not a copy**: resolve returns the parent's layers and then the
160
+ * branch's own on top, so a branch holds only the values that differ and
161
+ * tracks the parent live. Nothing is re-encrypted at creation — a secret's
162
+ * ciphertext is bound to `(environmentId, name)` as AAD, so copying blobs into
163
+ * a new environment could not decrypt anyway, and a snapshot would immediately
164
+ * drift from its base.
165
+ *
166
+ * Two rules keep the read path cheap and predictable, enforced here:
167
+ *
168
+ * - **Depth one.** A branch's parent must not itself be a branch, so resolve
169
+ * never recurses on the hot path.
170
+ * - **Application environments only.** Group environments are pulled in by
171
+ * composition (matched by slug) and have no single parent to overlay.
172
+ */
173
+ /** Longest life a branch may be given. Bounds sprawl even if nobody cleans up. */
174
+ const MAX_BRANCH_TTL_SECONDS = 720 * 60 * 60;
175
+ /**
176
+ * Parse a human TTL — `30m`, `12h`, `7d`, `2w`, or bare seconds — into seconds.
177
+ * Returns null for anything unparseable, so callers can report the input back.
178
+ * `never` / `none` mean "no expiry" and yield `Infinity`, which
179
+ * {@link planBranchCreate} rejects unless passed as an explicit `null`.
180
+ */
181
+ function parseBranchTtl(input) {
182
+ const raw = input.trim().toLowerCase();
183
+ if (raw === "never" || raw === "none") return Number.POSITIVE_INFINITY;
184
+ const match = /^(\d+)\s*(s|m|h|d|w)?$/.exec(raw);
185
+ if (!match) return null;
186
+ const value = Number(match[1]);
187
+ const multiplier = {
188
+ s: 1,
189
+ m: 60,
190
+ h: 3600,
191
+ d: 86400,
192
+ w: 604800
193
+ }[match[2] ?? "s"];
194
+ if (multiplier === void 0) return null;
195
+ return value * multiplier;
196
+ }
197
+ //#endregion
147
198
  //#region ../../packages/core/src/interpolate.ts
148
199
  /**
149
200
  * Secret references: `${OTHER_SECRET}` inside a secret value.
@@ -864,11 +915,22 @@ z.object({
864
915
  */
865
916
  encryptedPrivateKey: z.string().min(1)
866
917
  });
867
- z.object({
918
+ const grantEnvironmentKeySchema = z.object({
868
919
  principalType: principalTypeSchema,
869
920
  principalId: z.string().min(1),
870
921
  wrappedDek: z.string().min(1)
871
922
  });
923
+ z.object({
924
+ slug: slugSchema,
925
+ /** Display name; defaults to the slug. */
926
+ name: nameSchema.optional(),
927
+ ttlSeconds: z.number().int().min(60).max(MAX_BRANCH_TTL_SECONDS).nullish(),
928
+ /** The branch's own DEK, wrapped to the creator — generated client-side. */
929
+ wrappedDek: z.string().min(1),
930
+ recoveryWrappedDek: z.string().min(1).nullish(),
931
+ /** The same DEK wrapped to each of the parent's existing grant-holders. */
932
+ grants: z.array(grantEnvironmentKeySchema).max(500).default([])
933
+ });
872
934
  z.object({
873
935
  name: nameSchema,
874
936
  tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
@@ -2124,7 +2186,7 @@ function isServiceToken(value) {
2124
2186
  }
2125
2187
  //#endregion
2126
2188
  //#region package.json
2127
- var version = "0.25.0";
2189
+ var version = "0.26.0";
2128
2190
  //#endregion
2129
2191
  //#region ../../packages/api-client/src/index.ts
2130
2192
  var SeekritApiError = class extends Error {
@@ -2253,6 +2315,28 @@ var SeekritClient = class {
2253
2315
  deleteEnv(orgId, envId) {
2254
2316
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
2255
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
+ }
2256
2340
  listGroups(orgId) {
2257
2341
  return this.request("GET", `/v1/orgs/${orgId}/groups`);
2258
2342
  }
@@ -2288,6 +2372,7 @@ var SeekritClient = class {
2288
2372
  resolve(query = {}) {
2289
2373
  const params = new URLSearchParams();
2290
2374
  if (query.env) params.set("env", query.env);
2375
+ if (query.branch) params.set("branch", query.branch);
2291
2376
  for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
2292
2377
  const qs = params.size > 0 ? `?${params}` : "";
2293
2378
  return this.request("GET", `/v1/resolve${qs}`);
@@ -2681,12 +2766,14 @@ async function resolveOrg(ctx, orgSlug) {
2681
2766
  }
2682
2767
  /**
2683
2768
  * Resolve an environment to operate on — an application env (`--app --env`,
2684
- * 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`).
2685
2771
  */
2686
2772
  async function resolveEnvTarget(ctx, opts) {
2687
2773
  const org = await resolveOrg(ctx, opts.org);
2688
2774
  if (!opts.env) fail("specify --env");
2689
2775
  if (opts.group) {
2776
+ if (opts.branch) fail("--branch applies to application environments, not groups");
2690
2777
  const { groups } = await ctx.client.listGroups(org.id);
2691
2778
  const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
2692
2779
  if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
@@ -2699,34 +2786,59 @@ async function resolveEnvTarget(ctx, opts) {
2699
2786
  label: `${group.slug}@${env.slug}`
2700
2787
  };
2701
2788
  }
2702
- const appSlug = opts.app ?? findProjectConfig()?.app;
2703
- if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
2704
- const { apps } = await ctx.client.listApps(org.id);
2705
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
2706
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
2789
+ const app = await resolveApp(ctx, opts);
2707
2790
  const { environments } = await ctx.client.listEnvs(org.id, app.id);
2708
2791
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
2709
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
+ }
2710
2801
  return {
2711
2802
  orgId: org.id,
2712
2803
  envId: env.id,
2713
2804
  label: `${app.slug}/${env.slug}`
2714
2805
  };
2715
2806
  }
2716
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
2717
- async function resolveAppEnv(ctx, opts) {
2807
+ /** Resolve the target application from a flag or the committed config. */
2808
+ async function resolveApp(ctx, opts) {
2718
2809
  const org = await resolveOrg(ctx, opts.org);
2719
2810
  const appSlug = opts.app ?? findProjectConfig()?.app;
2720
2811
  if (!appSlug) fail("specify --app (or run `seekrit init`)");
2721
- if (!opts.env) fail("specify --env");
2722
2812
  const { apps } = await ctx.client.listApps(org.id);
2723
2813
  const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
2724
2814
  if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
2725
- 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);
2726
2838
  const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
2727
2839
  if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
2728
2840
  return {
2729
- orgId: org.id,
2841
+ orgId: app.orgId,
2730
2842
  appId: app.id,
2731
2843
  appSlug: app.slug,
2732
2844
  envId: env.id,
@@ -2888,198 +3000,6 @@ function registerAwsCommands(program) {
2888
3000
  });
2889
3001
  }
2890
3002
  //#endregion
2891
- //#region src/dotenv.ts
2892
- /**
2893
- * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
2894
- * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
2895
- * escapes; unquoted values drop trailing ` # comments`). Multiline values are
2896
- * not supported — keep those in seekrit itself.
2897
- */
2898
- function parseDotenv(content) {
2899
- const out = {};
2900
- for (const raw of content.split(/\r?\n/)) {
2901
- let line = raw.trim();
2902
- if (!line || line.startsWith("#")) continue;
2903
- if (line.startsWith("export ")) line = line.slice(7).trimStart();
2904
- const eq = line.indexOf("=");
2905
- if (eq === -1) continue;
2906
- const key = line.slice(0, eq).trim();
2907
- if (!key) continue;
2908
- let value = line.slice(eq + 1).trim();
2909
- const quote = value[0];
2910
- if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
2911
- value = value.slice(1, -1);
2912
- if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2913
- } else {
2914
- const comment = value.indexOf(" #");
2915
- if (comment !== -1) value = value.slice(0, comment).trim();
2916
- }
2917
- out[key] = value;
2918
- }
2919
- return out;
2920
- }
2921
- //#endregion
2922
- //#region src/format.ts
2923
- function needsQuoting(value) {
2924
- return /[\s"'`$\\#]/.test(value) || value === "";
2925
- }
2926
- function dotenvQuote(value) {
2927
- if (!needsQuoting(value)) return value;
2928
- return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
2929
- }
2930
- function shellQuote(value) {
2931
- return `'${value.replaceAll("'", `'\\''`)}'`;
2932
- }
2933
- function formatSecrets(values, format) {
2934
- const names = Object.keys(values).sort();
2935
- switch (format) {
2936
- case "json": return JSON.stringify(values, names, 2);
2937
- case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
2938
- case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
2939
- }
2940
- }
2941
- //#endregion
2942
- //#region src/gcp.ts
2943
- /**
2944
- * `seekrit gcp` — temporary GCP credentials via IAM Credentials
2945
- * `generateAccessToken` (Vault-style dynamic secrets, the tier-2 sibling of
2946
- * `seekrit aws`).
2947
- *
2948
- * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
2949
- * keypair on THIS machine and sends only the public key; GCP mints the token and
2950
- * the broker returns it wrapped to that key, so the control plane only ever
2951
- * relays ciphertext and only this machine can unwrap it. Registering a target
2952
- * wraps the service-account key JSON to the broker's public key locally, so the
2953
- * control plane never sees it either — the source service account needs only
2954
- * `roles/iam.serviceAccountTokenCreator` on the target.
2955
- */
2956
- /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
2957
- function parseTtlSeconds$5(input) {
2958
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
2959
- if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
2960
- return Number(m[1]) * ({
2961
- s: 1,
2962
- m: 60,
2963
- h: 3600,
2964
- d: 86400
2965
- }[m[2] || "s"] ?? 1);
2966
- }
2967
- /** Collect a repeatable flag (e.g. --scope) into a list. */
2968
- function collectList$1(value, acc = []) {
2969
- acc.push(value);
2970
- return acc;
2971
- }
2972
- /**
2973
- * The service-account key JSON the broker impersonates with. From --key-file or
2974
- * GOOGLE_APPLICATION_CREDENTIALS. Never leaves this machine unwrapped — it is
2975
- * wrapped to the broker key before upload.
2976
- */
2977
- function resolveServiceAccountKey(opts) {
2978
- const path = opts.keyFile ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
2979
- if (!path) fail("provide the source service-account key JSON via --key-file or GOOGLE_APPLICATION_CREDENTIALS (it needs roles/iam.serviceAccountTokenCreator on the target)");
2980
- const raw = readFileSync(path, "utf8").trim();
2981
- try {
2982
- const parsed = JSON.parse(raw);
2983
- if (typeof parsed.client_email !== "string" || typeof parsed.private_key !== "string") fail(`${path} is not a service-account key JSON (missing client_email/private_key)`);
2984
- } catch {
2985
- fail(`${path} is not valid JSON`);
2986
- }
2987
- return raw;
2988
- }
2989
- function registerGcpCommands(program) {
2990
- const gcp = program.command("gcp").description("temporary GCP credentials (IAM generateAccessToken, zero-knowledge)");
2991
- const target = gcp.command("target").description("manage GCP service-account targets");
2992
- target.command("add").description("register an impersonable service account to issue temporary tokens from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--service-account <email>", "the service account to impersonate, name@project.iam.gserviceaccount.com").option("--org <slug>").option("--scope <scope>", "OAuth scope to grant (repeatable; default cloud-platform)", collectList$1).option("--delegate <email>", "delegation-chain service account (repeatable)", collectList$1).option("--max-ttl <duration>", "clamp requested token lifetime, e.g. 1h").option("--key-file <path>", "source SA key JSON (else GOOGLE_APPLICATION_CREDENTIALS)").action(async (options) => {
2993
- const ctx = buildContext();
2994
- const org = await resolveOrg(ctx, options.org);
2995
- const config = {
2996
- provider: "gcp",
2997
- executor: "in_do",
2998
- serviceAccount: options.serviceAccount,
2999
- ...options.scope?.length ? { scopes: options.scope } : {},
3000
- ...options.delegate?.length ? { delegates: options.delegate } : {},
3001
- ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
3002
- };
3003
- const keyJson = resolveServiceAccountKey(options);
3004
- const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
3005
- const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(keyJson), publicKeyJwk);
3006
- const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
3007
- name: options.name,
3008
- config,
3009
- wrappedAdminSecret
3010
- });
3011
- console.error(`registered GCP target ${created.name} (${created.id})`);
3012
- console.error("\nGrant the source SA the token-creator role, then `seekrit gcp lease`:\n");
3013
- console.log(gcpSetupInstructions(config));
3014
- });
3015
- target.command("list").description("list GCP service-account targets").option("--org <slug>").action(async (options) => {
3016
- const ctx = buildContext();
3017
- const org = await resolveOrg(ctx, options.org);
3018
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3019
- for (const t of targets) {
3020
- const cfg = t.config;
3021
- if (cfg.provider !== "gcp") continue;
3022
- console.log(`${t.id}\t${t.name}\t${cfg.serviceAccount}`);
3023
- }
3024
- });
3025
- target.command("setup <targetId>").description("reprint the IAM setup for a GCP target").option("--org <slug>").action(async (targetId, options) => {
3026
- const ctx = buildContext();
3027
- const org = await resolveOrg(ctx, options.org);
3028
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3029
- const t = targets.find((x) => x.id === targetId || x.name === targetId);
3030
- if (!t) fail(`no target "${targetId}" in ${org.slug}`);
3031
- const cfg = t.config;
3032
- if (cfg.provider !== "gcp") fail("not a gcp target (see `seekrit aws`/`seekrit ssh`)");
3033
- console.log(gcpSetupInstructions(cfg));
3034
- });
3035
- target.command("rm <targetId>").description("delete a GCP service-account target").option("--org <slug>").action(async (targetId, options) => {
3036
- const ctx = buildContext();
3037
- const org = await resolveOrg(ctx, options.org);
3038
- await ctx.client.deleteLeaseTarget(org.id, targetId);
3039
- console.error(`deleted ${targetId}`);
3040
- });
3041
- gcp.command("lease <target>").description("mint a short-lived GCP access token; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "token lifetime, e.g. 15m, 1h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
3042
- const ctx = buildContext();
3043
- const org = await resolveOrg(ctx, options.org);
3044
- const { targets } = await ctx.client.listLeaseTargets(org.id);
3045
- const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
3046
- if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
3047
- if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
3048
- const ttlSeconds = parseTtlSeconds$5(options.ttl);
3049
- if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
3050
- if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
3051
- const recipient = await generateGcpRecipientKeyPair();
3052
- const { gcp: leased } = await ctx.client.mintLease(org.id, {
3053
- provider: "gcp",
3054
- targetId: t.id,
3055
- recipientPublicKey: recipient.publicKeyJwk,
3056
- ttlSeconds
3057
- });
3058
- const cred = await unwrapGcpCredential(leased.wrappedCredential, recipient.privateKeyJwk);
3059
- console.error(`leased ${cred.serviceAccount} — expires ${cred.expiration}`);
3060
- if (options.json) console.log(JSON.stringify(cred, null, 2));
3061
- else {
3062
- console.log(`export CLOUDSDK_AUTH_ACCESS_TOKEN=${cred.accessToken}`);
3063
- console.log(`export GOOGLE_OAUTH_ACCESS_TOKEN=${cred.accessToken}`);
3064
- }
3065
- });
3066
- gcp.command("leases").description("list GCP leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
3067
- const ctx = buildContext();
3068
- const org = await resolveOrg(ctx, options.org);
3069
- const { leases } = await ctx.client.listLeases(org.id);
3070
- for (const l of leases) {
3071
- if (l.provider !== "gcp") continue;
3072
- console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
3073
- }
3074
- });
3075
- gcp.command("revoke <leaseId>").description("mark a lease revoked in the ledger (tokens stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
3076
- const ctx = buildContext();
3077
- const org = await resolveOrg(ctx, options.org);
3078
- await ctx.client.revokeLease(org.id, leaseId);
3079
- console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
3080
- });
3081
- }
3082
- //#endregion
3083
3003
  //#region src/kms.ts
3084
3004
  /** Collect a repeatable option into a list. */
3085
3005
  function collect$6(value, acc = []) {
@@ -3367,12 +3287,495 @@ function registerKmsCommands(program) {
3367
3287
  });
3368
3288
  }
3369
3289
  //#endregion
3370
- //#region src/m2m.ts
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
+ }
3371
3300
  /**
3372
- * Resolve M2M client credentials from (in order) the process environment, a
3373
- * `.env` overlay (for `seekrit run`), then saved config. Both halves must come
3374
- * through for the credential to be usable.
3375
- */
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) => {
3757
+ const ctx = buildContext();
3758
+ const org = await resolveOrg(ctx, options.org);
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
+ }
3764
+ });
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) => {
3766
+ const ctx = buildContext();
3767
+ const org = await resolveOrg(ctx, options.org);
3768
+ await ctx.client.revokeLease(org.id, leaseId);
3769
+ console.error(`revoked ${leaseId} (issued tokens remain valid until they expire)`);
3770
+ });
3771
+ }
3772
+ //#endregion
3773
+ //#region src/m2m.ts
3774
+ /**
3775
+ * Resolve M2M client credentials from (in order) the process environment, a
3776
+ * `.env` overlay (for `seekrit run`), then saved config. Both halves must come
3777
+ * through for the credential to be usable.
3778
+ */
3376
3779
  function readM2mCreds(dotenvVars = {}) {
3377
3780
  const config = readGlobalConfig();
3378
3781
  const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
@@ -3463,7 +3866,7 @@ function parseTtlSeconds$4(input) {
3463
3866
  }[m[2] || "s"] ?? 1);
3464
3867
  }
3465
3868
  /** Collect a repeatable option into an array. */
3466
- function collect$5(value, previous) {
3869
+ function collect$4(value, previous) {
3467
3870
  return [...previous, value];
3468
3871
  }
3469
3872
  /** Parse `readWrite@app` → { role, db } for a custom target. */
@@ -3488,7 +3891,7 @@ function resolveAdminUri(uri) {
3488
3891
  function registerMongoCommands(program) {
3489
3892
  const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
3490
3893
  const target = mongo.command("target").description("manage MongoDB targets");
3491
- target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$5, []).option("--auth-source <db>", "authentication database (default admin)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--no-tls", "disable TLS to the cluster (TLS is on by default)").action(async (options) => {
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) => {
3492
3895
  const ctx = buildContext();
3493
3896
  const org = await resolveOrg(ctx, options.org);
3494
3897
  const adminUri = resolveAdminUri(options.uri);
@@ -3648,7 +4051,7 @@ function generateUserName$1(prefix = "tmp") {
3648
4051
  function registerMysqlCommands(program) {
3649
4052
  const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
3650
4053
  const target = mysql.command("target").description("manage provisioning targets");
3651
- target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$4, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$4, []).action(async (options) => {
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) => {
3652
4055
  const ctx = buildContext();
3653
4056
  const org = await resolveOrg(ctx, options.org);
3654
4057
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3749,7 +4152,7 @@ function registerMysqlCommands(program) {
3749
4152
  });
3750
4153
  }
3751
4154
  /** Collect a repeatable option into an array. */
3752
- function collect$4(value, acc) {
4155
+ function collect$3(value, acc) {
3753
4156
  acc.push(value);
3754
4157
  return acc;
3755
4158
  }
@@ -3786,7 +4189,7 @@ function generateRoleName(prefix = "tmp") {
3786
4189
  function registerPgCommands(program) {
3787
4190
  const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
3788
4191
  const target = pg.command("target").description("manage provisioning targets");
3789
- target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$3, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$3, []).action(async (options) => {
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) => {
3790
4193
  const ctx = buildContext();
3791
4194
  const org = await resolveOrg(ctx, options.org);
3792
4195
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3900,208 +4303,10 @@ function registerPgCommands(program) {
3900
4303
  });
3901
4304
  }
3902
4305
  /** Collect a repeatable option into an array. */
3903
- function collect$3(value, acc) {
3904
- acc.push(value);
3905
- return acc;
3906
- }
3907
- //#endregion
3908
- //#region src/recovery.ts
3909
- /** Collect a repeatable option into a list. */
3910
- function collect$2(value, acc = []) {
4306
+ function collect$2(value, acc) {
3911
4307
  acc.push(value);
3912
4308
  return acc;
3913
4309
  }
3914
- /** Resolve a custodian reference: a `skt_…` token id, otherwise a member email. */
3915
- function resolveCustodian(ctx, orgId, ref) {
3916
- return ref.startsWith("skt_") ? kmsResolveRecipient(ctx, orgId, { token: ref }) : kmsResolveRecipient(ctx, orgId, { user: ref });
3917
- }
3918
- /**
3919
- * The env DEK additionally wrapped to the org recovery key, when recovery is
3920
- * enabled — so a newly created environment is recovery-protected from birth.
3921
- * Returns undefined when recovery is off (the env is backfilled by `recovery
3922
- * sync` later).
3923
- */
3924
- async function recoveryWrapForNewEnv(ctx, orgId, dek) {
3925
- let recoveryPublicKeyJwk;
3926
- try {
3927
- const { recovery } = await ctx.client.getRecovery(orgId);
3928
- recoveryPublicKeyJwk = recovery.enabled ? recovery.recoveryPublicKeyJwk : null;
3929
- } catch (e) {
3930
- if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) return void 0;
3931
- throw e;
3932
- }
3933
- if (!recoveryPublicKeyJwk) return void 0;
3934
- return wrapDek(dek, recoveryPublicKeyJwk);
3935
- }
3936
- /**
3937
- * Wrap every environment the caller can decrypt but that lacks a recovery grant,
3938
- * and upload the grants. Idempotent — safe to re-run and to run from several
3939
- * admins to complete coverage.
3940
- */
3941
- async function syncRecoveryGrants(ctx, orgId) {
3942
- const { recovery } = await ctx.client.getRecovery(orgId);
3943
- if (!recovery.enabled || !recovery.recoveryPublicKeyJwk) fail("recovery is not enabled");
3944
- const recoveryPublicKeyJwk = recovery.recoveryPublicKeyJwk;
3945
- const privateKey = await getPrivateKey(ctx);
3946
- const grants = [];
3947
- let skipped = 0;
3948
- for (const environmentId of recovery.coverage.unprotectedEnvIds) {
3949
- let wrappedDek;
3950
- try {
3951
- ({wrappedDek} = await ctx.client.getMyEnvKey(orgId, environmentId));
3952
- } catch (e) {
3953
- if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) {
3954
- skipped++;
3955
- continue;
3956
- }
3957
- throw e;
3958
- }
3959
- const dek = await unwrapDek(wrappedDek, privateKey);
3960
- grants.push({
3961
- environmentId,
3962
- wrappedDek: await wrapDek(dek, recoveryPublicKeyJwk)
3963
- });
3964
- }
3965
- if (grants.length > 0) await ctx.client.uploadRecoveryGrants(orgId, { grants });
3966
- return {
3967
- wrapped: grants.length,
3968
- skipped
3969
- };
3970
- }
3971
- /** Generate + split a fresh recovery key across the given custodians. */
3972
- async function buildRecoveryConfig(ctx, orgId, thresholdRaw, custodianRefs) {
3973
- const threshold = Number.parseInt(thresholdRaw, 10);
3974
- if (!Number.isInteger(threshold) || threshold < 1) fail("--threshold must be a positive integer");
3975
- if (custodianRefs.length === 0) fail("pass at least one --custodian <email|skt_id>");
3976
- if (threshold > custodianRefs.length) fail("--threshold cannot exceed the number of custodians");
3977
- const custodians = await Promise.all(custodianRefs.map((ref) => resolveCustodian(ctx, orgId, ref)));
3978
- const recovery = await generateRecoveryKey();
3979
- const shares = await splitRecoveryKey(recovery.privateKeyJwk, threshold, custodians);
3980
- return {
3981
- recoveryPublicKeyJwk: recovery.publicKeyJwk,
3982
- threshold,
3983
- shares: shares.map((s) => ({
3984
- principalType: s.principalType,
3985
- principalId: s.principalId,
3986
- shareIndex: s.shareIndex,
3987
- wrappedShare: s.wrappedShare
3988
- }))
3989
- };
3990
- }
3991
- function registerRecoveryCommands(program) {
3992
- const recovery = program.command("recovery").description("customer-controlled M-of-N recovery (zero-knowledge)");
3993
- recovery.command("status").description("show recovery configuration and environment coverage").option("--org <slug>").action(async (options) => {
3994
- const ctx = buildContext();
3995
- const org = await resolveOrg(ctx, options.org);
3996
- const { recovery: status } = await ctx.client.getRecovery(org.id);
3997
- if (!status.enabled) {
3998
- console.log("recovery: disabled");
3999
- return;
4000
- }
4001
- console.log(`recovery: enabled (${status.threshold}-of-${status.shareCount})`);
4002
- console.log(`coverage: ${status.coverage.protected}/${status.coverage.total} environments protected`);
4003
- console.log("custodians:");
4004
- for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
4005
- if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
4006
- });
4007
- recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$2, []).option("--org <slug>").action(async (options) => {
4008
- const ctx = buildContext();
4009
- const org = await resolveOrg(ctx, options.org);
4010
- const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
4011
- await ctx.client.configureRecovery(org.id, {
4012
- ...config,
4013
- grants: []
4014
- });
4015
- console.error(`recovery enabled: ${config.threshold}-of-${config.shares.length}`);
4016
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
4017
- console.error(`recovery-protected ${wrapped} environment(s) you can decrypt`);
4018
- if (skipped > 0) console.error(`${skipped} environment(s) need another admin to run \`seekrit recovery sync\``);
4019
- });
4020
- recovery.command("sync").description("recovery-protect environments you can decrypt but that aren't yet covered").option("--org <slug>").action(async (options) => {
4021
- const ctx = buildContext();
4022
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
4023
- console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
4024
- });
4025
- recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$2, []).option("--org <slug>").action(async (options) => {
4026
- const ctx = buildContext();
4027
- const org = await resolveOrg(ctx, options.org);
4028
- const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
4029
- await ctx.client.rotateRecovery(org.id, {
4030
- ...config,
4031
- grants: []
4032
- });
4033
- console.error(`recovery rotated: ${config.threshold}-of-${config.shares.length}`);
4034
- const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
4035
- console.error(`re-wrapped ${wrapped} environment(s) you can decrypt to the new recovery key`);
4036
- if (skipped > 0) console.error(`${skipped} environment(s) still need another admin to run \`seekrit recovery sync\``);
4037
- });
4038
- recovery.command("disable").description("disable recovery and remove all recovery grants").option("--org <slug>").action(async (options) => {
4039
- const ctx = buildContext();
4040
- const org = await resolveOrg(ctx, options.org);
4041
- await ctx.client.disableRecovery(org.id);
4042
- console.error("recovery disabled; recovery grants removed");
4043
- });
4044
- recovery.command("request").description("start a recovery ceremony (defaults to recovering access for yourself)").option("--target-user <email>", "recover access for another member").option("--target-token <id>", "recover access for a service token").option("--reason <text>", "note recorded in the audit trail").option("--org <slug>").action(async (options) => {
4045
- const ctx = buildContext();
4046
- const org = await resolveOrg(ctx, options.org);
4047
- const target = options.targetUser || options.targetToken ? await kmsResolveRecipient(ctx, org.id, {
4048
- user: options.targetUser,
4049
- token: options.targetToken
4050
- }) : await kmsCallerIdentity(ctx);
4051
- const { request } = await ctx.client.createRecoveryRequest(org.id, {
4052
- targetPublicKeyJwk: target.publicKeyJwk,
4053
- targetType: target.principalType,
4054
- targetId: target.principalId,
4055
- reason: options.reason
4056
- });
4057
- console.error(`recovery request ${request.id} created (needs ${request.threshold} custodians)`);
4058
- console.error(` custodians run: seekrit recovery approve ${request.id}`);
4059
- console.error(` then the target: seekrit recovery complete ${request.id}`);
4060
- });
4061
- recovery.command("approve").description("as a custodian, contribute your share to a recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
4062
- const ctx = buildContext();
4063
- const org = await resolveOrg(ctx, options.org);
4064
- const { request } = await ctx.client.getRecoveryRequest(org.id, requestId);
4065
- const myShare = await ctx.client.getMyRecoveryShare(org.id);
4066
- const privateKey = await getPrivateKey(ctx);
4067
- const contributedShare = await rewrapRecoveryShare(await unwrapRecoveryShare(myShare.wrappedShare, privateKey), request.targetPublicKeyJwk);
4068
- const res = await ctx.client.contributeRecoveryShare(org.id, requestId, {
4069
- shareIndex: myShare.shareIndex,
4070
- contributedShare
4071
- });
4072
- console.error(`contributed share #${myShare.shareIndex}: ${res.contributed}/${res.threshold} collected${res.quorumReached ? " — quorum reached" : ""}`);
4073
- });
4074
- recovery.command("complete").description("as the recovery target, reconstruct the key and restore your access").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
4075
- const ctx = buildContext();
4076
- const org = await resolveOrg(ctx, options.org);
4077
- const { request, contributions, quorumReached } = await ctx.client.getRecoveryRequest(org.id, requestId);
4078
- if (!quorumReached) fail(`only ${contributions.length}/${request.threshold} custodians have contributed`);
4079
- const me = await kmsCallerIdentity(ctx);
4080
- const targetPrivateKey = await getPrivateKey(ctx);
4081
- const recoveryPrivateKey = await combineRecoveryShares(await Promise.all(contributions.map((cont) => unwrapRecoveryShare(cont.contributedShare, targetPrivateKey))));
4082
- const { grants: recoveryEnvKeys } = await ctx.client.getRecoveryEnvKeys(org.id);
4083
- const restored = [];
4084
- for (const g of recoveryEnvKeys) {
4085
- const dek = await unwrapDek(g.wrappedDek, recoveryPrivateKey);
4086
- restored.push({
4087
- environmentId: g.environmentId,
4088
- wrappedDek: await wrapDek(dek, me.publicKeyJwk)
4089
- });
4090
- }
4091
- await ctx.client.completeRecoveryRequest(org.id, requestId, {
4092
- principalType: me.principalType,
4093
- principalId: me.principalId,
4094
- grants: restored
4095
- });
4096
- console.error(`recovery complete: restored access to ${restored.length} environment(s)`);
4097
- });
4098
- recovery.command("cancel").description("cancel an open recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
4099
- const ctx = buildContext();
4100
- const org = await resolveOrg(ctx, options.org);
4101
- await ctx.client.cancelRecoveryRequest(org.id, requestId);
4102
- console.error(`recovery request ${requestId} canceled`);
4103
- });
4104
- }
4105
4310
  //#endregion
4106
4311
  //#region src/redis.ts
4107
4312
  /**
@@ -4326,6 +4531,7 @@ async function importSecrets(ctx, orgId, envId, entries) {
4326
4531
  async function materializeEnv(ctx, opts) {
4327
4532
  const query = {};
4328
4533
  if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
4534
+ if (opts.branch) query.branch = opts.branch;
4329
4535
  if (!isTokenAuth(ctx)) {
4330
4536
  if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
4331
4537
  query.env = opts.envId;
@@ -4336,7 +4542,10 @@ async function materializeEnv(ctx, opts) {
4336
4542
  const provenance = {};
4337
4543
  for (const layer of layers) {
4338
4544
  const dek = await unwrapDek(layer.wrappedDek, privateKey);
4339
- 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}`;
4340
4549
  for (const secret of layer.secrets) {
4341
4550
  values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
4342
4551
  provenance[secret.name] = label;
@@ -4702,7 +4911,7 @@ function parseVersion(raw) {
4702
4911
  }
4703
4912
  /** Attach the environment-selection flags shared by every `secrets` command. */
4704
4913
  function withTarget(cmd) {
4705
- return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").requiredOption("--env <slug>", "environment slug");
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");
4706
4915
  }
4707
4916
  const secrets = program.command("secrets").description("manage secrets in an application or group environment");
4708
4917
  withTarget(secrets.command("list").description("list secret names (no values)")).action(async (options) => {
@@ -4780,6 +4989,7 @@ async function materialize(ctx, options) {
4780
4989
  if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, options)).envId;
4781
4990
  return materializeEnv(ctx, {
4782
4991
  envId,
4992
+ branch: options.branch ?? process.env.SEEKRIT_BRANCH,
4783
4993
  with: options.with,
4784
4994
  envFiles: options.envFile ?? [".env"],
4785
4995
  interpolate: options.interpolate
@@ -4800,7 +5010,11 @@ async function materializeForRun(options) {
4800
5010
  await ensureM2mAdminToken(dotenvVars);
4801
5011
  const ctx = tryBuildContext(dotenvVars);
4802
5012
  if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
4803
- 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
+ });
4804
5018
  } catch (err) {
4805
5019
  const message = err instanceof Error ? err.message : String(err);
4806
5020
  console.error(`seekrit: continuing without seekrit-managed secrets: ${message}`);
@@ -4923,7 +5137,7 @@ async function reapStragglers(pids, signal) {
4923
5137
  process.kill(pid, "SIGKILL");
4924
5138
  } catch {}
4925
5139
  }
4926
- program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
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) => {
4927
5141
  const [cmd, ...args] = commandParts;
4928
5142
  if (!cmd) fail("no command given");
4929
5143
  const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
@@ -4975,7 +5189,7 @@ program.command("run").description("run a command with decrypted secrets injecte
4975
5189
  });
4976
5190
  child.on("error", (err) => fail(`failed to start ${cmd}: ${err.message}`));
4977
5191
  });
4978
- program.command("export").description("print decrypted secrets (dotenv, json, or shell)").option("--org <slug>").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--with <group=env>", "override one group’s slice", collectKv).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--format <format>", "dotenv | json | shell", "dotenv").action(async (options) => {
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) => {
4979
5193
  if (![
4980
5194
  "dotenv",
4981
5195
  "json",
@@ -5095,6 +5309,7 @@ token.command("delete <tokenId>").description("permanently delete a revoked serv
5095
5309
  await ctx.client.deleteToken(orgRef.id, tokenId);
5096
5310
  console.error(`${tokenId} deleted`);
5097
5311
  });
5312
+ registerBranchCommands(program);
5098
5313
  registerPgCommands(program);
5099
5314
  registerMysqlCommands(program);
5100
5315
  registerRedisCommands(program);
@@ -5106,7 +5321,7 @@ registerMongoCommands(program);
5106
5321
  registerKmsCommands(program);
5107
5322
  registerRecoveryCommands(program);
5108
5323
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
5109
- const { runMcpServer } = await import("./mcp-COWsshZZ.js");
5324
+ const { runMcpServer } = await import("./mcp-DLplPOvz.js");
5110
5325
  await runMcpServer();
5111
5326
  });
5112
5327
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -5120,4 +5335,4 @@ program.parseAsync(argv).catch((err) => {
5120
5335
  fail(err instanceof Error ? err.message : String(err));
5121
5336
  });
5122
5337
  //#endregion
5123
- export { generateMysqlCredential as A, generateSigningKeyMaterial as C, signatureKeyRef as D, signMessage as E, kmsEncrypt as F, wrapDek as I, generateDek as L, generateEncryptKeyMaterial as M, kmsBlobKeyRef as N, verifyMessage as O, kmsDecrypt as P, toBase64 as R, parseServiceToken as S, importVerifyingKey as T, setFailThrows as _, ensureM2mAdminToken as a, createServiceToken as b, kmsResolveKey as c, resolveEnvTarget as d, resolveGroup as f, tryBuildContext as g, isTokenAuth as h, materializeEnv as i, generateDataKey as j, generatePostgresCredential as k, kmsResolveRecipient as l, getDek as m, fetchDecryptedSecrets as n, kmsCallerIdentity as o, resolveOrg as p, fetchDecryptedVersion as r, kmsRecoverMaterial as s, encryptAndSetSecret as t, resolveAppEnv as u, writeProjectConfig as v, importSigningKey as w, isServiceToken as x, version as y };
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 };