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