@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/README.md +6 -1
- package/dist/index.js +933 -521
- package/dist/{mcp-t-sJ_SvE.js → mcp-DLplPOvz.js} +75 -4
- package/package.json +2 -2
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.
|
|
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`)
|
|
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
|
|
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
|
|
2571
|
-
async function
|
|
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
|
-
|
|
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:
|
|
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/
|
|
2746
|
-
/**
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
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
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
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
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
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
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
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
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
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
|
|
2844
|
-
const
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
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
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3211
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
/**
|
|
4099
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
4192
|
-
|
|
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)
|
|
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
|
-
|
|
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-
|
|
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 {
|
|
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 };
|