@seekrit/cli 0.43.0 → 0.45.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 +2791 -55
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,6 +8,11 @@ import { dirname, join, parse, resolve } from "node:path";
|
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { Writable } from "node:stream";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
+
/** Default lifetime of a published bundle (7 days), in seconds. */
|
|
12
|
+
const POLICY_DEFAULT_TTL_SECONDS = 10080 * 60;
|
|
13
|
+
/** Bounds on a bundle's lifetime: an hour at the short end, 90 days at the long. */
|
|
14
|
+
const POLICY_MIN_TTL_SECONDS = 3600;
|
|
15
|
+
const POLICY_MAX_TTL_SECONDS = 2160 * 60 * 60;
|
|
11
16
|
/** A bare hostname: no scheme, no port, no path, no wildcard. */
|
|
12
17
|
const policyHostSchema = z.string().trim().min(1).max(253).toLowerCase().refine((h) => !/[:/\s*]/.test(h), { message: "host must be a bare hostname (no scheme, port, path, or wildcard)" }).refine((h) => /^[a-z0-9.-]+$/.test(h), { message: "host contains invalid characters" });
|
|
13
18
|
const policyMethodSchema = z.string().trim().toUpperCase().regex(/^[A-Z]{3,10}$/, "method must be an HTTP method name");
|
|
@@ -17,7 +22,7 @@ const policyMethodSchema = z.string().trim().toUpperCase().regex(/^[A-Z]{3,10}$/
|
|
|
17
22
|
*/
|
|
18
23
|
const policyPathSchema = z.string().trim().min(1).max(512).startsWith("/", "path pattern must start with /").refine((p) => !p.includes("?"), { message: "path patterns match the path only, not the query" });
|
|
19
24
|
const policySecretNameSchema = z.string().trim().regex(/^[A-Za-z0-9_]+$/, "secret names are letters, digits, and underscores");
|
|
20
|
-
z.object({
|
|
25
|
+
const agentPolicyRuleSchema = z.object({
|
|
21
26
|
host: policyHostSchema,
|
|
22
27
|
methods: z.array(policyMethodSchema).max(16).default([]),
|
|
23
28
|
paths: z.array(policyPathSchema).max(64).default([]),
|
|
@@ -44,6 +49,88 @@ z.object({
|
|
|
44
49
|
path: z.string().trim().min(1).max(2048),
|
|
45
50
|
secret: policySecretNameSchema.optional()
|
|
46
51
|
});
|
|
52
|
+
/**
|
|
53
|
+
* Serialize a bundle to the exact bytes that get signed and transported.
|
|
54
|
+
*
|
|
55
|
+
* Explicit field order rather than a generic sorted-key walk: the field list is
|
|
56
|
+
* the format, and a reviewer should be able to read it here. Optional fields are
|
|
57
|
+
* omitted rather than emitted as `null`, and empty `methods`/`paths`/`allow`
|
|
58
|
+
* arrays are kept, since their emptiness is meaningful (any/any/none).
|
|
59
|
+
*/
|
|
60
|
+
function canonicalizeAgentPolicy(bundle) {
|
|
61
|
+
const rules = bundle.rules.map((rule) => {
|
|
62
|
+
const out = {
|
|
63
|
+
host: rule.host.trim().toLowerCase(),
|
|
64
|
+
methods: rule.methods.map((m) => m.trim().toUpperCase()).sort(),
|
|
65
|
+
paths: rule.paths.map((p) => p.trim()),
|
|
66
|
+
allow: [...rule.allow].sort()
|
|
67
|
+
};
|
|
68
|
+
if (rule.label?.trim()) out.label = rule.label.trim();
|
|
69
|
+
return out;
|
|
70
|
+
});
|
|
71
|
+
return JSON.stringify({
|
|
72
|
+
v: bundle.v,
|
|
73
|
+
org: bundle.org,
|
|
74
|
+
agent: bundle.agent,
|
|
75
|
+
agent_slug: bundle.agent_slug,
|
|
76
|
+
policy_version: bundle.policy_version,
|
|
77
|
+
issued_at: bundle.issued_at,
|
|
78
|
+
expires_at: bundle.expires_at,
|
|
79
|
+
rules,
|
|
80
|
+
signer: {
|
|
81
|
+
kid: bundle.signer.kid,
|
|
82
|
+
jwk: {
|
|
83
|
+
crv: bundle.signer.jwk.crv,
|
|
84
|
+
kty: bundle.signer.jwk.kty,
|
|
85
|
+
x: bundle.signer.jwk.x,
|
|
86
|
+
y: bundle.signer.jwk.y
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* RFC 7638 JWK thumbprint (SHA-256, base64url, no padding) — the identifier an
|
|
93
|
+
* operator pins in the proxy's TOML.
|
|
94
|
+
*/
|
|
95
|
+
async function policySignerThumbprint(jwk) {
|
|
96
|
+
const canonical = JSON.stringify({
|
|
97
|
+
crv: jwk.crv,
|
|
98
|
+
kty: jwk.kty,
|
|
99
|
+
x: jwk.x,
|
|
100
|
+
y: jwk.y
|
|
101
|
+
});
|
|
102
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8(canonical));
|
|
103
|
+
return base64url(new Uint8Array(digest));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Sign a draft bundle with the publishing admin's key, returning the `ap1.`
|
|
107
|
+
* envelope to hand to the API.
|
|
108
|
+
*
|
|
109
|
+
* `privateKey` must be an ECDSA P-256 key with the `sign` usage — in the
|
|
110
|
+
* dashboard that is the signed-in admin's existing principal key, re-imported
|
|
111
|
+
* for signing (`importPolicySigningKey` in `@seekrit/crypto`). No new key
|
|
112
|
+
* material is involved, which is what makes this shippable without a second
|
|
113
|
+
* passphrase-protected keypair to manage.
|
|
114
|
+
*/
|
|
115
|
+
async function signAgentPolicy(privateKey, signerJwk, draft) {
|
|
116
|
+
const kid = await policySignerThumbprint(signerJwk);
|
|
117
|
+
const bytes = utf8(canonicalizeAgentPolicy({
|
|
118
|
+
...draft,
|
|
119
|
+
signer: {
|
|
120
|
+
kid,
|
|
121
|
+
jwk: signerJwk
|
|
122
|
+
}
|
|
123
|
+
}));
|
|
124
|
+
const sig = new Uint8Array(await crypto.subtle.sign({
|
|
125
|
+
name: "ECDSA",
|
|
126
|
+
hash: "SHA-256"
|
|
127
|
+
}, privateKey, bytes));
|
|
128
|
+
return [
|
|
129
|
+
"ap1",
|
|
130
|
+
base64url(bytes),
|
|
131
|
+
base64url(sig)
|
|
132
|
+
].join(".");
|
|
133
|
+
}
|
|
47
134
|
/** A structurally invalid, unverifiable, or expired bundle. */
|
|
48
135
|
var AgentPolicyError = class extends Error {
|
|
49
136
|
constructor(message) {
|
|
@@ -72,9 +159,149 @@ function parseAgentPolicyUnverified(envelope) {
|
|
|
72
159
|
if (!Array.isArray(body.rules) || !body.signer?.jwk) throw new AgentPolicyError("policy bundle is missing rules or signer");
|
|
73
160
|
return body;
|
|
74
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Match a request path against a glob pattern: `*` matches within one segment,
|
|
164
|
+
* `**` matches any number of segments (including none, so `/v1/**` covers
|
|
165
|
+
* `/v1`). Case-sensitive, and the query string never participates.
|
|
166
|
+
*/
|
|
167
|
+
function matchPolicyPath(pattern, path) {
|
|
168
|
+
const bare = path.split("?")[0] ?? path;
|
|
169
|
+
return matchSegments(pattern.split("/"), bare.split("/"));
|
|
170
|
+
}
|
|
171
|
+
function matchSegments(pattern, segments) {
|
|
172
|
+
if (pattern.length === 0) return segments.length === 0;
|
|
173
|
+
const [head, ...rest] = pattern;
|
|
174
|
+
if (head === "**") {
|
|
175
|
+
for (let skip = 0; skip <= segments.length; skip++) if (matchSegments(rest, segments.slice(skip))) return true;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (segments.length === 0) return false;
|
|
179
|
+
return matchSegment(head, segments[0]) && matchSegments(rest, segments.slice(1));
|
|
180
|
+
}
|
|
181
|
+
function matchSegment(pattern, segment) {
|
|
182
|
+
if (!pattern.includes("*")) return pattern === segment;
|
|
183
|
+
const parts = pattern.split("*");
|
|
184
|
+
let rest = segment;
|
|
185
|
+
for (let i = 0; i < parts.length; i++) {
|
|
186
|
+
const part = parts[i];
|
|
187
|
+
if (part === "") continue;
|
|
188
|
+
if (i === 0) {
|
|
189
|
+
if (!rest.startsWith(part)) return false;
|
|
190
|
+
rest = rest.slice(part.length);
|
|
191
|
+
} else if (i === parts.length - 1) return rest.length >= part.length && rest.endsWith(part);
|
|
192
|
+
else {
|
|
193
|
+
const at = rest.indexOf(part);
|
|
194
|
+
if (at === -1) return false;
|
|
195
|
+
rest = rest.slice(at + part.length);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
function ruleCoversMethod(rule, method) {
|
|
201
|
+
if (rule.methods.length === 0) return true;
|
|
202
|
+
const wanted = method.trim().toUpperCase();
|
|
203
|
+
return rule.methods.some((m) => m.trim().toUpperCase() === wanted);
|
|
204
|
+
}
|
|
205
|
+
function ruleCoversPath(rule, path) {
|
|
206
|
+
if (rule.paths.length === 0) return true;
|
|
207
|
+
return rule.paths.some((p) => matchPolicyPath(p, path));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Decide a request against an ordered rule set, first match wins — the same
|
|
211
|
+
* evaluation `RuleSet::decide` performs in the proxy, including which rule
|
|
212
|
+
* decided and *why* a refusal happened. Naming the constraint matters: a
|
|
213
|
+
* default-deny policy fails in exactly the confusing direction.
|
|
214
|
+
*/
|
|
215
|
+
function evaluatePolicy(rules, query) {
|
|
216
|
+
const host = query.host.trim().toLowerCase();
|
|
217
|
+
let hostMatched = false;
|
|
218
|
+
let pathMatchedIndex = null;
|
|
219
|
+
for (let i = 0; i < rules.length; i++) {
|
|
220
|
+
const rule = rules[i];
|
|
221
|
+
if (rule.host.trim().toLowerCase() !== host) continue;
|
|
222
|
+
hostMatched = true;
|
|
223
|
+
const pathsOk = ruleCoversPath(rule, query.path);
|
|
224
|
+
if (pathsOk && ruleCoversMethod(rule, query.method)) {
|
|
225
|
+
if (query.secret !== void 0 && !rule.allow.includes(query.secret)) return {
|
|
226
|
+
decision: "secret_not_allowed",
|
|
227
|
+
ruleIndex: i
|
|
228
|
+
};
|
|
229
|
+
return {
|
|
230
|
+
decision: "allow",
|
|
231
|
+
ruleIndex: i
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (pathsOk && pathMatchedIndex === null) pathMatchedIndex = i;
|
|
235
|
+
}
|
|
236
|
+
if (pathMatchedIndex !== null) return {
|
|
237
|
+
decision: "method_not_allowed",
|
|
238
|
+
ruleIndex: pathMatchedIndex
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
decision: hostMatched ? "path_not_allowed" : "no_rule",
|
|
242
|
+
ruleIndex: null
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** A one-line, human-readable reason for a verdict — shared by UI and API. */
|
|
246
|
+
function describePolicyVerdict(verdict, rules) {
|
|
247
|
+
const rule = verdict.ruleIndex === null ? null : rules[verdict.ruleIndex];
|
|
248
|
+
switch (verdict.decision) {
|
|
249
|
+
case "allow": return `permitted by rule ${(verdict.ruleIndex ?? 0) + 1}${rule?.label ? ` (${rule.label})` : ""}`;
|
|
250
|
+
case "no_rule": return "no rule covers this host";
|
|
251
|
+
case "method_not_allowed": return `rule ${(verdict.ruleIndex ?? 0) + 1} covers this path but not this method`;
|
|
252
|
+
case "path_not_allowed": return "this host has rules, but none cover this path";
|
|
253
|
+
case "secret_not_allowed": return `rule ${(verdict.ruleIndex ?? 0) + 1} permits the request but not this secret`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Every host a rule set names — what a forward proxy would intercept. */
|
|
257
|
+
function policyHosts(rules) {
|
|
258
|
+
return [...new Set(rules.map((r) => r.host.trim().toLowerCase()))].sort();
|
|
259
|
+
}
|
|
260
|
+
function diffPolicyRules(before, after) {
|
|
261
|
+
const out = [];
|
|
262
|
+
const same = (a, b) => JSON.stringify(normalizeRule(a)) === JSON.stringify(normalizeRule(b));
|
|
263
|
+
for (let i = 0; i < Math.max(before.length, after.length); i++) {
|
|
264
|
+
const b = before[i];
|
|
265
|
+
const a = after[i];
|
|
266
|
+
if (b && a) out.push({
|
|
267
|
+
kind: same(b, a) ? "unchanged" : "changed",
|
|
268
|
+
index: i,
|
|
269
|
+
before: b,
|
|
270
|
+
after: a
|
|
271
|
+
});
|
|
272
|
+
else if (a) out.push({
|
|
273
|
+
kind: "added",
|
|
274
|
+
index: i,
|
|
275
|
+
after: a
|
|
276
|
+
});
|
|
277
|
+
else if (b) out.push({
|
|
278
|
+
kind: "removed",
|
|
279
|
+
index: i,
|
|
280
|
+
before: b
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
function normalizeRule(rule) {
|
|
286
|
+
return {
|
|
287
|
+
host: rule.host.trim().toLowerCase(),
|
|
288
|
+
methods: rule.methods.map((m) => m.toUpperCase()).sort(),
|
|
289
|
+
paths: [...rule.paths],
|
|
290
|
+
allow: [...rule.allow].sort(),
|
|
291
|
+
label: rule.label?.trim() || void 0
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function utf8(text) {
|
|
295
|
+
return new TextEncoder().encode(text);
|
|
296
|
+
}
|
|
75
297
|
function utf8Decode$1(bytes) {
|
|
76
298
|
return new TextDecoder().decode(bytes);
|
|
77
299
|
}
|
|
300
|
+
function base64url(bytes) {
|
|
301
|
+
let binary = "";
|
|
302
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
303
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
304
|
+
}
|
|
78
305
|
function fromBase64url(text) {
|
|
79
306
|
const padded = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
80
307
|
const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
@@ -82,6 +309,500 @@ function fromBase64url(text) {
|
|
|
82
309
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
83
310
|
return out;
|
|
84
311
|
}
|
|
312
|
+
/** One aggregated cell: a dimension tuple and how many times it happened. */
|
|
313
|
+
const activityEntrySchema = z.object({
|
|
314
|
+
host: policyHostSchema,
|
|
315
|
+
method: policyMethodSchema,
|
|
316
|
+
decision: z.enum([
|
|
317
|
+
"allow",
|
|
318
|
+
"no_rule",
|
|
319
|
+
"method_not_allowed",
|
|
320
|
+
"path_not_allowed",
|
|
321
|
+
"secret_not_allowed",
|
|
322
|
+
"unknown_secret",
|
|
323
|
+
"ratchet_withdrawn",
|
|
324
|
+
"policy_unavailable"
|
|
325
|
+
]),
|
|
326
|
+
/**
|
|
327
|
+
* Which published rule decided, when one did. Null for refusals that never
|
|
328
|
+
* reached a rule (`no_rule`, `policy_unavailable`) — the distinction matters to
|
|
329
|
+
* a review, because "rule 3 refused this" and "nothing covered this" call for
|
|
330
|
+
* opposite changes.
|
|
331
|
+
*/
|
|
332
|
+
ruleIndex: z.number().int().min(0).max(255).nullable(),
|
|
333
|
+
count: z.number().int().min(1).max(1e6),
|
|
334
|
+
/**
|
|
335
|
+
* Secret names actually injected, name → count. Only meaningful on `allow`.
|
|
336
|
+
* This is what lets a review say "rule 2 permits three secrets and the agent
|
|
337
|
+
* has only ever used one" — the most useful narrowing there is, and impossible
|
|
338
|
+
* to see from policy alone.
|
|
339
|
+
*/
|
|
340
|
+
secrets: z.record(policySecretNameSchema, z.number().int().min(1)).optional()
|
|
341
|
+
});
|
|
342
|
+
z.object({
|
|
343
|
+
/** Start of the window these counts cover (ISO 8601). */
|
|
344
|
+
windowStart: z.string().trim().min(20).max(40),
|
|
345
|
+
/** Policy version in force while they were collected, for the ledger. */
|
|
346
|
+
policyVersion: z.number().int().min(0).optional(),
|
|
347
|
+
/**
|
|
348
|
+
* Capped so one report cannot be unbounded work. A proxy with more distinct
|
|
349
|
+
* cells than this in a window has a policy far broader than a review can help
|
|
350
|
+
* with, and truncating loudly beats accepting anything.
|
|
351
|
+
*/
|
|
352
|
+
entries: z.array(activityEntrySchema).min(1).max(500)
|
|
353
|
+
});
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region ../../packages/core/src/agent-review.ts
|
|
356
|
+
function emptyStats() {
|
|
357
|
+
return {
|
|
358
|
+
allows: 0,
|
|
359
|
+
denials: 0,
|
|
360
|
+
methods: /* @__PURE__ */ new Set(),
|
|
361
|
+
secrets: /* @__PURE__ */ new Set()
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Compare an agent's live rules against what it actually did.
|
|
366
|
+
*
|
|
367
|
+
* Returns proposals ordered narrowing-first, because that is the order they
|
|
368
|
+
* should be read in: the safe changes, then the ones needing a judgment call.
|
|
369
|
+
*/
|
|
370
|
+
function reviewPolicy(input) {
|
|
371
|
+
const { rules, activity } = input;
|
|
372
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
373
|
+
/** Hosts refused with no rule to attribute it to → candidates for widening. */
|
|
374
|
+
const uncovered = /* @__PURE__ */ new Map();
|
|
375
|
+
for (const row of activity) {
|
|
376
|
+
if (row.ruleIndex === null) {
|
|
377
|
+
if (row.decision === "no_rule" || row.decision === "path_not_allowed") {
|
|
378
|
+
const entry = uncovered.get(row.host) ?? {
|
|
379
|
+
denials: 0,
|
|
380
|
+
methods: /* @__PURE__ */ new Set()
|
|
381
|
+
};
|
|
382
|
+
entry.denials += row.count;
|
|
383
|
+
entry.methods.add(row.method);
|
|
384
|
+
uncovered.set(row.host, entry);
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const stats = byRule.get(row.ruleIndex) ?? emptyStats();
|
|
389
|
+
if (row.decision === "allow") {
|
|
390
|
+
stats.allows += row.count;
|
|
391
|
+
stats.methods.add(row.method);
|
|
392
|
+
for (const name of Object.keys(row.secrets ?? {})) stats.secrets.add(name);
|
|
393
|
+
} else stats.denials += row.count;
|
|
394
|
+
byRule.set(row.ruleIndex, stats);
|
|
395
|
+
}
|
|
396
|
+
const narrowing = [];
|
|
397
|
+
const widening = [];
|
|
398
|
+
/**
|
|
399
|
+
* Did this agent demonstrably do anything in the window?
|
|
400
|
+
*
|
|
401
|
+
* This is what licenses a conclusion from *absence*. With permitted traffic on
|
|
402
|
+
* the record, a rule that never came up is unused. With none, the window says
|
|
403
|
+
* nothing about any rule, and every proposal below would be noise.
|
|
404
|
+
*/
|
|
405
|
+
const workedAtAll = [...byRule.values()].some((s) => s.allows > 0);
|
|
406
|
+
for (const [index, rule] of rules.entries()) {
|
|
407
|
+
const stats = byRule.get(index);
|
|
408
|
+
if (!stats) {
|
|
409
|
+
if (workedAtAll) narrowing.push({
|
|
410
|
+
kind: "remove_rule",
|
|
411
|
+
ruleIndex: index,
|
|
412
|
+
host: rule.host,
|
|
413
|
+
evidence: {
|
|
414
|
+
allows: 0,
|
|
415
|
+
denials: 0,
|
|
416
|
+
observations: 0
|
|
417
|
+
},
|
|
418
|
+
rationale: `rule ${index + 1} (${rule.host}) never matched a request, while other rules did`,
|
|
419
|
+
applicable: true
|
|
420
|
+
});
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const evidence = {
|
|
424
|
+
allows: stats.allows,
|
|
425
|
+
denials: stats.denials,
|
|
426
|
+
observations: stats.allows + stats.denials
|
|
427
|
+
};
|
|
428
|
+
if (stats.allows === 0) {
|
|
429
|
+
narrowing.push({
|
|
430
|
+
kind: "remove_rule",
|
|
431
|
+
ruleIndex: index,
|
|
432
|
+
host: rule.host,
|
|
433
|
+
evidence,
|
|
434
|
+
rationale: stats.denials > 0 ? `rule ${index + 1} (${rule.host}) authorized nothing in this window and refused ${stats.denials} — it may be misconfigured rather than unused` : `rule ${index + 1} (${rule.host}) authorized nothing in this window`,
|
|
435
|
+
applicable: true
|
|
436
|
+
});
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const unusedSecrets = rule.allow.filter((name) => !stats.secrets.has(name));
|
|
440
|
+
if (unusedSecrets.length > 0) narrowing.push({
|
|
441
|
+
kind: "remove_secret",
|
|
442
|
+
ruleIndex: index,
|
|
443
|
+
host: rule.host,
|
|
444
|
+
names: unusedSecrets,
|
|
445
|
+
evidence,
|
|
446
|
+
rationale: `rule ${index + 1} (${rule.host}) permits ${unusedSecrets.join(", ")}, never injected in ${stats.allows} permitted request(s)`,
|
|
447
|
+
applicable: true
|
|
448
|
+
});
|
|
449
|
+
const unusedMethods = rule.methods.filter((m) => !stats.methods.has(m.toUpperCase()));
|
|
450
|
+
if (rule.methods.length > 0 && unusedMethods.length > 0) narrowing.push({
|
|
451
|
+
kind: "narrow_methods",
|
|
452
|
+
ruleIndex: index,
|
|
453
|
+
host: rule.host,
|
|
454
|
+
names: unusedMethods,
|
|
455
|
+
evidence,
|
|
456
|
+
rationale: `rule ${index + 1} (${rule.host}) permits ${unusedMethods.join(", ")}, never used in ${stats.allows} permitted request(s)`,
|
|
457
|
+
applicable: true
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
for (const [host, entry] of uncovered) widening.push({
|
|
461
|
+
kind: "widen",
|
|
462
|
+
ruleIndex: null,
|
|
463
|
+
host,
|
|
464
|
+
names: [...entry.methods].sort(),
|
|
465
|
+
evidence: {
|
|
466
|
+
allows: 0,
|
|
467
|
+
denials: entry.denials,
|
|
468
|
+
observations: entry.denials
|
|
469
|
+
},
|
|
470
|
+
rationale: `${entry.denials} request(s) to ${host} matched no rule (${[...entry.methods].sort().join(", ")}) — the agent wants this and cannot have it`,
|
|
471
|
+
applicable: false
|
|
472
|
+
});
|
|
473
|
+
narrowing.sort((a, b) => (a.ruleIndex ?? 0) - (b.ruleIndex ?? 0));
|
|
474
|
+
widening.sort((a, b) => b.evidence.denials - a.evidence.denials);
|
|
475
|
+
return [...narrowing, ...widening];
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Apply the accepted narrowing proposals, returning a new rule list.
|
|
479
|
+
*
|
|
480
|
+
* Pure and total: the input rules are untouched, and anything not applicable is
|
|
481
|
+
* skipped rather than throwing, so a caller can pass the whole review back.
|
|
482
|
+
*
|
|
483
|
+
* Rule *positions* shift when one is removed, which matters because order decides
|
|
484
|
+
* (first match wins). Removals are therefore applied last, after the by-index
|
|
485
|
+
* edits, so an index in a proposal always refers to the policy the review was
|
|
486
|
+
* computed against.
|
|
487
|
+
*/
|
|
488
|
+
function applyProposals(rules, accepted) {
|
|
489
|
+
const applicable = accepted.filter((p) => p.applicable && p.ruleIndex !== null);
|
|
490
|
+
const next = rules.map((rule) => ({
|
|
491
|
+
...rule,
|
|
492
|
+
methods: [...rule.methods],
|
|
493
|
+
paths: [...rule.paths],
|
|
494
|
+
allow: [...rule.allow]
|
|
495
|
+
}));
|
|
496
|
+
for (const proposal of applicable) {
|
|
497
|
+
const index = proposal.ruleIndex;
|
|
498
|
+
const rule = next[index];
|
|
499
|
+
if (!rule) continue;
|
|
500
|
+
if (proposal.kind === "remove_secret") {
|
|
501
|
+
const drop = new Set(proposal.names ?? []);
|
|
502
|
+
rule.allow = rule.allow.filter((name) => !drop.has(name));
|
|
503
|
+
} else if (proposal.kind === "narrow_methods") {
|
|
504
|
+
const drop = new Set((proposal.names ?? []).map((m) => m.toUpperCase()));
|
|
505
|
+
rule.methods = rule.methods.filter((m) => !drop.has(m.toUpperCase()));
|
|
506
|
+
if (rule.methods.length === 0) rule.methods = [...rules[index]?.methods ?? []];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const removed = new Set(applicable.filter((p) => p.kind === "remove_rule").map((p) => p.ruleIndex));
|
|
510
|
+
return next.filter((_, index) => !removed.has(index));
|
|
511
|
+
}
|
|
512
|
+
/** How many of a review's proposals `applyProposals` would act on. */
|
|
513
|
+
function countApplicable(proposals) {
|
|
514
|
+
return proposals.filter((p) => p.applicable).length;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Twelve hours, matching `[control] max_ttl` in a proxy config. A task is meant
|
|
518
|
+
* to bound one run; something that needs longer wants a policy change, not a
|
|
519
|
+
* longer ticket.
|
|
520
|
+
*/
|
|
521
|
+
const TASK_MAX_TTL_SECONDS = 720 * 60;
|
|
522
|
+
/** An EC P-256 public JWK, for a sender-constraint proof key. */
|
|
523
|
+
const taskProofJwkSchema = z.object({
|
|
524
|
+
kty: z.literal("EC"),
|
|
525
|
+
crv: z.literal("P-256"),
|
|
526
|
+
x: z.string().min(1).max(128),
|
|
527
|
+
y: z.string().min(1).max(128)
|
|
528
|
+
});
|
|
529
|
+
z.object({
|
|
530
|
+
/**
|
|
531
|
+
* The public `skd_…` segment of the minted token. Sent because the API never
|
|
532
|
+
* sees the token at dispatch and still needs a readable handle for the audit
|
|
533
|
+
* row and for a revoke to name — the id half of a credential, without the
|
|
534
|
+
* secret half.
|
|
535
|
+
*/
|
|
536
|
+
taskRef: z.string().trim().regex(/^skd_[0-9A-Za-z]+$/, "taskRef must be the skd_… segment of the minted token"),
|
|
537
|
+
/**
|
|
538
|
+
* SHA-256 (base64url) of the token the dispatcher minted. The token itself
|
|
539
|
+
* never reaches this API on the dispatch path — only on introspection, where
|
|
540
|
+
* it is hashed and discarded.
|
|
541
|
+
*/
|
|
542
|
+
tokenHash: z.string().trim().min(16).max(128),
|
|
543
|
+
/**
|
|
544
|
+
* Secret names this run may use. Omit for "whatever the agent's policy
|
|
545
|
+
* allows" — mirroring `Session.scopes: Option<BTreeSet<String>>` in the proxy,
|
|
546
|
+
* so absent means unnarrowed in both places.
|
|
547
|
+
*/
|
|
548
|
+
scopes: z.array(policySecretNameSchema).max(64).optional(),
|
|
549
|
+
ttlSeconds: z.number().int().min(60).max(TASK_MAX_TTL_SECONDS).optional(),
|
|
550
|
+
/**
|
|
551
|
+
* What this run is for, for the audit row and the operator's task list. Free
|
|
552
|
+
* text, and **not** a security input: never put a secret value in it.
|
|
553
|
+
*/
|
|
554
|
+
label: z.string().trim().max(200).optional(),
|
|
555
|
+
/**
|
|
556
|
+
* Public half of a proof key the presenter holds, recorded as an RFC 7638
|
|
557
|
+
* thumbprint. See `AgentTaskSession.proofThumbprint` for what this does and —
|
|
558
|
+
* importantly — does not yet do.
|
|
559
|
+
*/
|
|
560
|
+
proofJwk: taskProofJwkSchema.optional()
|
|
561
|
+
});
|
|
562
|
+
z.object({
|
|
563
|
+
/** The presented token. In the body, never a URL — it is a credential. */
|
|
564
|
+
token: z.string().trim().min(8).max(512) });
|
|
565
|
+
//#endregion
|
|
566
|
+
//#region ../../packages/core/src/archive.ts
|
|
567
|
+
/**
|
|
568
|
+
* The **break-glass archive** format: one signed JSON file holding everything
|
|
569
|
+
* seekrit stores for an org, in the form seekrit stores it — ciphertext stays
|
|
570
|
+
* ciphertext. Its whole purpose is to be openable on a machine that has never
|
|
571
|
+
* heard of seekrit, so the format is plain JSON, self-describing, and versioned
|
|
572
|
+
* by name (`seekrit-archive/v1`); a breaking change ships a new format string
|
|
573
|
+
* rather than mutating this one, exactly like the `sc1.`/`wd1.` blob prefixes.
|
|
574
|
+
*
|
|
575
|
+
* Three parts:
|
|
576
|
+
* - `manifest` — what this archive is, and a SHA-256 digest per section.
|
|
577
|
+
* - `signature` — Ed25519 over the canonical manifest, or null when the
|
|
578
|
+
* producing deployment has no signing key configured.
|
|
579
|
+
* - `data` — the sections themselves.
|
|
580
|
+
*
|
|
581
|
+
* Integrity fields (`digest`, `signature.value`, `publicKey`, `keyId`) are
|
|
582
|
+
* lowercase hex. Every blob *inside* `data` keeps its native base64url form, so
|
|
583
|
+
* the one encoding rule to remember is "the archive's own bookkeeping is hex,
|
|
584
|
+
* seekrit's blobs are unchanged".
|
|
585
|
+
*
|
|
586
|
+
* See docs/break-glass-export.md for what is deliberately excluded and why.
|
|
587
|
+
*/
|
|
588
|
+
const ARCHIVE_FORMAT = "seekrit-archive/v1";
|
|
589
|
+
/**
|
|
590
|
+
* Section order is part of the format: `manifest.digest` covers the section
|
|
591
|
+
* headers in this order, so a verifier that re-serializes must produce the same
|
|
592
|
+
* list. Appending a section is backwards-compatible; reordering is not.
|
|
593
|
+
*/
|
|
594
|
+
const ARCHIVE_SECTIONS = [
|
|
595
|
+
"organization",
|
|
596
|
+
"users",
|
|
597
|
+
"memberships",
|
|
598
|
+
"invites",
|
|
599
|
+
"applications",
|
|
600
|
+
"groups",
|
|
601
|
+
"environments",
|
|
602
|
+
"environmentGroups",
|
|
603
|
+
"environmentKeys",
|
|
604
|
+
"secrets",
|
|
605
|
+
"secretVersions",
|
|
606
|
+
"serviceTokens",
|
|
607
|
+
"m2mClients",
|
|
608
|
+
"kmsKeys",
|
|
609
|
+
"kmsKeyVersions",
|
|
610
|
+
"kmsKeyGrants",
|
|
611
|
+
"recoveryConfig",
|
|
612
|
+
"recoveryShares",
|
|
613
|
+
"rotations",
|
|
614
|
+
"syncConnections",
|
|
615
|
+
"syncBindings",
|
|
616
|
+
"leaseTargets",
|
|
617
|
+
"agentIdentities",
|
|
618
|
+
"agentPolicies",
|
|
619
|
+
"auditLog",
|
|
620
|
+
"keyMaterial"
|
|
621
|
+
];
|
|
622
|
+
/**
|
|
623
|
+
* Deterministic JSON: object keys sorted recursively, no whitespace. A digest
|
|
624
|
+
* has to be reproducible by a verifier that parsed the file and re-serialized
|
|
625
|
+
* it, and parsing loses key order — so key order must not matter.
|
|
626
|
+
*
|
|
627
|
+
* Arrays keep their order (it is data). Every numeric field in the schema is an
|
|
628
|
+
* integer, so JSON's float formatting never comes into it. `undefined` members
|
|
629
|
+
* are dropped, matching `JSON.stringify`.
|
|
630
|
+
*/
|
|
631
|
+
function canonicalJson(value) {
|
|
632
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
633
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
634
|
+
return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
635
|
+
}
|
|
636
|
+
function bytesToHex(bytes) {
|
|
637
|
+
let out = "";
|
|
638
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
|
639
|
+
return out;
|
|
640
|
+
}
|
|
641
|
+
/** Throws on odd length or a non-hex character — a malformed field, not a mismatch. */
|
|
642
|
+
function hexToBytes(hex) {
|
|
643
|
+
if (hex.length % 2 !== 0) throw new Error("hex string has odd length");
|
|
644
|
+
const out = new Uint8Array(hex.length / 2);
|
|
645
|
+
for (let i = 0; i < out.length; i++) {
|
|
646
|
+
const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
647
|
+
if (Number.isNaN(byte)) throw new Error("hex string contains a non-hex character");
|
|
648
|
+
out[i] = byte;
|
|
649
|
+
}
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
/** `sha256:<hex>` over the UTF-8 bytes of `text`. */
|
|
653
|
+
async function sha256Hex(text) {
|
|
654
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
655
|
+
return `sha256:${bytesToHex(new Uint8Array(digest))}`;
|
|
656
|
+
}
|
|
657
|
+
/** Number a section header reports: array length, or 0/1 for the singleton sections. */
|
|
658
|
+
function sectionCount(value) {
|
|
659
|
+
if (value === null || value === void 0) return 0;
|
|
660
|
+
return Array.isArray(value) ? value.length : 1;
|
|
661
|
+
}
|
|
662
|
+
/** Digest one section's value. Absent singletons hash as `null`, not as omitted. */
|
|
663
|
+
function sectionDigest(value) {
|
|
664
|
+
return sha256Hex(canonicalJson(value ?? null));
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Digest over the section-header list. Covers each section's digest, count, and
|
|
668
|
+
* truncation flag, so trimming rows *and* fixing up their digest still breaks
|
|
669
|
+
* the manifest.
|
|
670
|
+
*/
|
|
671
|
+
function manifestDigest(sections) {
|
|
672
|
+
return sha256Hex(canonicalJson(sections));
|
|
673
|
+
}
|
|
674
|
+
/** The exact bytes the signature covers: the canonical manifest, digest included. */
|
|
675
|
+
function signingPayload(manifest) {
|
|
676
|
+
return new TextEncoder().encode(canonicalJson(manifest));
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Verify an archive's digests and signature. Pure and offline — WebCrypto only,
|
|
680
|
+
* no I/O, so it runs identically in the CLI, a browser page, and a test.
|
|
681
|
+
*
|
|
682
|
+
* Reports every problem it finds rather than throwing on the first, because the
|
|
683
|
+
* useful answer to "my archive won't verify" is *which part*.
|
|
684
|
+
*/
|
|
685
|
+
async function verifyArchive(archive, options = {}) {
|
|
686
|
+
const declared = new Map(archive.manifest.sections.map((s) => [s.name, s]));
|
|
687
|
+
const badSections = [];
|
|
688
|
+
const missingSections = [];
|
|
689
|
+
const truncatedSections = [];
|
|
690
|
+
const data = archive.data ?? {};
|
|
691
|
+
for (const header of archive.manifest.sections) {
|
|
692
|
+
if (header.truncated) truncatedSections.push(header.name);
|
|
693
|
+
if (!(header.name in data)) {
|
|
694
|
+
missingSections.push(header.name);
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const value = data[header.name];
|
|
698
|
+
if (sectionCount(value) !== header.count || await sectionDigest(value) !== header.digest) badSections.push(header.name);
|
|
699
|
+
}
|
|
700
|
+
const undeclaredSections = Object.keys(data).filter((key) => !declared.has(key));
|
|
701
|
+
const manifestDigestOk = await manifestDigest(archive.manifest.sections) === archive.manifest.digest;
|
|
702
|
+
let signature = "unsigned";
|
|
703
|
+
let signatureNote;
|
|
704
|
+
if (options.skipSignature) {
|
|
705
|
+
signature = "unverifiable";
|
|
706
|
+
signatureNote = "signature checking was skipped";
|
|
707
|
+
} else if (archive.signature) if (options.expectKeyId && options.expectKeyId !== archive.signature.keyId) {
|
|
708
|
+
signature = "invalid";
|
|
709
|
+
signatureNote = `signed by key ${archive.signature.keyId}, expected ${options.expectKeyId}`;
|
|
710
|
+
} else try {
|
|
711
|
+
signature = await verifySignature(archive.manifest, archive.signature) ? "valid" : "invalid";
|
|
712
|
+
} catch (err) {
|
|
713
|
+
signature = "unverifiable";
|
|
714
|
+
signatureNote = err instanceof Error ? err.message : String(err);
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
ok: badSections.length === 0 && missingSections.length === 0 && undeclaredSections.length === 0 && manifestDigestOk && signature === "valid",
|
|
718
|
+
badSections,
|
|
719
|
+
undeclaredSections,
|
|
720
|
+
missingSections,
|
|
721
|
+
manifestDigestOk,
|
|
722
|
+
signature,
|
|
723
|
+
signatureNote,
|
|
724
|
+
truncatedSections
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Ed25519 verify over the canonical manifest. Plain WebCrypto: raw public-key
|
|
729
|
+
* import plus `verify` is supported in Workers, Node ≥18, and current browsers,
|
|
730
|
+
* so no verifier anywhere needs a dependency. Throws (rather than returning
|
|
731
|
+
* false) when the runtime has no Ed25519 at all, so "old browser" is reported
|
|
732
|
+
* as unverifiable instead of as a bad signature.
|
|
733
|
+
*/
|
|
734
|
+
async function verifySignature(manifest, signature) {
|
|
735
|
+
if (signature.algorithm !== "ed25519") throw new Error(`unsupported signature algorithm: ${signature.algorithm}`);
|
|
736
|
+
const publicKey = await crypto.subtle.importKey("raw", hexToBytes(signature.publicKey), { name: "Ed25519" }, false, ["verify"]);
|
|
737
|
+
return crypto.subtle.verify({ name: "Ed25519" }, publicKey, hexToBytes(signature.value), signingPayload(manifest));
|
|
738
|
+
}
|
|
739
|
+
const sectionHeaderSchema = z.object({
|
|
740
|
+
name: z.enum(ARCHIVE_SECTIONS),
|
|
741
|
+
count: z.number().int().min(0),
|
|
742
|
+
truncated: z.boolean(),
|
|
743
|
+
digest: z.string().regex(/^sha256:[0-9a-f]{64}$/)
|
|
744
|
+
});
|
|
745
|
+
const manifestSchema = z.object({
|
|
746
|
+
archiveId: z.string().min(1),
|
|
747
|
+
createdAt: z.string().min(1),
|
|
748
|
+
org: z.object({
|
|
749
|
+
id: z.string(),
|
|
750
|
+
slug: z.string(),
|
|
751
|
+
name: z.string()
|
|
752
|
+
}),
|
|
753
|
+
producer: z.object({
|
|
754
|
+
service: z.string(),
|
|
755
|
+
environment: z.string(),
|
|
756
|
+
formatVersion: z.string()
|
|
757
|
+
}),
|
|
758
|
+
requestedBy: z.object({
|
|
759
|
+
actorType: z.string(),
|
|
760
|
+
actorId: z.string(),
|
|
761
|
+
label: z.string().nullable()
|
|
762
|
+
}),
|
|
763
|
+
options: z.object({
|
|
764
|
+
includeVersions: z.boolean(),
|
|
765
|
+
includeAudit: z.boolean(),
|
|
766
|
+
auditLimit: z.number().int().min(0)
|
|
767
|
+
}),
|
|
768
|
+
sections: z.array(sectionHeaderSchema),
|
|
769
|
+
digest: z.string().regex(/^sha256:[0-9a-f]{64}$/)
|
|
770
|
+
});
|
|
771
|
+
const signatureSchema = z.object({
|
|
772
|
+
algorithm: z.literal("ed25519"),
|
|
773
|
+
publicKey: z.string().regex(/^[0-9a-f]{64}$/),
|
|
774
|
+
keyId: z.string().regex(/^[0-9a-f]{16}$/),
|
|
775
|
+
value: z.string().regex(/^[0-9a-f]{128}$/)
|
|
776
|
+
});
|
|
777
|
+
/**
|
|
778
|
+
* Envelope schema. `data` is validated only as an object: a v1 verifier must
|
|
779
|
+
* still be able to check and decrypt an archive from a later producer that
|
|
780
|
+
* appended a section, and unknown sections are caught by
|
|
781
|
+
* `verifyArchive`'s undeclared/declared cross-check rather than by rejecting
|
|
782
|
+
* the file outright.
|
|
783
|
+
*/
|
|
784
|
+
const archiveSchema = z.object({
|
|
785
|
+
format: z.literal(ARCHIVE_FORMAT),
|
|
786
|
+
manifest: manifestSchema,
|
|
787
|
+
signature: signatureSchema.nullable(),
|
|
788
|
+
data: z.record(z.string(), z.unknown())
|
|
789
|
+
});
|
|
790
|
+
/** Parse untrusted JSON text into an archive. Throws with a readable message. */
|
|
791
|
+
function parseArchive(text) {
|
|
792
|
+
let json;
|
|
793
|
+
try {
|
|
794
|
+
json = JSON.parse(text);
|
|
795
|
+
} catch {
|
|
796
|
+
throw new Error("not valid JSON — is this a seekrit archive?");
|
|
797
|
+
}
|
|
798
|
+
const parsed = archiveSchema.safeParse(json);
|
|
799
|
+
if (!parsed.success) {
|
|
800
|
+
const first = parsed.error.issues[0];
|
|
801
|
+
const where = first?.path.join(".") || "archive";
|
|
802
|
+
throw new Error(`not a ${ARCHIVE_FORMAT} archive: ${where}: ${first?.message ?? "invalid"}`);
|
|
803
|
+
}
|
|
804
|
+
return parsed.data;
|
|
805
|
+
}
|
|
85
806
|
/** All catalog keys as a runtime array (for iteration / zod enums). */
|
|
86
807
|
const ENTITLEMENT_KEYS = Object.keys({
|
|
87
808
|
"feature.kms": {
|
|
@@ -1183,6 +1904,7 @@ const AUDIT_ACTIONS = [
|
|
|
1183
1904
|
"org.member_invited",
|
|
1184
1905
|
"org.invite_revoked",
|
|
1185
1906
|
"org.mfa_policy_changed",
|
|
1907
|
+
"org.exported",
|
|
1186
1908
|
"user.keys_updated",
|
|
1187
1909
|
"user.notification_prefs_updated",
|
|
1188
1910
|
"app.created",
|
|
@@ -1267,7 +1989,9 @@ const AUDIT_ACTIONS = [
|
|
|
1267
1989
|
"agent.updated",
|
|
1268
1990
|
"agent.deleted",
|
|
1269
1991
|
"agent.policy_published",
|
|
1270
|
-
"agent.policy_rolled_back"
|
|
1992
|
+
"agent.policy_rolled_back",
|
|
1993
|
+
"agent.task_dispatched",
|
|
1994
|
+
"agent.task_revoked"
|
|
1271
1995
|
];
|
|
1272
1996
|
/**
|
|
1273
1997
|
* Transactional notification emails seekrit can send. Each id is one
|
|
@@ -1366,6 +2090,13 @@ const inviteRoleSchema = z.enum(["admin", "member"]);
|
|
|
1366
2090
|
const principalTypeSchema = z.enum(["user", "service_token"]);
|
|
1367
2091
|
/** Org-level capability a service token can hold (never `owner`). */
|
|
1368
2092
|
const serviceTokenRoleSchema = z.enum(["admin", "member"]);
|
|
2093
|
+
z.object({
|
|
2094
|
+
/** Include the full append-only ciphertext history of every secret. */
|
|
2095
|
+
includeVersions: z.boolean().optional(),
|
|
2096
|
+
includeAudit: z.boolean().optional(),
|
|
2097
|
+
/** Newest audit rows to keep. Exceeding the server cap is a 400, not a silent trim. */
|
|
2098
|
+
auditLimit: z.number().int().min(0).optional()
|
|
2099
|
+
});
|
|
1369
2100
|
z.object({
|
|
1370
2101
|
name: nameSchema,
|
|
1371
2102
|
slug: slugSchema
|
|
@@ -3492,6 +4223,76 @@ async function decryptPrivateKey(passphrase, blob) {
|
|
|
3492
4223
|
}
|
|
3493
4224
|
}
|
|
3494
4225
|
//#endregion
|
|
4226
|
+
//#region ../../packages/crypto/src/policy-key.ts
|
|
4227
|
+
/**
|
|
4228
|
+
* Signing with a principal's **existing** keypair, for agent access policy.
|
|
4229
|
+
*
|
|
4230
|
+
* Policy bundles are signed in the browser so the API can serve a blob it cannot
|
|
4231
|
+
* forge (`docs/agent-access-governance.md` §1). Every user already has a P-256
|
|
4232
|
+
* keypair whose private half is passphrase-encrypted and opaque to the server
|
|
4233
|
+
* (`users.public_key_jwk`), so this feature needs **no new key material**: no
|
|
4234
|
+
* second passphrase, no wrapping, no schema, and nothing extra for an admin to
|
|
4235
|
+
* lose. That was a deliberate condition of the design.
|
|
4236
|
+
*
|
|
4237
|
+
* The catch is that WebCrypto keys are algorithm-bound. A principal key is
|
|
4238
|
+
* imported for ECDH (`deriveBits`) and cannot sign, even though the underlying
|
|
4239
|
+
* curve is the same one ECDSA uses. So the JWK is re-imported here with the
|
|
4240
|
+
* algorithm hints stripped — the same private scalar, presented as an ECDSA key.
|
|
4241
|
+
*
|
|
4242
|
+
* **The tradeoff, stated plainly:** this reuses one key for two algorithms,
|
|
4243
|
+
* which key-management hygiene (NIST SP 800-57 §5.2) advises against. We accept
|
|
4244
|
+
* it because the alternative — a second keypair per admin — is the kind of
|
|
4245
|
+
* ceremony that gets skipped, and because both uses stay inside the same trust
|
|
4246
|
+
* boundary: the key already authorizes reading every secret the admin can read,
|
|
4247
|
+
* so a signature capability adds no reach an attacker holding it wouldn't have.
|
|
4248
|
+
* If a future version wants separation, the clean path is a managed KMS `sign`
|
|
4249
|
+
* key granted to publishers — the thumbprint pinning in the proxy works
|
|
4250
|
+
* unchanged, which is why the format carries the key rather than a user id.
|
|
4251
|
+
*/
|
|
4252
|
+
const ECDSA_PARAMS$1 = {
|
|
4253
|
+
name: "ECDSA",
|
|
4254
|
+
namedCurve: "P-256"
|
|
4255
|
+
};
|
|
4256
|
+
/**
|
|
4257
|
+
* Re-import a principal's private key JWK as an ECDSA signing key.
|
|
4258
|
+
*
|
|
4259
|
+
* `key_ops`, `alg`, and `use` are dropped: they say "ECDH" on a principal key,
|
|
4260
|
+
* and WebCrypto refuses an import whose declared operations don't include the
|
|
4261
|
+
* requested usage. Everything that determines the key — `crv`, `d`, `x`, `y` —
|
|
4262
|
+
* is passed through untouched.
|
|
4263
|
+
*/
|
|
4264
|
+
async function importPolicySigningKey(privateKeyJwk) {
|
|
4265
|
+
const jwk = JSON.parse(privateKeyJwk);
|
|
4266
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256") throw new SeekritCryptoError("MALFORMED_BLOB", "policy signing needs an EC P-256 principal key");
|
|
4267
|
+
if (!jwk.d) throw new SeekritCryptoError("MALFORMED_BLOB", "policy signing needs the private half of the key");
|
|
4268
|
+
const { kty, crv, d, x, y } = jwk;
|
|
4269
|
+
return crypto.subtle.importKey("jwk", {
|
|
4270
|
+
kty,
|
|
4271
|
+
crv,
|
|
4272
|
+
d,
|
|
4273
|
+
x,
|
|
4274
|
+
y
|
|
4275
|
+
}, ECDSA_PARAMS$1, false, ["sign"]);
|
|
4276
|
+
}
|
|
4277
|
+
/**
|
|
4278
|
+
* Trim a principal's public key JWK to the members a policy bundle carries.
|
|
4279
|
+
*
|
|
4280
|
+
* The bundle names its signer by thumbprint, which is computed over exactly
|
|
4281
|
+
* these four members — so anything else in the stored JWK (`key_ops`, `ext`,
|
|
4282
|
+
* `alg`) must be dropped here, or the thumbprint an admin pins would depend on
|
|
4283
|
+
* incidental fields.
|
|
4284
|
+
*/
|
|
4285
|
+
function policySignerJwk(publicKeyJwk) {
|
|
4286
|
+
const jwk = JSON.parse(publicKeyJwk);
|
|
4287
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) throw new SeekritCryptoError("MALFORMED_BLOB", "not an EC P-256 public key");
|
|
4288
|
+
return {
|
|
4289
|
+
kty: "EC",
|
|
4290
|
+
crv: "P-256",
|
|
4291
|
+
x: jwk.x,
|
|
4292
|
+
y: jwk.y
|
|
4293
|
+
};
|
|
4294
|
+
}
|
|
4295
|
+
//#endregion
|
|
3495
4296
|
//#region ../../packages/crypto/src/shamir.ts
|
|
3496
4297
|
/**
|
|
3497
4298
|
* Shamir's Secret Sharing over GF(2^8) — the same field AES uses, with the
|
|
@@ -3985,10 +4786,13 @@ function encodeOpensshPrivateKey(seed, pub, comment) {
|
|
|
3985
4786
|
*/
|
|
3986
4787
|
const TOKEN_PREFIX = "skt";
|
|
3987
4788
|
const CLI_SESSION_PREFIX = "skc";
|
|
4789
|
+
const TASK_PREFIX = "skd";
|
|
3988
4790
|
const TOKEN_ID_LENGTH = 22;
|
|
3989
4791
|
const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
3990
4792
|
/** 32 bytes of entropy for the CLI session secret. */
|
|
3991
4793
|
const CLI_SESSION_SECRET_BYTES = 32;
|
|
4794
|
+
/** 32 bytes for a task token's secret, matching the CLI session. */
|
|
4795
|
+
const TASK_SECRET_BYTES = 32;
|
|
3992
4796
|
function randomTokenId(prefix = TOKEN_PREFIX) {
|
|
3993
4797
|
let out = "";
|
|
3994
4798
|
while (out.length < TOKEN_ID_LENGTH) {
|
|
@@ -4058,9 +4862,18 @@ function parseCliSessionToken(token) {
|
|
|
4058
4862
|
function isCliSessionToken(value) {
|
|
4059
4863
|
return value.startsWith(`${CLI_SESSION_PREFIX}_`);
|
|
4060
4864
|
}
|
|
4865
|
+
async function createAgentTaskToken() {
|
|
4866
|
+
const taskRef = randomTokenId(TASK_PREFIX);
|
|
4867
|
+
const token = `${taskRef}_${toBase64Url(crypto.getRandomValues(new Uint8Array(TASK_SECRET_BYTES)))}`;
|
|
4868
|
+
return {
|
|
4869
|
+
token,
|
|
4870
|
+
taskRef,
|
|
4871
|
+
tokenHash: await hashToken(token)
|
|
4872
|
+
};
|
|
4873
|
+
}
|
|
4061
4874
|
//#endregion
|
|
4062
4875
|
//#region package.json
|
|
4063
|
-
var version = "0.
|
|
4876
|
+
var version = "0.45.0";
|
|
4064
4877
|
//#endregion
|
|
4065
4878
|
//#region ../../packages/api-client/src/index.ts
|
|
4066
4879
|
var SeekritApiError = class extends Error {
|
|
@@ -4437,6 +5250,60 @@ var SeekritClient = class {
|
|
|
4437
5250
|
getAgentPolicyBundle(agentRef) {
|
|
4438
5251
|
return this.request("GET", `/v1/agents/${encodeURIComponent(agentRef)}/policy`);
|
|
4439
5252
|
}
|
|
5253
|
+
/**
|
|
5254
|
+
* Dispatch a task for one agent run.
|
|
5255
|
+
*
|
|
5256
|
+
* The caller mints the token (`createAgentTaskToken` in `@seekrit/crypto`) and
|
|
5257
|
+
* sends only its hash plus the public `skd_…` segment, so no presentable
|
|
5258
|
+
* credential ever reaches this API — the same shape as service-token and CLI
|
|
5259
|
+
* session creation. `scopes` may only narrow what the agent's published policy
|
|
5260
|
+
* already permits; a name outside it is refused rather than dropped.
|
|
5261
|
+
*
|
|
5262
|
+
* Not org-scoped, because an orchestrator is not: it knows an agent slug.
|
|
5263
|
+
*/
|
|
5264
|
+
dispatchAgentTask(agentRef, input) {
|
|
5265
|
+
return this.request("POST", `/v1/agents/${encodeURIComponent(agentRef)}/dispatch`, input);
|
|
5266
|
+
}
|
|
5267
|
+
/**
|
|
5268
|
+
* Exchange a presented token for the session it authorizes — what an
|
|
5269
|
+
* enforcement point calls once per task and caches until expiry.
|
|
5270
|
+
*
|
|
5271
|
+
* A POST because the token is a credential and must not land in a URL or an
|
|
5272
|
+
* access log. Fails closed and says which way: revoked, expired, or a disabled
|
|
5273
|
+
* identity are three different answers.
|
|
5274
|
+
*/
|
|
5275
|
+
introspectAgentTask(token) {
|
|
5276
|
+
return this.request("POST", "/v1/tasks/introspect", { token });
|
|
5277
|
+
}
|
|
5278
|
+
/** End a run's authority now. Idempotent. */
|
|
5279
|
+
revokeAgentTask(taskId) {
|
|
5280
|
+
return this.request("POST", `/v1/tasks/${taskId}/revoke`);
|
|
5281
|
+
}
|
|
5282
|
+
getAgentTask(taskId) {
|
|
5283
|
+
return this.request("GET", `/v1/tasks/${taskId}`);
|
|
5284
|
+
}
|
|
5285
|
+
/** Runs dispatched for one identity, newest first (admin). */
|
|
5286
|
+
listAgentTasks(orgId, agentId) {
|
|
5287
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/tasks`);
|
|
5288
|
+
}
|
|
5289
|
+
/**
|
|
5290
|
+
* Report aggregate decisions. Called by an enforcement point, not a person.
|
|
5291
|
+
*
|
|
5292
|
+
* Counts only — hosts, methods, secret *names*, decisions, and rule indices.
|
|
5293
|
+
* Never a request path: see the module comment in `agent-activity.ts` for why
|
|
5294
|
+
* that line is drawn where it is.
|
|
5295
|
+
*/
|
|
5296
|
+
reportAgentActivity(agentRef, input) {
|
|
5297
|
+
return this.request("POST", `/v1/agents/${encodeURIComponent(agentRef)}/activity`, input);
|
|
5298
|
+
}
|
|
5299
|
+
/**
|
|
5300
|
+
* What an agent actually did, collapsed onto its dimensions — the evidence a
|
|
5301
|
+
* grant review reasons over. The proposals themselves are computed client-side
|
|
5302
|
+
* (`reviewPolicy` in `@seekrit/core`), so the API never opines on policy.
|
|
5303
|
+
*/
|
|
5304
|
+
getAgentActivity(orgId, agentId, days = 14) {
|
|
5305
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/activity?days=${encodeURIComponent(String(days))}`);
|
|
5306
|
+
}
|
|
4440
5307
|
/** Keys the caller can see: all org keys for admins, granted keys otherwise. */
|
|
4441
5308
|
listKmsKeys(orgId) {
|
|
4442
5309
|
return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
|
|
@@ -4589,6 +5456,17 @@ var SeekritClient = class {
|
|
|
4589
5456
|
const qs = params.size > 0 ? `?${params}` : "";
|
|
4590
5457
|
return this.request("GET", `/v1/orgs/${orgId}/audit${qs}`);
|
|
4591
5458
|
}
|
|
5459
|
+
/**
|
|
5460
|
+
* Export the org as one signed archive: every row seekrit holds for it, with
|
|
5461
|
+
* ciphertext still ciphertext (docs/break-glass-export.md).
|
|
5462
|
+
*
|
|
5463
|
+
* The archive comes back inline rather than as a job handle, and it can be
|
|
5464
|
+
* megabytes — buffer it to a file rather than holding several copies. Requires
|
|
5465
|
+
* admin; deliberately not entitlement-gated.
|
|
5466
|
+
*/
|
|
5467
|
+
exportArchive(orgId, input = {}) {
|
|
5468
|
+
return this.request("POST", `/v1/orgs/${orgId}/export`, input);
|
|
5469
|
+
}
|
|
4592
5470
|
getLogSink(orgId) {
|
|
4593
5471
|
return this.request("GET", `/v1/orgs/${orgId}/log-sink`);
|
|
4594
5472
|
}
|
|
@@ -4748,6 +5626,24 @@ function fail(message) {
|
|
|
4748
5626
|
console.error(`error: ${message}`);
|
|
4749
5627
|
process.exit(1);
|
|
4750
5628
|
}
|
|
5629
|
+
/**
|
|
5630
|
+
* Parse a duration flag like `30m`, `24h`, `90d`, or a bare seconds count.
|
|
5631
|
+
*
|
|
5632
|
+
* Lives here rather than in a command module because more than one command
|
|
5633
|
+
* takes a duration and they must agree: `--every 7d` and `--ttl 7d` meaning
|
|
5634
|
+
* different things would be a nasty surprise. Invalid input is a flag error, so
|
|
5635
|
+
* it exits through `fail` with the accepted forms named.
|
|
5636
|
+
*/
|
|
5637
|
+
function parseDurationSeconds(input, flag) {
|
|
5638
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
5639
|
+
if (!m) fail(`invalid ${flag} "${input}" (try 12h, 7d, 90d)`);
|
|
5640
|
+
return Number(m[1]) * ({
|
|
5641
|
+
s: 1,
|
|
5642
|
+
m: 60,
|
|
5643
|
+
h: 3600,
|
|
5644
|
+
d: 86400
|
|
5645
|
+
}[m[2] || "s"] ?? 1);
|
|
5646
|
+
}
|
|
4751
5647
|
/** Prompt without echoing input (for passphrases). */
|
|
4752
5648
|
function promptHidden(question) {
|
|
4753
5649
|
const muted = new Writable({ write(_chunk, _encoding, callback) {
|
|
@@ -4883,6 +5779,31 @@ async function getPrivateKey(ctx) {
|
|
|
4883
5779
|
const { encryptedPrivateKey } = await ctx.client.getMyKeys();
|
|
4884
5780
|
return importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey));
|
|
4885
5781
|
}
|
|
5782
|
+
/**
|
|
5783
|
+
* Recover the caller's **policy signing** key: the same P-256 principal key,
|
|
5784
|
+
* re-imported for ECDSA rather than ECDH. The dashboard does exactly this in
|
|
5785
|
+
* `keyring.getPolicySigner`, so a bundle signed here and one signed in a browser
|
|
5786
|
+
* are indistinguishable — same key, same thumbprint, same pin in a proxy config.
|
|
5787
|
+
*
|
|
5788
|
+
* Service tokens are refused *here* rather than at the API, so the reason is
|
|
5789
|
+
* legible at the point of use: publishing policy is deliberately gated on a
|
|
5790
|
+
* human's key, because an agent that can widen its own authorization is not
|
|
5791
|
+
* governed by it. See the module comment in `apps/api/src/routes/agents.ts`.
|
|
5792
|
+
*
|
|
5793
|
+
* The honest caveat is `SEEKRIT_PASSPHRASE`: where it is set, anything that can
|
|
5794
|
+
* read the environment can sign. That is already true of every other CLI
|
|
5795
|
+
* decryption, but it matters more here, so the docs say to leave it unset on any
|
|
5796
|
+
* machine an agent shares.
|
|
5797
|
+
*/
|
|
5798
|
+
async function getPolicySigner(ctx) {
|
|
5799
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) fail("publishing agent policy needs a human's signing key — sign in with `seekrit login` (a service token cannot publish, by design)");
|
|
5800
|
+
const { encryptedPrivateKey } = await ctx.client.getMyKeys();
|
|
5801
|
+
const privateKeyJwk = await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey);
|
|
5802
|
+
return {
|
|
5803
|
+
signingKey: await importPolicySigningKey(privateKeyJwk),
|
|
5804
|
+
jwk: policySignerJwk(privateKeyJwk)
|
|
5805
|
+
};
|
|
5806
|
+
}
|
|
4886
5807
|
/** Recover one environment's DEK for the current principal. */
|
|
4887
5808
|
async function getDek(ctx, orgId, envId) {
|
|
4888
5809
|
const [{ wrappedDek }, privateKey] = await Promise.all([ctx.client.getMyEnvKey(orgId, envId), getPrivateKey(ctx)]);
|
|
@@ -5213,24 +6134,661 @@ function registerAccountCommands(program) {
|
|
|
5213
6134
|
});
|
|
5214
6135
|
}
|
|
5215
6136
|
//#endregion
|
|
5216
|
-
//#region src/
|
|
5217
|
-
/**
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
6137
|
+
//#region src/agents.ts
|
|
6138
|
+
/**
|
|
6139
|
+
* Agent access governance from the CLI.
|
|
6140
|
+
*
|
|
6141
|
+
* Policy lived only in the dashboard until now, which was backwards: the people
|
|
6142
|
+
* deploying agents work in a terminal and a repo, and a rule set is exactly the
|
|
6143
|
+
* kind of thing that wants review and version control. So the loop this module
|
|
6144
|
+
* exists for is:
|
|
6145
|
+
*
|
|
6146
|
+
* seekrit agents policy pull nova -o nova.policy.json # current rules
|
|
6147
|
+
* $EDITOR nova.policy.json # commit it, review it
|
|
6148
|
+
* seekrit agents policy publish nova -f nova.policy.json
|
|
6149
|
+
*
|
|
6150
|
+
* **Signing still happens here, on this machine, with the operator's own key**
|
|
6151
|
+
* (`getPolicySigner` in `context.ts`), so nothing about the trust argument in
|
|
6152
|
+
* `docs/agent-access-governance.md` §1 changes: the API receives an opaque
|
|
6153
|
+
* envelope it cannot forge, and a proxy verifies it against thumbprints pinned in
|
|
6154
|
+
* its own local file. A service token is refused — an agent that can publish its
|
|
6155
|
+
* own policy is not governed by it.
|
|
6156
|
+
*
|
|
6157
|
+
* Everything else here is read-only, and two commands are deliberately *local*
|
|
6158
|
+
* evaluations rather than API calls:
|
|
6159
|
+
*
|
|
6160
|
+
* - `simulate` runs `evaluatePolicy` from `@seekrit/core`, the mirror of
|
|
6161
|
+
* `RuleSet::decide` in the proxy — so a dry run and a real refusal give the
|
|
6162
|
+
* same verdict in the same words.
|
|
6163
|
+
* - `fetch` asks for the bytes a proxy would get (`GET /v1/agents/:ref/policy`)
|
|
6164
|
+
* and re-derives the signer thumbprint from the bundle itself, rather than
|
|
6165
|
+
* trusting the field the API echoes beside it.
|
|
6166
|
+
*/
|
|
6167
|
+
/** How many published versions the API returns per page. */
|
|
6168
|
+
const POLICY_PAGE = 50;
|
|
6169
|
+
/** The `-` sentinel every other seekrit command uses to mean stdin. */
|
|
6170
|
+
const STDIN = "-";
|
|
6171
|
+
/**
|
|
6172
|
+
* Resolve `nova`, or `agt_…`, to an identity.
|
|
6173
|
+
*
|
|
6174
|
+
* The org-scoped API routes take an id, but nobody types ids — so a slug is
|
|
6175
|
+
* resolved from the list. The list is small (agents are a per-deployment thing,
|
|
6176
|
+
* not a per-request one), which is why this costs one request rather than
|
|
6177
|
+
* needing a lookup route.
|
|
6178
|
+
*/
|
|
6179
|
+
async function resolveAgent(ctx, orgId, ref) {
|
|
6180
|
+
const { agents } = await ctx.client.listAgents(orgId);
|
|
6181
|
+
const found = agents.find((a) => a.slug === ref || a.id === ref);
|
|
6182
|
+
if (!found) {
|
|
6183
|
+
const known = agents.map((a) => a.slug).join(", ");
|
|
6184
|
+
fail(`no agent "${ref}"${known ? ` — this org has: ${known}` : " in this org"}`);
|
|
5232
6185
|
}
|
|
5233
|
-
|
|
6186
|
+
return found;
|
|
6187
|
+
}
|
|
6188
|
+
/**
|
|
6189
|
+
* One published version: the newest by default, or an explicit `--version`.
|
|
6190
|
+
*
|
|
6191
|
+
* An agent with no published policy is a real state, not an error — a proxy for
|
|
6192
|
+
* it fails closed — so the caller decides how to report it.
|
|
6193
|
+
*/
|
|
6194
|
+
async function loadVersion(ctx, orgId, agent, version) {
|
|
6195
|
+
const { policies } = await ctx.client.listAgentPolicies(orgId, agent.id);
|
|
6196
|
+
if (version === void 0) return policies[0] ?? null;
|
|
6197
|
+
const found = policies.find((p) => p.version === version);
|
|
6198
|
+
if (!found) fail(policies.length >= POLICY_PAGE ? `no version ${version} in the last ${POLICY_PAGE} published` : `no version ${version} — published: ${policies.map((p) => p.version).join(", ") || "none"}`);
|
|
6199
|
+
return found;
|
|
6200
|
+
}
|
|
6201
|
+
/**
|
|
6202
|
+
* Read a rule file: either a bare array or `{ "rules": [...] }`, from a path or
|
|
6203
|
+
* stdin. Both shapes are accepted because `policy pull` writes the second and
|
|
6204
|
+
* hand-written files tend to be the first.
|
|
6205
|
+
*
|
|
6206
|
+
* Validation is the same zod schema the API and the dashboard use, so a file
|
|
6207
|
+
* rejected here would have been rejected there — before anything is signed.
|
|
6208
|
+
*
|
|
6209
|
+
* Exported for its own test: this is the one place a hand-written file meets the
|
|
6210
|
+
* schema, and its error messages are the whole user experience of a typo.
|
|
6211
|
+
*/
|
|
6212
|
+
function parseRuleFile(raw, source) {
|
|
6213
|
+
let parsed;
|
|
6214
|
+
try {
|
|
6215
|
+
parsed = JSON.parse(raw);
|
|
6216
|
+
} catch (err) {
|
|
6217
|
+
fail(`${source} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
6218
|
+
}
|
|
6219
|
+
const list = Array.isArray(parsed) ? parsed : parsed?.rules === void 0 ? fail(`${source} must be a JSON array of rules, or an object with a "rules" array`) : parsed.rules;
|
|
6220
|
+
if (!Array.isArray(list)) fail(`${source}: "rules" must be an array`);
|
|
6221
|
+
if (list.length === 0) console.error(`note: ${source} has no rules — publishing it denies every request`);
|
|
6222
|
+
return list.map((rule, i) => {
|
|
6223
|
+
const result = agentPolicyRuleSchema.safeParse(rule);
|
|
6224
|
+
if (!result.success) {
|
|
6225
|
+
const first = result.error.issues[0];
|
|
6226
|
+
fail(`${source}: rule ${i + 1} is invalid — ${first?.path.join(".") || "rule"}: ${first?.message ?? "unknown error"}`);
|
|
6227
|
+
}
|
|
6228
|
+
return result.data;
|
|
6229
|
+
});
|
|
6230
|
+
}
|
|
6231
|
+
/** `--file path` or `--file -`. */
|
|
6232
|
+
async function readRuleSource(file) {
|
|
6233
|
+
if (file === STDIN) return {
|
|
6234
|
+
raw: await readStdin(),
|
|
6235
|
+
label: "stdin"
|
|
6236
|
+
};
|
|
6237
|
+
try {
|
|
6238
|
+
return {
|
|
6239
|
+
raw: readFileSync(file, "utf8"),
|
|
6240
|
+
label: file
|
|
6241
|
+
};
|
|
6242
|
+
} catch (err) {
|
|
6243
|
+
return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
/**
|
|
6247
|
+
* An empty `methods` or `paths` list means *any*: those fields narrow a rule that
|
|
6248
|
+
* already matched its host, so absent means unconstrained. A blank column would
|
|
6249
|
+
* read as the opposite, so it renders as `any`.
|
|
6250
|
+
*
|
|
6251
|
+
* Exported for its own test — this is a real invariant of the format, not a
|
|
6252
|
+
* cosmetic choice.
|
|
6253
|
+
*/
|
|
6254
|
+
function describeList(values) {
|
|
6255
|
+
return values.length === 0 ? "any" : values.join(" ");
|
|
6256
|
+
}
|
|
6257
|
+
/**
|
|
6258
|
+
* `allow` is the field where empty means the **opposite** — no secret may be
|
|
6259
|
+
* injected toward this host at all (`Decision::SecretNotAllowed` for every name;
|
|
6260
|
+
* see `crates/seekrit-core/src/policy.rs`). Such a rule is useful and
|
|
6261
|
+
* intentional: it permits the *request* while granting no credential, which is
|
|
6262
|
+
* how you let an agent read a public API through the proxy without handing it a
|
|
6263
|
+
* key. Rendering that as `any` would invert the meaning of the one column an
|
|
6264
|
+
* operator reviews most carefully, so it gets its own function.
|
|
6265
|
+
*/
|
|
6266
|
+
function describeSecrets(values) {
|
|
6267
|
+
return values.length === 0 ? "none" : values.join(" ");
|
|
6268
|
+
}
|
|
6269
|
+
function printRules(rules) {
|
|
6270
|
+
printTable(rules.map((rule, i) => ({
|
|
6271
|
+
n: i + 1,
|
|
6272
|
+
rule
|
|
6273
|
+
})), [
|
|
6274
|
+
col("#", (r) => r.n),
|
|
6275
|
+
col("host", (r) => r.rule.host),
|
|
6276
|
+
col("methods", (r) => describeList(r.rule.methods)),
|
|
6277
|
+
col("paths", (r) => describeList(r.rule.paths)),
|
|
6278
|
+
col("secrets", (r) => describeSecrets(r.rule.allow)),
|
|
6279
|
+
col("label", (r) => r.rule.label ?? "-")
|
|
6280
|
+
], "no rules — this policy denies every request");
|
|
6281
|
+
}
|
|
6282
|
+
/** One rule as a single line, for the publish diff. */
|
|
6283
|
+
function ruleLine(rule) {
|
|
6284
|
+
const parts = [
|
|
6285
|
+
rule.host,
|
|
6286
|
+
describeList(rule.methods),
|
|
6287
|
+
describeList(rule.paths),
|
|
6288
|
+
`secrets=${describeSecrets(rule.allow)}`
|
|
6289
|
+
];
|
|
6290
|
+
return rule.label ? `${parts.join(" ")} (${rule.label})` : parts.join(" ");
|
|
6291
|
+
}
|
|
6292
|
+
/**
|
|
6293
|
+
* The change a publish would make. Rules are compared by *position* because
|
|
6294
|
+
* order decides — first match wins — so a reordering is a real change and shows
|
|
6295
|
+
* up as one.
|
|
6296
|
+
*/
|
|
6297
|
+
function printPolicyDiff(before, after) {
|
|
6298
|
+
const changes = diffPolicyRules(before, after).filter((c) => c.kind !== "unchanged");
|
|
6299
|
+
if (changes.length === 0) {
|
|
6300
|
+
console.error(after.length === 0 ? "no rules — this policy denies every request" : "no rule changes — publishing would only extend the expiry");
|
|
6301
|
+
return;
|
|
6302
|
+
}
|
|
6303
|
+
for (const change of changes) {
|
|
6304
|
+
const n = change.index + 1;
|
|
6305
|
+
if (change.kind === "added") console.log(`+ ${n} ${ruleLine(change.after)}`);
|
|
6306
|
+
else if (change.kind === "removed") console.log(`- ${n} ${ruleLine(change.before)}`);
|
|
6307
|
+
else {
|
|
6308
|
+
console.log(`- ${n} ${ruleLine(change.before)}`);
|
|
6309
|
+
console.log(`+ ${n} ${ruleLine(change.after)}`);
|
|
6310
|
+
}
|
|
6311
|
+
}
|
|
6312
|
+
}
|
|
6313
|
+
/** Seconds until an ISO instant, or a negative number when it has passed. */
|
|
6314
|
+
function secondsUntil(iso) {
|
|
6315
|
+
return Math.round((new Date(iso).getTime() - Date.now()) / 1e3);
|
|
6316
|
+
}
|
|
6317
|
+
/** Collect a repeated `--scope NAME` into a list. */
|
|
6318
|
+
function collectScope(value, acc = []) {
|
|
6319
|
+
acc.push(value);
|
|
6320
|
+
return acc;
|
|
6321
|
+
}
|
|
6322
|
+
/**
|
|
6323
|
+
* A task's state, derived rather than stored: `revoked` beats `expired`, so a run
|
|
6324
|
+
* somebody killed does not read as one that merely lapsed.
|
|
6325
|
+
*
|
|
6326
|
+
* Exported for its own test — it mirrors `taskState` in
|
|
6327
|
+
* `apps/api/src/lib/agent-tasks.ts`, and the two disagreeing would mean the list
|
|
6328
|
+
* screen and the enforcement point describe the same run differently.
|
|
6329
|
+
*/
|
|
6330
|
+
function taskStateOf(task, now) {
|
|
6331
|
+
if (task.revokedAt) return "revoked";
|
|
6332
|
+
return Date.parse(task.expiresAt) <= now ? "expired" : "active";
|
|
6333
|
+
}
|
|
6334
|
+
/**
|
|
6335
|
+
* A detail view on **stderr**, so a command whose stdout is a credential can
|
|
6336
|
+
* still explain itself to a person without corrupting `$(…)`.
|
|
6337
|
+
*/
|
|
6338
|
+
function printFieldsToStderr(fields) {
|
|
6339
|
+
const present = fields.filter(([, value]) => value !== null && value !== void 0);
|
|
6340
|
+
const width = Math.max(...present.map(([label]) => label.length));
|
|
6341
|
+
for (const [label, value] of present) console.error(`${label.padEnd(width)} ${value}`);
|
|
6342
|
+
}
|
|
6343
|
+
/**
|
|
6344
|
+
* Exported for its own test: the expired branch is the one that must be loud.
|
|
6345
|
+
*
|
|
6346
|
+
* The granularity has to span both users of this — a policy bundle lasting a
|
|
6347
|
+
* week and a task lasting fifteen minutes. Rounding a task to the nearest hour
|
|
6348
|
+
* printed "in 0h", which is worse than no estimate at all.
|
|
6349
|
+
*/
|
|
6350
|
+
function describeExpiry(iso) {
|
|
6351
|
+
const seconds = secondsUntil(iso);
|
|
6352
|
+
if (seconds <= 0) return `${iso} (EXPIRED — proxies for this agent fail closed)`;
|
|
6353
|
+
const days = Math.floor(seconds / 86400);
|
|
6354
|
+
const hours = Math.floor(seconds % 86400 / 3600);
|
|
6355
|
+
const minutes = Math.floor(seconds % 3600 / 60);
|
|
6356
|
+
if (days > 0) return `${iso} (in ${days}d ${hours}h)`;
|
|
6357
|
+
if (hours > 0) return `${iso} (in ${hours}h ${minutes}m)`;
|
|
6358
|
+
if (minutes > 0) return `${iso} (in ${minutes}m)`;
|
|
6359
|
+
return `${iso} (in ${seconds}s)`;
|
|
6360
|
+
}
|
|
6361
|
+
function registerAgentCommands(program) {
|
|
6362
|
+
const agents = program.command("agents").description("agent access policy — which agent may reach which upstream with which secret");
|
|
6363
|
+
agents.command("list", { isDefault: true }).description("list agent identities").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
|
|
6364
|
+
const ctx = buildContext();
|
|
6365
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6366
|
+
const { agents: rows } = await ctx.client.listAgents(org.id);
|
|
6367
|
+
emit(options, { agents: rows }, () => printTable(rows, [
|
|
6368
|
+
col("slug", (a) => a.slug),
|
|
6369
|
+
col("name", (a) => a.name),
|
|
6370
|
+
col("policy", (a) => a.currentPolicyVersion === 0 ? "none" : `v${a.currentPolicyVersion}`),
|
|
6371
|
+
col("enabled", (a) => a.enabled ? "yes" : "no"),
|
|
6372
|
+
col("last fetch", (a) => a.lastPolicyFetchAt ?? "never")
|
|
6373
|
+
], "no agent identities — create one with `seekrit agents create`"));
|
|
6374
|
+
});
|
|
6375
|
+
agents.command("create <name>").description("create an agent identity (a policy subject; it holds no key material)").requiredOption("--slug <slug>", "short name a proxy config and a ticket refer to").option("--org <slug>", "organization").option("--app <slug>", "application, when scoping the agent to one environment").option("--env <slug>", "environment whose secret names its rules may reference").option("--json", "machine-readable output").action(async (name, options) => {
|
|
6376
|
+
const ctx = buildContext();
|
|
6377
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6378
|
+
const environmentId = options.env ? (await resolveAppEnv(ctx, {
|
|
6379
|
+
org: options.org,
|
|
6380
|
+
app: options.app,
|
|
6381
|
+
env: options.env
|
|
6382
|
+
})).envId : void 0;
|
|
6383
|
+
const { agent } = await ctx.client.createAgent(org.id, {
|
|
6384
|
+
name,
|
|
6385
|
+
slug: options.slug,
|
|
6386
|
+
...environmentId ? { environmentId } : {}
|
|
6387
|
+
});
|
|
6388
|
+
emit(options, { agent }, () => {
|
|
6389
|
+
console.error(`created ${agent.slug} — no policy published yet, so any proxy for it fails closed`);
|
|
6390
|
+
console.error(`next: seekrit agents policy publish ${agent.slug} -f rules.json`);
|
|
6391
|
+
});
|
|
6392
|
+
});
|
|
6393
|
+
agents.command("show <ref>").description("an identity and its live rules").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6394
|
+
const ctx = buildContext();
|
|
6395
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6396
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6397
|
+
const { environment } = await ctx.client.getAgent(org.id, agent.id);
|
|
6398
|
+
const live = await loadVersion(ctx, org.id, agent);
|
|
6399
|
+
emit(options, {
|
|
6400
|
+
agent,
|
|
6401
|
+
environment,
|
|
6402
|
+
policy: live
|
|
6403
|
+
}, () => {
|
|
6404
|
+
printFields([
|
|
6405
|
+
["name", agent.name],
|
|
6406
|
+
["slug", agent.slug],
|
|
6407
|
+
["id", agent.id],
|
|
6408
|
+
["environment", environment ? environment.slug : "any in the org"],
|
|
6409
|
+
["enabled", agent.enabled ? "yes" : "no"],
|
|
6410
|
+
["policy", live ? `v${live.version}` : "none published"],
|
|
6411
|
+
["signer", live?.signerThumbprint],
|
|
6412
|
+
["expires", live ? describeExpiry(live.expiresAt) : void 0],
|
|
6413
|
+
["last fetch", agent.lastPolicyFetchAt ?? "never"]
|
|
6414
|
+
]);
|
|
6415
|
+
if (live) {
|
|
6416
|
+
section(`rules (v${live.version})`);
|
|
6417
|
+
printRules(live.rules);
|
|
6418
|
+
section("hosts a forward proxy would intercept");
|
|
6419
|
+
console.log(policyHosts(live.rules).join("\n") || "none");
|
|
6420
|
+
}
|
|
6421
|
+
});
|
|
6422
|
+
});
|
|
6423
|
+
agents.command("disable <ref>").description("stop serving this identity's policy (the revocation path)").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, options) => {
|
|
6424
|
+
const ctx = buildContext();
|
|
6425
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6426
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6427
|
+
await confirmDestructive(options.yes, `Disable ${agent.slug}? Running proxies keep their current bundle until it expires.`);
|
|
6428
|
+
await ctx.client.updateAgent(org.id, agent.id, { enabled: false });
|
|
6429
|
+
console.error(`${agent.slug} disabled — its next policy refresh is refused`);
|
|
6430
|
+
});
|
|
6431
|
+
agents.command("enable <ref>").description("serve this identity's policy again").option("--org <slug>", "organization").action(async (ref, options) => {
|
|
6432
|
+
const ctx = buildContext();
|
|
6433
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6434
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6435
|
+
await ctx.client.updateAgent(org.id, agent.id, { enabled: true });
|
|
6436
|
+
console.error(`${agent.slug} enabled`);
|
|
6437
|
+
});
|
|
6438
|
+
agents.command("rm <ref>").description("delete an identity and its published history").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, options) => {
|
|
6439
|
+
const ctx = buildContext();
|
|
6440
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6441
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6442
|
+
await confirmDestructive(options.yes, `Delete ${agent.slug} and all ${agent.currentPolicyVersion} published version(s)? The audit_log rows survive.`);
|
|
6443
|
+
await ctx.client.deleteAgent(org.id, agent.id);
|
|
6444
|
+
console.error(`${agent.slug} deleted`);
|
|
6445
|
+
});
|
|
6446
|
+
/**
|
|
6447
|
+
* The trust anchor. This prints a *public* key fingerprint — the security of
|
|
6448
|
+
* pinning comes from the operator putting it in a local file the API cannot
|
|
6449
|
+
* reach, not from this command being honest. Which is why it prints the TOML
|
|
6450
|
+
* line to paste rather than offering to write the config.
|
|
6451
|
+
*/
|
|
6452
|
+
agents.command("signer").description("your policy signing thumbprint, to pin in a proxy config").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
|
|
6453
|
+
const ctx = buildContext();
|
|
6454
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6455
|
+
const { signer } = await ctx.client.getMyPolicySigner(org.id);
|
|
6456
|
+
if (!signer) fail("your account has no keypair yet — run `seekrit keys setup`");
|
|
6457
|
+
emit(options, { signer }, () => {
|
|
6458
|
+
printFields([["thumbprint", signer.thumbprint], ["user", signer.userId]]);
|
|
6459
|
+
section("pin it in seekrit-proxy.toml");
|
|
6460
|
+
console.log("[policy]");
|
|
6461
|
+
console.log(`signers = ["${signer.thumbprint}"]`);
|
|
6462
|
+
console.error("");
|
|
6463
|
+
console.error("pin a second admin's key too — one pinned signer means one lost passphrase leaves nobody able to publish");
|
|
6464
|
+
});
|
|
6465
|
+
});
|
|
6466
|
+
/**
|
|
6467
|
+
* The token is printed to **stdout** and everything else to stderr, so the
|
|
6468
|
+
* common thing works without parsing:
|
|
6469
|
+
*
|
|
6470
|
+
* TASK=$(seekrit agents dispatch nova --scope GITHUB_TOKEN --ttl 15m)
|
|
6471
|
+
*
|
|
6472
|
+
* It is shown exactly once. The API stores only a hash, so a lost token cannot
|
|
6473
|
+
* be recovered — dispatch another and revoke the first.
|
|
6474
|
+
*/
|
|
6475
|
+
agents.command("dispatch <ref>").description("mint task-scoped authority for one agent run").option("--scope <NAME>", "narrow this run to one secret (repeatable)", collectScope).option("--ttl <duration>", "how long the run stays authorized (e.g. 15m, 2h)").option("--label <text>", "what this run is for — recorded in the audit trail").option("--json", "machine-readable output (includes the token)").action(async (ref, options) => {
|
|
6476
|
+
const ctx = buildContext();
|
|
6477
|
+
const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : 900;
|
|
6478
|
+
if (ttlSeconds < 60 || ttlSeconds > 43200) fail(`--ttl must be between 60s and ${TASK_MAX_TTL_SECONDS / 3600}h`);
|
|
6479
|
+
const minted = await createAgentTaskToken();
|
|
6480
|
+
const { task, header } = await ctx.client.dispatchAgentTask(ref, {
|
|
6481
|
+
taskRef: minted.taskRef,
|
|
6482
|
+
tokenHash: minted.tokenHash,
|
|
6483
|
+
ttlSeconds,
|
|
6484
|
+
...options.scope ? { scopes: options.scope } : {},
|
|
6485
|
+
...options.label ? { label: options.label } : {}
|
|
6486
|
+
});
|
|
6487
|
+
if (options.json) {
|
|
6488
|
+
console.log(JSON.stringify({
|
|
6489
|
+
task,
|
|
6490
|
+
header,
|
|
6491
|
+
token: minted.token
|
|
6492
|
+
}, null, 2));
|
|
6493
|
+
return;
|
|
6494
|
+
}
|
|
6495
|
+
console.log(minted.token);
|
|
6496
|
+
printFieldsToStderr([
|
|
6497
|
+
["task", task.id],
|
|
6498
|
+
["agent", ref],
|
|
6499
|
+
["scopes", task.scopes ? describeSecrets(task.scopes) : "the agent's full policy"],
|
|
6500
|
+
["policy", `v${task.policyVersion}`],
|
|
6501
|
+
["expires", describeExpiry(task.expiresAt)],
|
|
6502
|
+
["header", header]
|
|
6503
|
+
]);
|
|
6504
|
+
});
|
|
6505
|
+
agents.command("tasks <ref>").description("runs dispatched for an agent identity, newest first").option("--org <slug>", "organization").option("--all", "include expired and revoked runs").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6506
|
+
const ctx = buildContext();
|
|
6507
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6508
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6509
|
+
const { tasks } = await ctx.client.listAgentTasks(org.id, agent.id);
|
|
6510
|
+
const now = Date.now();
|
|
6511
|
+
const rows = options.all ? tasks : tasks.filter((t) => taskStateOf(t, now) === "active");
|
|
6512
|
+
emit(options, { tasks: rows }, () => printTable(rows, [
|
|
6513
|
+
col("task", (t) => t.id),
|
|
6514
|
+
col("state", (t) => taskStateOf(t, now)),
|
|
6515
|
+
col("scopes", (t) => t.scopes ? describeSecrets(t.scopes) : "policy"),
|
|
6516
|
+
col("policy", (t) => `v${t.policyVersion}`),
|
|
6517
|
+
col("expires", (t) => t.expiresAt),
|
|
6518
|
+
col("last seen", (t) => t.lastSeenAt ?? "never"),
|
|
6519
|
+
col("label", (t) => t.label ?? "-")
|
|
6520
|
+
], options.all ? "no runs dispatched yet" : "no live runs (try --all)"));
|
|
6521
|
+
});
|
|
6522
|
+
agents.command("revoke <taskId>").description("end a run's authority now").action(async (taskId) => {
|
|
6523
|
+
const { task } = await buildContext().client.revokeAgentTask(taskId);
|
|
6524
|
+
console.error(`${task.id} revoked — enforcement points refuse it at their next introspection`);
|
|
6525
|
+
});
|
|
6526
|
+
registerPolicyCommands(agents.command("policy").description("published policy versions (`seekrit agents policy --help`)"));
|
|
6527
|
+
agents.command("activity <ref>").description("what this agent actually did — aggregate decisions, not a request log").option("--days <n>", "how far back to look (1–90)", "14").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6528
|
+
const ctx = buildContext();
|
|
6529
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6530
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6531
|
+
const days = Number(options.days ?? 14);
|
|
6532
|
+
if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
|
|
6533
|
+
const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
|
|
6534
|
+
emit(options, {
|
|
6535
|
+
activity,
|
|
6536
|
+
summary
|
|
6537
|
+
}, () => {
|
|
6538
|
+
printFields([
|
|
6539
|
+
["window", `${days}d`],
|
|
6540
|
+
["allowed", summary.allowed],
|
|
6541
|
+
["denied", summary.denied],
|
|
6542
|
+
["hosts", summary.hosts],
|
|
6543
|
+
["earliest", summary.from ?? "no activity reported"]
|
|
6544
|
+
]);
|
|
6545
|
+
if (activity.length === 0) {
|
|
6546
|
+
console.error("");
|
|
6547
|
+
console.error("nothing reported — add an [activity] block to the proxy (`seekrit proxy init --activity`)");
|
|
6548
|
+
return;
|
|
6549
|
+
}
|
|
6550
|
+
section("decisions");
|
|
6551
|
+
printTable(activity, [
|
|
6552
|
+
col("host", (a) => a.host),
|
|
6553
|
+
col("method", (a) => a.method),
|
|
6554
|
+
col("decision", (a) => a.decision),
|
|
6555
|
+
col("rule", (a) => a.ruleIndex === null ? "-" : String(a.ruleIndex + 1)),
|
|
6556
|
+
col("count", (a) => a.count),
|
|
6557
|
+
col("secrets", (a) => a.secrets ? Object.entries(a.secrets).map(([name, n]) => `${name}×${n}`).join(" ") : "-")
|
|
6558
|
+
]);
|
|
6559
|
+
});
|
|
6560
|
+
});
|
|
6561
|
+
/**
|
|
6562
|
+
* The grant review loop. Proposals are computed *here*, from activity the API
|
|
6563
|
+
* served — the API is deliberately not in the business of saying what a policy
|
|
6564
|
+
* should be. And `--out` writes a rule file rather than publishing: the change
|
|
6565
|
+
* still goes through `policy publish`, which still needs a human's key.
|
|
6566
|
+
*/
|
|
6567
|
+
agents.command("review <ref>").description("compare published policy against observed activity, and propose changes").option("--days <n>", "how far back to look (1–90)", "14").option("-o, --out <path>", "write the narrowed rules to a file, ready to publish").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6568
|
+
const ctx = buildContext();
|
|
6569
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6570
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6571
|
+
const days = Number(options.days ?? 14);
|
|
6572
|
+
if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
|
|
6573
|
+
const live = await loadVersion(ctx, org.id, agent);
|
|
6574
|
+
if (!live) fail(`${agent.slug} has no published policy to review`);
|
|
6575
|
+
const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
|
|
6576
|
+
const proposals = reviewPolicy({
|
|
6577
|
+
rules: live.rules,
|
|
6578
|
+
activity
|
|
6579
|
+
});
|
|
6580
|
+
emit(options, {
|
|
6581
|
+
proposals,
|
|
6582
|
+
summary,
|
|
6583
|
+
version: live.version
|
|
6584
|
+
}, () => {
|
|
6585
|
+
console.error(`${agent.slug} — policy v${live.version}, ${summary.allowed + summary.denied} decision(s) over ${days}d`);
|
|
6586
|
+
if (activity.length === 0) {
|
|
6587
|
+
console.error("");
|
|
6588
|
+
console.error("no activity reported yet — nothing to review against");
|
|
6589
|
+
return;
|
|
6590
|
+
}
|
|
6591
|
+
if (proposals.length === 0) {
|
|
6592
|
+
console.error("");
|
|
6593
|
+
console.error("no changes proposed: every rule is in use and nothing was refused");
|
|
6594
|
+
return;
|
|
6595
|
+
}
|
|
6596
|
+
section("proposals");
|
|
6597
|
+
for (const p of proposals) {
|
|
6598
|
+
const marker = p.applicable ? "-" : "?";
|
|
6599
|
+
console.log(`${marker} ${p.rationale}`);
|
|
6600
|
+
}
|
|
6601
|
+
const applicable = countApplicable(proposals);
|
|
6602
|
+
console.error("");
|
|
6603
|
+
console.error(`${applicable} narrowing change(s) can be applied; ${proposals.length - applicable} need a human decision`);
|
|
6604
|
+
});
|
|
6605
|
+
if (options.out) {
|
|
6606
|
+
const narrowed = applyProposals(live.rules, proposals);
|
|
6607
|
+
writeFileSync(options.out, `${JSON.stringify({ rules: narrowed }, null, 2)}\n`);
|
|
6608
|
+
console.error(`wrote ${narrowed.length} rule(s) to ${options.out} — review the diff, then: seekrit agents policy publish ${agent.slug} -f ${options.out}`);
|
|
6609
|
+
console.error("widening proposals are never applied: an agent must not earn permissions by retrying");
|
|
6610
|
+
}
|
|
6611
|
+
});
|
|
6612
|
+
agents.command("simulate <ref>").description("ask what a policy would decide, without sending a request").requiredOption("--host <host>", "upstream hostname").requiredOption("--path <path>", "request path").option("--method <method>", "HTTP method", "GET").option("--secret <NAME>", "the secret the request would inject").option("--version <n>", "evaluate a specific published version").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6613
|
+
const ctx = buildContext();
|
|
6614
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6615
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6616
|
+
const version = options.version === void 0 ? void 0 : Number(options.version);
|
|
6617
|
+
if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
|
|
6618
|
+
const published = await loadVersion(ctx, org.id, agent, version);
|
|
6619
|
+
if (!published) fail(`${agent.slug} has no published policy — every request is denied`);
|
|
6620
|
+
const verdict = evaluatePolicy(published.rules, {
|
|
6621
|
+
host: options.host,
|
|
6622
|
+
method: options.method,
|
|
6623
|
+
path: options.path,
|
|
6624
|
+
...options.secret === void 0 ? {} : { secret: options.secret }
|
|
6625
|
+
});
|
|
6626
|
+
const reason = describePolicyVerdict(verdict, published.rules);
|
|
6627
|
+
emit(options, {
|
|
6628
|
+
version: published.version,
|
|
6629
|
+
verdict,
|
|
6630
|
+
reason
|
|
6631
|
+
}, () => {
|
|
6632
|
+
const target = `${options.method.toUpperCase()} ${options.host}${options.path}`;
|
|
6633
|
+
console.log(`${verdict.decision === "allow" ? "allow" : "DENY"} ${target}${options.secret ? ` [${options.secret}]` : ""}`);
|
|
6634
|
+
console.log(` v${published.version}: ${reason}`);
|
|
6635
|
+
});
|
|
6636
|
+
if (verdict.decision !== "allow") process.exitCode = 1;
|
|
6637
|
+
});
|
|
6638
|
+
}
|
|
6639
|
+
function registerPolicyCommands(policy) {
|
|
6640
|
+
policy.command("list <ref>").description("published versions, newest first").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6641
|
+
const ctx = buildContext();
|
|
6642
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6643
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6644
|
+
const { policies } = await ctx.client.listAgentPolicies(org.id, agent.id);
|
|
6645
|
+
emit(options, { policies }, () => printTable(policies, [
|
|
6646
|
+
col("version", (p) => `v${p.version}`),
|
|
6647
|
+
col("rules", (p) => p.ruleCount),
|
|
6648
|
+
col("signer", (p) => p.signerThumbprint.slice(0, 12)),
|
|
6649
|
+
col("published", (p) => p.publishedAt),
|
|
6650
|
+
col("expires", (p) => secondsUntil(p.expiresAt) <= 0 ? `${p.expiresAt} (expired)` : p.expiresAt),
|
|
6651
|
+
col("note", (p) => p.rolledBackFromVersion ? `restored v${p.rolledBackFromVersion}` : "-")
|
|
6652
|
+
], "nothing published yet"));
|
|
6653
|
+
});
|
|
6654
|
+
policy.command("show <ref>").description("the rules in a published version (live by default)").option("--version <n>", "a specific version").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6655
|
+
const ctx = buildContext();
|
|
6656
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6657
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6658
|
+
const version = options.version === void 0 ? void 0 : Number(options.version);
|
|
6659
|
+
if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
|
|
6660
|
+
const published = await loadVersion(ctx, org.id, agent, version);
|
|
6661
|
+
if (!published) fail(`${agent.slug} has no published policy`);
|
|
6662
|
+
emit(options, { policy: published }, () => {
|
|
6663
|
+
printFields([
|
|
6664
|
+
["version", `v${published.version}`],
|
|
6665
|
+
["signer", published.signerThumbprint],
|
|
6666
|
+
["published", published.publishedAt],
|
|
6667
|
+
["expires", describeExpiry(published.expiresAt)]
|
|
6668
|
+
]);
|
|
6669
|
+
section("rules");
|
|
6670
|
+
printRules(published.rules);
|
|
6671
|
+
});
|
|
6672
|
+
});
|
|
6673
|
+
/**
|
|
6674
|
+
* Write the live rules out as the editable source for the next publish. This
|
|
6675
|
+
* is the half that makes policy-as-code work: what comes back out is exactly
|
|
6676
|
+
* the shape `publish` takes in, so a round trip is a no-op diff.
|
|
6677
|
+
*/
|
|
6678
|
+
policy.command("pull <ref>").description("write a version's rules to a JSON file you can edit and publish").option("-o, --out <path>", "where to write it (default: stdout)").option("--version <n>", "a specific version").option("--org <slug>", "organization").action(async (ref, options) => {
|
|
6679
|
+
const ctx = buildContext();
|
|
6680
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6681
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6682
|
+
const version = options.version === void 0 ? void 0 : Number(options.version);
|
|
6683
|
+
if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
|
|
6684
|
+
const published = await loadVersion(ctx, org.id, agent, version);
|
|
6685
|
+
const rules = published?.rules ?? [];
|
|
6686
|
+
const body = `${JSON.stringify({ rules }, null, 2)}\n`;
|
|
6687
|
+
if (options.out) {
|
|
6688
|
+
writeFileSync(options.out, body);
|
|
6689
|
+
console.error(published ? `wrote ${rules.length} rule(s) from v${published.version} to ${options.out}` : `wrote an empty rule set to ${options.out} (nothing published yet)`);
|
|
6690
|
+
return;
|
|
6691
|
+
}
|
|
6692
|
+
process.stdout.write(body);
|
|
6693
|
+
});
|
|
6694
|
+
policy.command("publish <ref>").description("sign a rule file with your own key and publish it").requiredOption("-f, --file <path>", `rule file, or ${STDIN} for stdin`).option("--ttl <duration>", "how long the bundle stays valid (e.g. 7d, 12h)").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6695
|
+
const ctx = buildContext();
|
|
6696
|
+
if (isTokenAuth(ctx)) fail("publishing agent policy needs a human's signing key — sign in with `seekrit login`. A service token cannot publish, so an agent cannot widen its own policy.");
|
|
6697
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6698
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6699
|
+
const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : POLICY_DEFAULT_TTL_SECONDS;
|
|
6700
|
+
if (ttlSeconds < 3600 || ttlSeconds > 7776e3) fail(`--ttl must be between ${POLICY_MIN_TTL_SECONDS / 3600}h and ${POLICY_MAX_TTL_SECONDS / 86400}d`);
|
|
6701
|
+
const { raw, label } = await readRuleSource(options.file);
|
|
6702
|
+
const rules = parseRuleFile(raw, label);
|
|
6703
|
+
const live = await loadVersion(ctx, org.id, agent);
|
|
6704
|
+
const nextVersion = agent.currentPolicyVersion + 1;
|
|
6705
|
+
const from = agent.currentPolicyVersion === 0 ? "nothing" : `v${agent.currentPolicyVersion}`;
|
|
6706
|
+
console.error(`${agent.slug}: ${from} → v${nextVersion}`);
|
|
6707
|
+
printPolicyDiff(live?.rules ?? [], rules);
|
|
6708
|
+
await confirmDestructive(options.yes, `Publish v${nextVersion} for ${agent.slug} (${rules.length} rule(s), valid ${options.ttl ?? "7d"})?`);
|
|
6709
|
+
const { signingKey, jwk } = await getPolicySigner(ctx);
|
|
6710
|
+
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
6711
|
+
const bundle = await signAgentPolicy(signingKey, jwk, {
|
|
6712
|
+
v: 1,
|
|
6713
|
+
org: org.id,
|
|
6714
|
+
agent: agent.id,
|
|
6715
|
+
agent_slug: agent.slug,
|
|
6716
|
+
policy_version: nextVersion,
|
|
6717
|
+
issued_at: issuedAt,
|
|
6718
|
+
expires_at: issuedAt + ttlSeconds,
|
|
6719
|
+
rules
|
|
6720
|
+
});
|
|
6721
|
+
const { policy: published } = await ctx.client.publishAgentPolicy(org.id, agent.id, bundle);
|
|
6722
|
+
emit(options, { policy: published }, () => {
|
|
6723
|
+
console.error(`published v${published.version} — signed with ${published.signerThumbprint.slice(0, 12)}…, expires ${published.expiresAt}`);
|
|
6724
|
+
console.error("proxies pick it up at their next refresh");
|
|
6725
|
+
});
|
|
6726
|
+
});
|
|
6727
|
+
policy.command("rollback <ref> <version>").description("republish an earlier version's bundle as the newest one").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, versionArg, options) => {
|
|
6728
|
+
const ctx = buildContext();
|
|
6729
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6730
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6731
|
+
const version = Number(versionArg);
|
|
6732
|
+
if (!Number.isInteger(version) || version < 1) fail("version must be a positive integer");
|
|
6733
|
+
const source = await loadVersion(ctx, org.id, agent, version);
|
|
6734
|
+
if (!source) fail(`no version ${version}`);
|
|
6735
|
+
if (secondsUntil(source.expiresAt) <= 0) fail(`v${version} expired at ${source.expiresAt} — a rollback keeps the original expiry, so it would be inert. Publish the rules again instead: seekrit agents policy pull ${agent.slug} --version ${version} -o rules.json`);
|
|
6736
|
+
await confirmDestructive(options.yes, `Roll ${agent.slug} back to v${version} (${source.ruleCount} rule(s))?`);
|
|
6737
|
+
const { policy: published } = await ctx.client.rollbackAgentPolicy(org.id, agent.id, version);
|
|
6738
|
+
console.error(`published v${published.version} carrying v${version}'s bundle`);
|
|
6739
|
+
});
|
|
6740
|
+
/**
|
|
6741
|
+
* What a proxy actually gets. Useful when a deployment misbehaves and the
|
|
6742
|
+
* question is whether the API, the pin, or the rules are at fault — so it
|
|
6743
|
+
* re-derives the thumbprint from the bundle rather than reporting the one the
|
|
6744
|
+
* API sends beside it, and says plainly when the two disagree.
|
|
6745
|
+
*/
|
|
6746
|
+
policy.command("fetch <ref>").description("the bundle a proxy would fetch, with its signer re-derived locally").option("--bundle", "print the raw ap1. envelope and nothing else").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6747
|
+
const fetched = await buildContext().client.getAgentPolicyBundle(ref);
|
|
6748
|
+
if (options.bundle) {
|
|
6749
|
+
process.stdout.write(`${fetched.bundle}\n`);
|
|
6750
|
+
return;
|
|
6751
|
+
}
|
|
6752
|
+
const body = parseAgentPolicyUnverified(fetched.bundle);
|
|
6753
|
+
const derived = await policySignerThumbprint(body.signer.jwk);
|
|
6754
|
+
emit(options, {
|
|
6755
|
+
...fetched,
|
|
6756
|
+
derivedThumbprint: derived,
|
|
6757
|
+
rules: body.rules
|
|
6758
|
+
}, () => {
|
|
6759
|
+
printFields([
|
|
6760
|
+
["agent", `${fetched.agent.slug} (${fetched.agent.name})`],
|
|
6761
|
+
["version", `v${fetched.version}`],
|
|
6762
|
+
["expires", describeExpiry(fetched.expiresAt)],
|
|
6763
|
+
["signer (in bundle)", derived],
|
|
6764
|
+
["signer (reported)", fetched.signerThumbprint === derived ? "matches" : `${fetched.signerThumbprint} — DOES NOT MATCH the bundle; a proxy would refuse this`]
|
|
6765
|
+
]);
|
|
6766
|
+
section("rules");
|
|
6767
|
+
printRules(body.rules);
|
|
6768
|
+
console.error("");
|
|
6769
|
+
console.error("this command does not verify the signature — only a pinned signer list can, which lives in the proxy's own config");
|
|
6770
|
+
});
|
|
6771
|
+
});
|
|
6772
|
+
}
|
|
6773
|
+
//#endregion
|
|
6774
|
+
//#region src/kms.ts
|
|
6775
|
+
/** Collect a repeatable option into a list. */
|
|
6776
|
+
function collect$7(value, acc = []) {
|
|
6777
|
+
acc.push(value);
|
|
6778
|
+
return acc;
|
|
6779
|
+
}
|
|
6780
|
+
/** The calling principal's identity + public key (for a self-grant). */
|
|
6781
|
+
async function kmsCallerIdentity(ctx) {
|
|
6782
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
|
|
6783
|
+
const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
|
|
6784
|
+
const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
6785
|
+
return {
|
|
6786
|
+
principalType: "service_token",
|
|
6787
|
+
principalId: tokenId,
|
|
6788
|
+
publicKeyJwk: JSON.stringify(pub)
|
|
6789
|
+
};
|
|
6790
|
+
}
|
|
6791
|
+
const { user } = await ctx.client.me();
|
|
5234
6792
|
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
5235
6793
|
return {
|
|
5236
6794
|
principalType: "user",
|
|
@@ -5283,7 +6841,7 @@ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
|
|
|
5283
6841
|
}
|
|
5284
6842
|
function registerKmsCommands(program) {
|
|
5285
6843
|
const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
|
|
5286
|
-
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$
|
|
6844
|
+
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$7, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$7, []).action(async (options) => {
|
|
5287
6845
|
if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
|
|
5288
6846
|
if (options.app && options.group) fail("pass at most one of --app or --group");
|
|
5289
6847
|
const ctx = buildContext();
|
|
@@ -5502,7 +7060,7 @@ function registerKmsCommands(program) {
|
|
|
5502
7060
|
//#endregion
|
|
5503
7061
|
//#region src/recovery.ts
|
|
5504
7062
|
/** Collect a repeatable option into a list. */
|
|
5505
|
-
function collect$
|
|
7063
|
+
function collect$6(value, acc = []) {
|
|
5506
7064
|
acc.push(value);
|
|
5507
7065
|
return acc;
|
|
5508
7066
|
}
|
|
@@ -5599,7 +7157,7 @@ function registerRecoveryCommands(program) {
|
|
|
5599
7157
|
for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
|
|
5600
7158
|
if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
|
|
5601
7159
|
});
|
|
5602
|
-
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$
|
|
7160
|
+
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$6, []).option("--org <slug>").action(async (options) => {
|
|
5603
7161
|
const ctx = buildContext();
|
|
5604
7162
|
const org = await resolveOrg(ctx, options.org);
|
|
5605
7163
|
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
@@ -5617,7 +7175,7 @@ function registerRecoveryCommands(program) {
|
|
|
5617
7175
|
const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
|
|
5618
7176
|
console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
|
|
5619
7177
|
});
|
|
5620
|
-
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$
|
|
7178
|
+
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$6, []).option("--org <slug>").action(async (options) => {
|
|
5621
7179
|
const ctx = buildContext();
|
|
5622
7180
|
const org = await resolveOrg(ctx, options.org);
|
|
5623
7181
|
const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
|
|
@@ -5897,6 +7455,1179 @@ function registerAppCommands(program) {
|
|
|
5897
7455
|
});
|
|
5898
7456
|
}
|
|
5899
7457
|
//#endregion
|
|
7458
|
+
//#region src/decryptor.ts
|
|
7459
|
+
/**
|
|
7460
|
+
* The standalone offline decryptor: one self-contained HTML file, written out by
|
|
7461
|
+
* `seekrit archive decryptor`, that opens a break-glass archive in any browser
|
|
7462
|
+
* with no install, no network, and no seekrit.
|
|
7463
|
+
*
|
|
7464
|
+
* Why a duplicate of the decrypt path instead of a bundle of `@seekrit/crypto`:
|
|
7465
|
+
* the artifact has to be a single file a customer can store next to their
|
|
7466
|
+
* archives for years and open from `file://`, which rules out a module graph and
|
|
7467
|
+
* a build step. So it is a second implementation — pinned, like every other
|
|
7468
|
+
* second implementation in this repo (the four SDKs, `crates/seekrit-core`), by a
|
|
7469
|
+
* test that runs *this* script against ciphertext produced by the real library:
|
|
7470
|
+
* `test/decryptor.test.ts`. If you change a blob format, that test fails here
|
|
7471
|
+
* too, which is the point.
|
|
7472
|
+
*
|
|
7473
|
+
* Two rules for editing the embedded script:
|
|
7474
|
+
* - **No backticks and no `${`** anywhere inside it — it lives in a template
|
|
7475
|
+
* literal. Use string concatenation.
|
|
7476
|
+
* - **No network of any kind.** The page declares
|
|
7477
|
+
* `Content-Security-Policy: default-src 'none'`, which is the property that
|
|
7478
|
+
* makes it safe to type a passphrase into. Anything that needs a fetch does
|
|
7479
|
+
* not belong here.
|
|
7480
|
+
*/
|
|
7481
|
+
const OFFLINE_DECRYPTOR_HTML = `<!doctype html>
|
|
7482
|
+
<html lang="en">
|
|
7483
|
+
<head>
|
|
7484
|
+
<meta charset="utf-8">
|
|
7485
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7486
|
+
<!--
|
|
7487
|
+
The whole security argument for this page, in one header: with default-src
|
|
7488
|
+
'none' the browser refuses every outbound request the page could make, so the
|
|
7489
|
+
passphrase you type and the plaintext it produces cannot leave this machine.
|
|
7490
|
+
'unsafe-inline' covers the page's own inline script and styles; there is no
|
|
7491
|
+
connect-src, no img-src, no form-action.
|
|
7492
|
+
-->
|
|
7493
|
+
<meta http-equiv="Content-Security-Policy"
|
|
7494
|
+
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
|
7495
|
+
<title>seekrit — offline archive decryptor</title>
|
|
7496
|
+
<style>
|
|
7497
|
+
:root {
|
|
7498
|
+
color-scheme: dark light;
|
|
7499
|
+
--bg: #0b0c0e; --fg: #e7e9ea; --dim: #9aa0a6; --line: #23262b;
|
|
7500
|
+
--panel: #101215; --accent: #7dd3a0; --warn: #f0b76b; --bad: #f08a8a;
|
|
7501
|
+
}
|
|
7502
|
+
@media (prefers-color-scheme: light) {
|
|
7503
|
+
:root {
|
|
7504
|
+
--bg: #fbfbfa; --fg: #14161a; --dim: #5f6673; --line: #e3e5e8;
|
|
7505
|
+
--panel: #ffffff; --accent: #1a7f4b; --warn: #9a6413; --bad: #b23b3b;
|
|
7506
|
+
}
|
|
7507
|
+
}
|
|
7508
|
+
* { box-sizing: border-box; }
|
|
7509
|
+
body {
|
|
7510
|
+
margin: 0; padding: 2rem 1.25rem 4rem; background: var(--bg); color: var(--fg);
|
|
7511
|
+
font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
7512
|
+
}
|
|
7513
|
+
main { max-width: 62rem; margin: 0 auto; }
|
|
7514
|
+
h1 { font-size: 1.1rem; letter-spacing: 0.02em; margin: 0 0 0.25rem; }
|
|
7515
|
+
h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.12em;
|
|
7516
|
+
color: var(--dim); margin: 2rem 0 0.75rem; font-weight: 500; }
|
|
7517
|
+
p { margin: 0.4rem 0; color: var(--dim); }
|
|
7518
|
+
.panel { border: 1px solid var(--line); background: var(--panel); border-radius: 6px;
|
|
7519
|
+
padding: 1rem 1.1rem; }
|
|
7520
|
+
.drop { border: 1px dashed var(--line); border-radius: 6px; padding: 2rem 1rem;
|
|
7521
|
+
text-align: center; color: var(--dim); }
|
|
7522
|
+
.drop.over { border-color: var(--accent); color: var(--fg); }
|
|
7523
|
+
label { display: block; color: var(--dim); margin: 0.75rem 0 0.25rem; }
|
|
7524
|
+
input, select, textarea, button {
|
|
7525
|
+
font: inherit; color: var(--fg); background: var(--bg);
|
|
7526
|
+
border: 1px solid var(--line); border-radius: 4px; padding: 0.5rem 0.6rem;
|
|
7527
|
+
}
|
|
7528
|
+
input, select, textarea { width: 100%; }
|
|
7529
|
+
textarea { min-height: 5rem; }
|
|
7530
|
+
button { cursor: pointer; background: var(--panel); }
|
|
7531
|
+
button:hover { border-color: var(--accent); }
|
|
7532
|
+
button.primary { border-color: var(--accent); color: var(--accent); }
|
|
7533
|
+
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
|
7534
|
+
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
|
7535
|
+
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--line);
|
|
7536
|
+
vertical-align: top; word-break: break-all; }
|
|
7537
|
+
th { color: var(--dim); font-weight: 500; font-size: 0.85rem; }
|
|
7538
|
+
.kv { display: grid; grid-template-columns: 12rem 1fr; gap: 0.15rem 1rem; }
|
|
7539
|
+
.kv dt { color: var(--dim); }
|
|
7540
|
+
.kv dd { margin: 0; word-break: break-all; }
|
|
7541
|
+
.ok { color: var(--accent); } .warn { color: var(--warn); } .bad { color: var(--bad); }
|
|
7542
|
+
.hidden { display: none; }
|
|
7543
|
+
.env { margin-bottom: 1.5rem; }
|
|
7544
|
+
.muted { color: var(--dim); font-size: 0.85rem; }
|
|
7545
|
+
pre { white-space: pre-wrap; word-break: break-all; margin: 0.5rem 0 0; }
|
|
7546
|
+
.value { font-family: inherit; }
|
|
7547
|
+
ul { margin: 0.3rem 0 0; padding-left: 1.2rem; color: var(--dim); }
|
|
7548
|
+
</style>
|
|
7549
|
+
</head>
|
|
7550
|
+
<body>
|
|
7551
|
+
<main>
|
|
7552
|
+
<h1>seekrit — offline archive decryptor</h1>
|
|
7553
|
+
<p>
|
|
7554
|
+
Opens a <code>seekrit-archive/v1</code> file with your own key. This page has
|
|
7555
|
+
no network access at all (see the CSP in its source) — your passphrase
|
|
7556
|
+
and your secrets never leave this machine. Nothing is uploaded, and nothing
|
|
7557
|
+
needs to be installed.
|
|
7558
|
+
</p>
|
|
7559
|
+
|
|
7560
|
+
<h2>1 · the archive</h2>
|
|
7561
|
+
<div class="drop panel" id="drop">
|
|
7562
|
+
<input type="file" id="file" accept=".json,application/json" style="width:auto">
|
|
7563
|
+
<p class="muted">or drop the file here</p>
|
|
7564
|
+
</div>
|
|
7565
|
+
<div id="manifest" class="panel hidden" style="margin-top:0.75rem"></div>
|
|
7566
|
+
|
|
7567
|
+
<div id="step2" class="hidden">
|
|
7568
|
+
<h2>2 · your key</h2>
|
|
7569
|
+
<div class="panel">
|
|
7570
|
+
<label for="method">how you hold it</label>
|
|
7571
|
+
<select id="method">
|
|
7572
|
+
<option value="passphrase">passphrase (unlocks the key inside the archive)</option>
|
|
7573
|
+
<option value="token">service token (skt_…)</option>
|
|
7574
|
+
<option value="jwk">private key (JWK)</option>
|
|
7575
|
+
<option value="shares">custodian shares (recovery quorum)</option>
|
|
7576
|
+
</select>
|
|
7577
|
+
|
|
7578
|
+
<div id="field-passphrase">
|
|
7579
|
+
<label for="passphrase">passphrase</label>
|
|
7580
|
+
<input type="password" id="passphrase" autocomplete="off" spellcheck="false">
|
|
7581
|
+
<p class="muted" id="key-owner"></p>
|
|
7582
|
+
</div>
|
|
7583
|
+
<div id="field-token" class="hidden">
|
|
7584
|
+
<label for="token">service token</label>
|
|
7585
|
+
<input type="password" id="token" autocomplete="off" spellcheck="false"
|
|
7586
|
+
placeholder="skt_...">
|
|
7587
|
+
</div>
|
|
7588
|
+
<div id="field-jwk" class="hidden">
|
|
7589
|
+
<label for="jwk">private key JWK</label>
|
|
7590
|
+
<textarea id="jwk" spellcheck="false" placeholder='{"kty":"EC","crv":"P-256",...}'></textarea>
|
|
7591
|
+
</div>
|
|
7592
|
+
<div id="field-shares" class="hidden">
|
|
7593
|
+
<label for="shares">custodian shares</label>
|
|
7594
|
+
<textarea id="shares" spellcheck="false"
|
|
7595
|
+
placeholder="paste the contents of each share file from 'seekrit archive share', one after another"></textarea>
|
|
7596
|
+
<p class="muted">
|
|
7597
|
+
Reconstructs the org recovery key from a quorum, which opens every
|
|
7598
|
+
environment — for when the key-holder is gone.
|
|
7599
|
+
</p>
|
|
7600
|
+
</div>
|
|
7601
|
+
|
|
7602
|
+
<div class="row" style="margin-top:1rem">
|
|
7603
|
+
<button class="primary" id="decrypt">decrypt</button>
|
|
7604
|
+
<span id="status" class="muted"></span>
|
|
7605
|
+
</div>
|
|
7606
|
+
</div>
|
|
7607
|
+
</div>
|
|
7608
|
+
|
|
7609
|
+
<div id="results"></div>
|
|
7610
|
+
</main>
|
|
7611
|
+
<script>
|
|
7612
|
+
(function () {
|
|
7613
|
+
"use strict";
|
|
7614
|
+
|
|
7615
|
+
// ── encoding ────────────────────────────────────────────────────────────
|
|
7616
|
+
function b64uToBytes(text) {
|
|
7617
|
+
var base64 = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
7618
|
+
var binary = atob(base64);
|
|
7619
|
+
var out = new Uint8Array(binary.length);
|
|
7620
|
+
for (var i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
7621
|
+
return out;
|
|
7622
|
+
}
|
|
7623
|
+
function hexToBytes(hex) {
|
|
7624
|
+
if (hex.length % 2 !== 0) throw new Error("malformed hex");
|
|
7625
|
+
var out = new Uint8Array(hex.length / 2);
|
|
7626
|
+
for (var i = 0; i < out.length; i++) {
|
|
7627
|
+
var byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
7628
|
+
if (Number.isNaN(byte)) throw new Error("malformed hex");
|
|
7629
|
+
out[i] = byte;
|
|
7630
|
+
}
|
|
7631
|
+
return out;
|
|
7632
|
+
}
|
|
7633
|
+
function bytesToHex(bytes) {
|
|
7634
|
+
var out = "";
|
|
7635
|
+
for (var i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
|
|
7636
|
+
return out;
|
|
7637
|
+
}
|
|
7638
|
+
function utf8(text) { return new TextEncoder().encode(text); }
|
|
7639
|
+
function fromUtf8(bytes) { return new TextDecoder().decode(bytes); }
|
|
7640
|
+
|
|
7641
|
+
// ── canonical JSON + digests (must match packages/core/src/archive.ts) ──
|
|
7642
|
+
function canonicalJson(value) {
|
|
7643
|
+
if (value === null || typeof value !== "object") {
|
|
7644
|
+
var scalar = JSON.stringify(value);
|
|
7645
|
+
return scalar === undefined ? "null" : scalar;
|
|
7646
|
+
}
|
|
7647
|
+
if (Array.isArray(value)) {
|
|
7648
|
+
return "[" + value.map(canonicalJson).join(",") + "]";
|
|
7649
|
+
}
|
|
7650
|
+
var keys = Object.keys(value).filter(function (key) { return value[key] !== undefined; });
|
|
7651
|
+
keys.sort();
|
|
7652
|
+
var parts = keys.map(function (key) {
|
|
7653
|
+
return JSON.stringify(key) + ":" + canonicalJson(value[key]);
|
|
7654
|
+
});
|
|
7655
|
+
return "{" + parts.join(",") + "}";
|
|
7656
|
+
}
|
|
7657
|
+
async function sha256Hex(text) {
|
|
7658
|
+
var digest = await crypto.subtle.digest("SHA-256", utf8(text));
|
|
7659
|
+
return "sha256:" + bytesToHex(new Uint8Array(digest));
|
|
7660
|
+
}
|
|
7661
|
+
function sectionCount(value) {
|
|
7662
|
+
if (value === null || value === undefined) return 0;
|
|
7663
|
+
return Array.isArray(value) ? value.length : 1;
|
|
7664
|
+
}
|
|
7665
|
+
|
|
7666
|
+
// ── the three blob formats ──────────────────────────────────────────────
|
|
7667
|
+
function splitBlob(blob, prefix, parts) {
|
|
7668
|
+
var pieces = String(blob).split(".");
|
|
7669
|
+
if (pieces.length !== parts + 1 || pieces[0] !== prefix) {
|
|
7670
|
+
throw new Error("not a " + prefix + ". blob");
|
|
7671
|
+
}
|
|
7672
|
+
return pieces.slice(1);
|
|
7673
|
+
}
|
|
7674
|
+
|
|
7675
|
+
/** pk1.<iterations>.<salt>.<iv>.<ciphertext> — PBKDF2-SHA256 + AES-256-GCM. */
|
|
7676
|
+
async function decryptPrivateKeyBlob(passphrase, blob) {
|
|
7677
|
+
var parts = splitBlob(blob, "pk1", 4);
|
|
7678
|
+
var iterations = parseInt(parts[0], 10);
|
|
7679
|
+
if (!Number.isFinite(iterations) || iterations < 1) throw new Error("bad iteration count");
|
|
7680
|
+
var material = await crypto.subtle.importKey("raw", utf8(passphrase), "PBKDF2", false, [
|
|
7681
|
+
"deriveKey",
|
|
7682
|
+
]);
|
|
7683
|
+
var kek = await crypto.subtle.deriveKey(
|
|
7684
|
+
{ name: "PBKDF2", hash: "SHA-256", salt: b64uToBytes(parts[1]), iterations: iterations },
|
|
7685
|
+
material,
|
|
7686
|
+
{ name: "AES-GCM", length: 256 },
|
|
7687
|
+
false,
|
|
7688
|
+
["decrypt"]
|
|
7689
|
+
);
|
|
7690
|
+
var plaintext = await crypto.subtle.decrypt(
|
|
7691
|
+
{ name: "AES-GCM", iv: b64uToBytes(parts[2]) },
|
|
7692
|
+
kek,
|
|
7693
|
+
b64uToBytes(parts[3])
|
|
7694
|
+
);
|
|
7695
|
+
return fromUtf8(new Uint8Array(plaintext));
|
|
7696
|
+
}
|
|
7697
|
+
|
|
7698
|
+
/** wd1.<ephemeral pub>.<hkdf salt>.<iv>.<ciphertext> — ECDH P-256 + HKDF + AES-GCM. */
|
|
7699
|
+
async function unwrap(blob, privateKey) {
|
|
7700
|
+
var parts = splitBlob(blob, "wd1", 4);
|
|
7701
|
+
var ephemeral = await crypto.subtle.importKey(
|
|
7702
|
+
"raw",
|
|
7703
|
+
b64uToBytes(parts[0]),
|
|
7704
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
7705
|
+
false,
|
|
7706
|
+
[]
|
|
7707
|
+
);
|
|
7708
|
+
var shared = await crypto.subtle.deriveBits(
|
|
7709
|
+
{ name: "ECDH", public: ephemeral },
|
|
7710
|
+
privateKey,
|
|
7711
|
+
256
|
|
7712
|
+
);
|
|
7713
|
+
var hkdf = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
|
|
7714
|
+
var wrappingKey = await crypto.subtle.deriveKey(
|
|
7715
|
+
{
|
|
7716
|
+
name: "HKDF",
|
|
7717
|
+
hash: "SHA-256",
|
|
7718
|
+
salt: b64uToBytes(parts[1]),
|
|
7719
|
+
info: utf8("seekrit/wrap-dek/v1"),
|
|
7720
|
+
},
|
|
7721
|
+
hkdf,
|
|
7722
|
+
{ name: "AES-GCM", length: 256 },
|
|
7723
|
+
false,
|
|
7724
|
+
["decrypt"]
|
|
7725
|
+
);
|
|
7726
|
+
var opened = await crypto.subtle.decrypt(
|
|
7727
|
+
{ name: "AES-GCM", iv: b64uToBytes(parts[2]) },
|
|
7728
|
+
wrappingKey,
|
|
7729
|
+
b64uToBytes(parts[3])
|
|
7730
|
+
);
|
|
7731
|
+
return new Uint8Array(opened);
|
|
7732
|
+
}
|
|
7733
|
+
|
|
7734
|
+
/** sc1.<iv>.<ciphertext>, authenticated over "<environmentId>/<NAME>". */
|
|
7735
|
+
async function decryptSecret(dek, blob, aad) {
|
|
7736
|
+
var parts = splitBlob(blob, "sc1", 2);
|
|
7737
|
+
var key = await crypto.subtle.importKey("raw", dek, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
7738
|
+
var plaintext = await crypto.subtle.decrypt(
|
|
7739
|
+
{ name: "AES-GCM", iv: b64uToBytes(parts[0]), additionalData: utf8(aad) },
|
|
7740
|
+
key,
|
|
7741
|
+
b64uToBytes(parts[1])
|
|
7742
|
+
);
|
|
7743
|
+
return fromUtf8(new Uint8Array(plaintext));
|
|
7744
|
+
}
|
|
7745
|
+
|
|
7746
|
+
function importPrivateJwk(jwkText) {
|
|
7747
|
+
return crypto.subtle.importKey(
|
|
7748
|
+
"jwk",
|
|
7749
|
+
JSON.parse(jwkText),
|
|
7750
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
7751
|
+
true,
|
|
7752
|
+
["deriveBits"]
|
|
7753
|
+
);
|
|
7754
|
+
}
|
|
7755
|
+
|
|
7756
|
+
/** skt_<id>_<pkcs8 base64url>: a service token carries its own private key. */
|
|
7757
|
+
async function importToken(token) {
|
|
7758
|
+
var match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(String(token).trim());
|
|
7759
|
+
if (!match) throw new Error("not a valid seekrit service token");
|
|
7760
|
+
var privateKey = await crypto.subtle.importKey(
|
|
7761
|
+
"pkcs8",
|
|
7762
|
+
b64uToBytes(match[2]),
|
|
7763
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
7764
|
+
true,
|
|
7765
|
+
["deriveBits"]
|
|
7766
|
+
);
|
|
7767
|
+
return { principalId: match[1], privateKey: privateKey };
|
|
7768
|
+
}
|
|
7769
|
+
|
|
7770
|
+
// ── Shamir over GF(2^8) (must match packages/crypto/src/shamir.ts) ──────
|
|
7771
|
+
function gfMul(a, b) {
|
|
7772
|
+
var result = 0;
|
|
7773
|
+
var x = a;
|
|
7774
|
+
var y = b;
|
|
7775
|
+
for (var i = 0; i < 8; i++) {
|
|
7776
|
+
if (y & 1) result ^= x;
|
|
7777
|
+
var high = x & 0x80;
|
|
7778
|
+
x = (x << 1) & 0xff;
|
|
7779
|
+
if (high) x ^= 0x1b;
|
|
7780
|
+
y >>= 1;
|
|
7781
|
+
}
|
|
7782
|
+
return result & 0xff;
|
|
7783
|
+
}
|
|
7784
|
+
function gfPow(base, exponent) {
|
|
7785
|
+
var result = 1;
|
|
7786
|
+
for (var i = 0; i < exponent; i++) result = gfMul(result, base);
|
|
7787
|
+
return result;
|
|
7788
|
+
}
|
|
7789
|
+
function gfInv(value) {
|
|
7790
|
+
if (value === 0) throw new Error("no inverse for 0");
|
|
7791
|
+
return gfPow(value, 254);
|
|
7792
|
+
}
|
|
7793
|
+
/**
|
|
7794
|
+
* Lagrange interpolation at x=0 over the shares. Each share is
|
|
7795
|
+
* [x, y0, y1, ...]; the secret is the vector of y-intercepts.
|
|
7796
|
+
*/
|
|
7797
|
+
function combineShares(shares) {
|
|
7798
|
+
if (shares.length === 0) throw new Error("no shares");
|
|
7799
|
+
var length = shares[0].length - 1;
|
|
7800
|
+
var out = new Uint8Array(length);
|
|
7801
|
+
for (var byte = 0; byte < length; byte++) {
|
|
7802
|
+
var acc = 0;
|
|
7803
|
+
for (var i = 0; i < shares.length; i++) {
|
|
7804
|
+
var xi = shares[i][0];
|
|
7805
|
+
var yi = shares[i][byte + 1];
|
|
7806
|
+
var numerator = 1;
|
|
7807
|
+
var denominator = 1;
|
|
7808
|
+
for (var j = 0; j < shares.length; j++) {
|
|
7809
|
+
if (i === j) continue;
|
|
7810
|
+
var xj = shares[j][0];
|
|
7811
|
+
numerator = gfMul(numerator, xj);
|
|
7812
|
+
denominator = gfMul(denominator, xi ^ xj);
|
|
7813
|
+
}
|
|
7814
|
+
acc ^= gfMul(yi, gfMul(numerator, gfInv(denominator)));
|
|
7815
|
+
}
|
|
7816
|
+
out[byte] = acc;
|
|
7817
|
+
}
|
|
7818
|
+
return out;
|
|
7819
|
+
}
|
|
7820
|
+
|
|
7821
|
+
// ── verification ────────────────────────────────────────────────────────
|
|
7822
|
+
async function verifyArchive(archive) {
|
|
7823
|
+
var problems = [];
|
|
7824
|
+
var truncated = [];
|
|
7825
|
+
var data = archive.data || {};
|
|
7826
|
+
var declared = {};
|
|
7827
|
+
for (var i = 0; i < archive.manifest.sections.length; i++) {
|
|
7828
|
+
var header = archive.manifest.sections[i];
|
|
7829
|
+
declared[header.name] = true;
|
|
7830
|
+
if (header.truncated) truncated.push(header.name);
|
|
7831
|
+
if (!(header.name in data)) {
|
|
7832
|
+
problems.push("section " + header.name + " is missing");
|
|
7833
|
+
continue;
|
|
7834
|
+
}
|
|
7835
|
+
var value = data[header.name] === undefined ? null : data[header.name];
|
|
7836
|
+
var digest = await sha256Hex(canonicalJson(value === undefined ? null : value));
|
|
7837
|
+
if (digest !== header.digest || sectionCount(value) !== header.count) {
|
|
7838
|
+
problems.push("section " + header.name + " does not match its digest");
|
|
7839
|
+
}
|
|
7840
|
+
}
|
|
7841
|
+
Object.keys(data).forEach(function (key) {
|
|
7842
|
+
if (!declared[key]) problems.push("section " + key + " is present but undeclared");
|
|
7843
|
+
});
|
|
7844
|
+
var manifestDigest = await sha256Hex(canonicalJson(archive.manifest.sections));
|
|
7845
|
+
if (manifestDigest !== archive.manifest.digest) {
|
|
7846
|
+
problems.push("the manifest digest does not cover its sections");
|
|
7847
|
+
}
|
|
7848
|
+
|
|
7849
|
+
var signature = "unsigned";
|
|
7850
|
+
var note = "";
|
|
7851
|
+
if (archive.signature) {
|
|
7852
|
+
try {
|
|
7853
|
+
var publicKey = await crypto.subtle.importKey(
|
|
7854
|
+
"raw",
|
|
7855
|
+
hexToBytes(archive.signature.publicKey),
|
|
7856
|
+
{ name: "Ed25519" },
|
|
7857
|
+
false,
|
|
7858
|
+
["verify"]
|
|
7859
|
+
);
|
|
7860
|
+
var valid = await crypto.subtle.verify(
|
|
7861
|
+
{ name: "Ed25519" },
|
|
7862
|
+
publicKey,
|
|
7863
|
+
hexToBytes(archive.signature.value),
|
|
7864
|
+
utf8(canonicalJson(archive.manifest))
|
|
7865
|
+
);
|
|
7866
|
+
signature = valid ? "valid" : "invalid";
|
|
7867
|
+
if (!valid) problems.push("the signature does not match the manifest");
|
|
7868
|
+
} catch (err) {
|
|
7869
|
+
signature = "unverifiable";
|
|
7870
|
+
note = "this browser cannot check Ed25519 signatures";
|
|
7871
|
+
}
|
|
7872
|
+
}
|
|
7873
|
+
return {
|
|
7874
|
+
integrityOk: problems.length === 0,
|
|
7875
|
+
problems: problems,
|
|
7876
|
+
truncated: truncated,
|
|
7877
|
+
signature: signature,
|
|
7878
|
+
signatureNote: note,
|
|
7879
|
+
};
|
|
7880
|
+
}
|
|
7881
|
+
|
|
7882
|
+
// ── unlocking + decrypting ──────────────────────────────────────────────
|
|
7883
|
+
async function unlockPassphrase(archive, passphrase) {
|
|
7884
|
+
var keyMaterial = archive.data.keyMaterial;
|
|
7885
|
+
if (!keyMaterial) {
|
|
7886
|
+
throw new Error(
|
|
7887
|
+
"this archive carries no key material - use a service token, a private key, or a custodian quorum"
|
|
7888
|
+
);
|
|
7889
|
+
}
|
|
7890
|
+
var jwk = await decryptPrivateKeyBlob(passphrase, keyMaterial.encryptedPrivateKey);
|
|
7891
|
+
return {
|
|
7892
|
+
principalId: keyMaterial.principalId,
|
|
7893
|
+
privateKey: await importPrivateJwk(jwk),
|
|
7894
|
+
how: keyMaterial.principalType + " " + keyMaterial.principalId,
|
|
7895
|
+
};
|
|
7896
|
+
}
|
|
7897
|
+
|
|
7898
|
+
async function unlockJwk(jwkText) {
|
|
7899
|
+
return {
|
|
7900
|
+
principalId: null,
|
|
7901
|
+
privateKey: await importPrivateJwk(jwkText.trim()),
|
|
7902
|
+
how: "a private key",
|
|
7903
|
+
};
|
|
7904
|
+
}
|
|
7905
|
+
|
|
7906
|
+
async function unlockToken(token) {
|
|
7907
|
+
var opened = await importToken(token);
|
|
7908
|
+
return {
|
|
7909
|
+
principalId: opened.principalId,
|
|
7910
|
+
privateKey: opened.privateKey,
|
|
7911
|
+
how: "service token " + opened.principalId,
|
|
7912
|
+
};
|
|
7913
|
+
}
|
|
7914
|
+
|
|
7915
|
+
/**
|
|
7916
|
+
* Reconstruct the org recovery key from pasted share files. Accepts several
|
|
7917
|
+
* JSON objects one after another, which is what you get from concatenating
|
|
7918
|
+
* the output of "seekrit archive share".
|
|
7919
|
+
*/
|
|
7920
|
+
async function unlockShares(archive, text) {
|
|
7921
|
+
var shares = [];
|
|
7922
|
+
var pattern = /"share"\\s*:\\s*"([0-9a-f]+)"/g;
|
|
7923
|
+
var match = pattern.exec(text);
|
|
7924
|
+
while (match !== null) {
|
|
7925
|
+
shares.push(hexToBytes(match[1]));
|
|
7926
|
+
match = pattern.exec(text);
|
|
7927
|
+
}
|
|
7928
|
+
if (shares.length === 0) throw new Error("no shares found in that text");
|
|
7929
|
+
var jwk = fromUtf8(combineShares(shares));
|
|
7930
|
+
return {
|
|
7931
|
+
principalId: archive.manifest.org.id,
|
|
7932
|
+
privateKey: await importPrivateJwk(jwk),
|
|
7933
|
+
how: shares.length + " custodian shares",
|
|
7934
|
+
};
|
|
7935
|
+
}
|
|
7936
|
+
|
|
7937
|
+
function envLabel(archive, env) {
|
|
7938
|
+
var i;
|
|
7939
|
+
if (env.groupId) {
|
|
7940
|
+
for (i = 0; i < archive.data.groups.length; i++) {
|
|
7941
|
+
if (archive.data.groups[i].id === env.groupId) {
|
|
7942
|
+
return archive.data.groups[i].slug + "@" + env.slug;
|
|
7943
|
+
}
|
|
7944
|
+
}
|
|
7945
|
+
return env.groupId + "@" + env.slug;
|
|
7946
|
+
}
|
|
7947
|
+
for (i = 0; i < archive.data.applications.length; i++) {
|
|
7948
|
+
if (archive.data.applications[i].id === env.applicationId) {
|
|
7949
|
+
return archive.data.applications[i].slug + "/" + env.slug;
|
|
7950
|
+
}
|
|
7951
|
+
}
|
|
7952
|
+
return env.applicationId + "/" + env.slug;
|
|
7953
|
+
}
|
|
7954
|
+
|
|
7955
|
+
async function decryptAll(archive, key) {
|
|
7956
|
+
var environments = [];
|
|
7957
|
+
var skipped = [];
|
|
7958
|
+
for (var e = 0; e < archive.data.environments.length; e++) {
|
|
7959
|
+
var env = archive.data.environments[e];
|
|
7960
|
+
var dek = null;
|
|
7961
|
+
for (var g = 0; g < archive.data.environmentKeys.length; g++) {
|
|
7962
|
+
var grant = archive.data.environmentKeys[g];
|
|
7963
|
+
if (grant.environmentId !== env.id) continue;
|
|
7964
|
+
if (key.principalId !== null && grant.principalId !== key.principalId) continue;
|
|
7965
|
+
try {
|
|
7966
|
+
dek = await unwrap(grant.wrappedDek, key.privateKey);
|
|
7967
|
+
break;
|
|
7968
|
+
} catch (err) {
|
|
7969
|
+
// Not this key's grant. A principal with no grant here is normal.
|
|
7970
|
+
}
|
|
7971
|
+
}
|
|
7972
|
+
if (dek === null) {
|
|
7973
|
+
skipped.push(envLabel(archive, env));
|
|
7974
|
+
continue;
|
|
7975
|
+
}
|
|
7976
|
+
var values = [];
|
|
7977
|
+
var failures = [];
|
|
7978
|
+
for (var s = 0; s < archive.data.secrets.length; s++) {
|
|
7979
|
+
var secret = archive.data.secrets[s];
|
|
7980
|
+
if (secret.environmentId !== env.id) continue;
|
|
7981
|
+
try {
|
|
7982
|
+
values.push({
|
|
7983
|
+
name: secret.name,
|
|
7984
|
+
value: await decryptSecret(
|
|
7985
|
+
dek,
|
|
7986
|
+
secret.ciphertext,
|
|
7987
|
+
secret.environmentId + "/" + secret.name
|
|
7988
|
+
),
|
|
7989
|
+
});
|
|
7990
|
+
} catch (err) {
|
|
7991
|
+
failures.push(secret.name);
|
|
7992
|
+
}
|
|
7993
|
+
}
|
|
7994
|
+
values.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
|
|
7995
|
+
environments.push({ label: envLabel(archive, env), values: values, failures: failures });
|
|
7996
|
+
}
|
|
7997
|
+
return { environments: environments, skipped: skipped };
|
|
7998
|
+
}
|
|
7999
|
+
|
|
8000
|
+
/**
|
|
8001
|
+
* dotenv quoting, mirroring packages/core/src/dotenv.ts — single quotes when
|
|
8002
|
+
* they are safe (literal, so a JSON credential survives untouched), double
|
|
8003
|
+
* quotes with written-out escapes otherwise.
|
|
8004
|
+
*/
|
|
8005
|
+
function needsQuoting(value) {
|
|
8006
|
+
return /[\\s"'\`$\\\\#]/.test(value) || value === "";
|
|
8007
|
+
}
|
|
8008
|
+
function dotenvQuote(value) {
|
|
8009
|
+
if (!needsQuoting(value)) return value;
|
|
8010
|
+
if (value.indexOf("'") === -1 && !/[\\n\\r]/.test(value)) return "'" + value + "'";
|
|
8011
|
+
return (
|
|
8012
|
+
'"' +
|
|
8013
|
+
value
|
|
8014
|
+
.replace(/\\\\/g, "\\\\\\\\")
|
|
8015
|
+
.replace(/"/g, '\\\\"')
|
|
8016
|
+
.replace(/\\n/g, "\\\\n")
|
|
8017
|
+
.replace(/\\r/g, "\\\\r") +
|
|
8018
|
+
'"'
|
|
8019
|
+
);
|
|
8020
|
+
}
|
|
8021
|
+
function toDotenv(values) {
|
|
8022
|
+
return values
|
|
8023
|
+
.map(function (entry) { return entry.name + "=" + dotenvQuote(entry.value); })
|
|
8024
|
+
.join("\\n");
|
|
8025
|
+
}
|
|
8026
|
+
|
|
8027
|
+
var api = {
|
|
8028
|
+
canonicalJson: canonicalJson,
|
|
8029
|
+
verifyArchive: verifyArchive,
|
|
8030
|
+
unlockPassphrase: unlockPassphrase,
|
|
8031
|
+
unlockToken: unlockToken,
|
|
8032
|
+
unlockJwk: unlockJwk,
|
|
8033
|
+
unlockShares: unlockShares,
|
|
8034
|
+
decryptAll: decryptAll,
|
|
8035
|
+
toDotenv: toDotenv,
|
|
8036
|
+
};
|
|
8037
|
+
globalThis.seekritOffline = api;
|
|
8038
|
+
|
|
8039
|
+
// Everything above is pure and runs headless; the test suite drives it through
|
|
8040
|
+
// globalThis.seekritOffline. Only what follows needs a document.
|
|
8041
|
+
if (typeof document === "undefined") return;
|
|
8042
|
+
|
|
8043
|
+
var archive = null;
|
|
8044
|
+
var el = function (id) { return document.getElementById(id); };
|
|
8045
|
+
var text = function (value) { return document.createTextNode(String(value)); };
|
|
8046
|
+
|
|
8047
|
+
function node(tag, className, content) {
|
|
8048
|
+
var element = document.createElement(tag);
|
|
8049
|
+
if (className) element.className = className;
|
|
8050
|
+
if (content !== undefined) element.appendChild(text(content));
|
|
8051
|
+
return element;
|
|
8052
|
+
}
|
|
8053
|
+
|
|
8054
|
+
function setStatus(message, kind) {
|
|
8055
|
+
var status = el("status");
|
|
8056
|
+
status.className = kind ? kind : "muted";
|
|
8057
|
+
status.textContent = message;
|
|
8058
|
+
}
|
|
8059
|
+
|
|
8060
|
+
async function loadArchive(fileText) {
|
|
8061
|
+
try {
|
|
8062
|
+
archive = JSON.parse(fileText);
|
|
8063
|
+
} catch (err) {
|
|
8064
|
+
archive = null;
|
|
8065
|
+
setStatus("that file is not JSON", "bad");
|
|
8066
|
+
return;
|
|
8067
|
+
}
|
|
8068
|
+
if (!archive || archive.format !== "seekrit-archive/v1" || !archive.manifest) {
|
|
8069
|
+
archive = null;
|
|
8070
|
+
el("manifest").className = "panel bad";
|
|
8071
|
+
el("manifest").textContent = "not a seekrit-archive/v1 file";
|
|
8072
|
+
return;
|
|
8073
|
+
}
|
|
8074
|
+
var check = await verifyArchive(archive);
|
|
8075
|
+
var panel = el("manifest");
|
|
8076
|
+
panel.className = "panel";
|
|
8077
|
+
panel.textContent = "";
|
|
8078
|
+
|
|
8079
|
+
var list = document.createElement("dl");
|
|
8080
|
+
list.className = "kv";
|
|
8081
|
+
var rows = [
|
|
8082
|
+
["organization", archive.manifest.org.name + " (" + archive.manifest.org.slug + ")"],
|
|
8083
|
+
["created", archive.manifest.createdAt],
|
|
8084
|
+
["exported by", archive.manifest.requestedBy.label || archive.manifest.requestedBy.actorId],
|
|
8085
|
+
["producer", archive.manifest.producer.service + " / " + archive.manifest.producer.environment],
|
|
8086
|
+
["environments", String(sectionCount(archive.data.environments))],
|
|
8087
|
+
["secrets", String(sectionCount(archive.data.secrets))],
|
|
8088
|
+
["key grants", String(sectionCount(archive.data.environmentKeys))],
|
|
8089
|
+
["integrity", check.integrityOk ? "every section matches its digest" : "FAILED"],
|
|
8090
|
+
[
|
|
8091
|
+
"signature",
|
|
8092
|
+
check.signature === "valid"
|
|
8093
|
+
? "valid, key " + archive.signature.keyId
|
|
8094
|
+
: check.signature + (check.signatureNote ? " (" + check.signatureNote + ")" : ""),
|
|
8095
|
+
],
|
|
8096
|
+
];
|
|
8097
|
+
rows.forEach(function (row) {
|
|
8098
|
+
list.appendChild(node("dt", null, row[0]));
|
|
8099
|
+
var value = node("dd", null, row[1]);
|
|
8100
|
+
if (row[0] === "integrity") value.className = check.integrityOk ? "ok" : "bad";
|
|
8101
|
+
if (row[0] === "signature") {
|
|
8102
|
+
value.className =
|
|
8103
|
+
check.signature === "valid" ? "ok" : check.signature === "invalid" ? "bad" : "warn";
|
|
8104
|
+
}
|
|
8105
|
+
list.appendChild(value);
|
|
8106
|
+
});
|
|
8107
|
+
panel.appendChild(list);
|
|
8108
|
+
|
|
8109
|
+
if (check.problems.length > 0) {
|
|
8110
|
+
var problems = document.createElement("ul");
|
|
8111
|
+
check.problems.forEach(function (problem) {
|
|
8112
|
+
problems.appendChild(node("li", "bad", problem));
|
|
8113
|
+
});
|
|
8114
|
+
panel.appendChild(problems);
|
|
8115
|
+
}
|
|
8116
|
+
if (check.truncated.length > 0) {
|
|
8117
|
+
panel.appendChild(
|
|
8118
|
+
node("p", "warn", "truncated sections: " + check.truncated.join(", "))
|
|
8119
|
+
);
|
|
8120
|
+
}
|
|
8121
|
+
panel.classList.remove("hidden");
|
|
8122
|
+
el("step2").classList.remove("hidden");
|
|
8123
|
+
el("key-owner").textContent = archive.data.keyMaterial
|
|
8124
|
+
? "unlocks " + archive.data.keyMaterial.principalType + " " + archive.data.keyMaterial.principalId
|
|
8125
|
+
: "this archive carries no key material - use another method";
|
|
8126
|
+
setStatus("");
|
|
8127
|
+
}
|
|
8128
|
+
|
|
8129
|
+
function showResults(result, key) {
|
|
8130
|
+
var container = el("results");
|
|
8131
|
+
container.textContent = "";
|
|
8132
|
+
container.appendChild(node("h2", null, "3 - plaintext"));
|
|
8133
|
+
container.appendChild(
|
|
8134
|
+
node("p", "muted", "unlocked with " + key.how + ". Values are as stored: a \${REF} reference is expanded when an app reads it, not here.")
|
|
8135
|
+
);
|
|
8136
|
+
|
|
8137
|
+
result.environments.forEach(function (entry) {
|
|
8138
|
+
var panel = node("div", "panel env");
|
|
8139
|
+
panel.appendChild(node("strong", null, entry.label));
|
|
8140
|
+
panel.appendChild(
|
|
8141
|
+
node("span", "muted", " " + entry.values.length + (entry.values.length === 1 ? " secret" : " secrets"))
|
|
8142
|
+
);
|
|
8143
|
+
|
|
8144
|
+
var table = document.createElement("table");
|
|
8145
|
+
var head = document.createElement("tr");
|
|
8146
|
+
head.appendChild(node("th", null, "name"));
|
|
8147
|
+
head.appendChild(node("th", null, "value"));
|
|
8148
|
+
table.appendChild(head);
|
|
8149
|
+
entry.values.forEach(function (item) {
|
|
8150
|
+
var row = document.createElement("tr");
|
|
8151
|
+
row.appendChild(node("td", null, item.name));
|
|
8152
|
+
var cell = node("td", "value");
|
|
8153
|
+
var masked = node("span", null, "•".repeat(Math.min(24, Math.max(6, item.value.length))));
|
|
8154
|
+
var reveal = node("button", null, "reveal");
|
|
8155
|
+
reveal.style.marginLeft = "0.5rem";
|
|
8156
|
+
reveal.addEventListener("click", function () {
|
|
8157
|
+
if (reveal.textContent === "reveal") {
|
|
8158
|
+
masked.textContent = item.value;
|
|
8159
|
+
reveal.textContent = "hide";
|
|
8160
|
+
} else {
|
|
8161
|
+
masked.textContent = "•".repeat(Math.min(24, Math.max(6, item.value.length)));
|
|
8162
|
+
reveal.textContent = "reveal";
|
|
8163
|
+
}
|
|
8164
|
+
});
|
|
8165
|
+
cell.appendChild(masked);
|
|
8166
|
+
cell.appendChild(reveal);
|
|
8167
|
+
row.appendChild(cell);
|
|
8168
|
+
table.appendChild(row);
|
|
8169
|
+
});
|
|
8170
|
+
panel.appendChild(table);
|
|
8171
|
+
|
|
8172
|
+
if (entry.failures.length > 0) {
|
|
8173
|
+
panel.appendChild(node("p", "bad", "failed to decrypt: " + entry.failures.join(", ")));
|
|
8174
|
+
}
|
|
8175
|
+
|
|
8176
|
+
var area = document.createElement("textarea");
|
|
8177
|
+
area.readOnly = true;
|
|
8178
|
+
area.spellcheck = false;
|
|
8179
|
+
area.className = "hidden";
|
|
8180
|
+
area.value = toDotenv(entry.values);
|
|
8181
|
+
|
|
8182
|
+
var show = node("button", null, "show as .env");
|
|
8183
|
+
show.addEventListener("click", function () {
|
|
8184
|
+
area.classList.toggle("hidden");
|
|
8185
|
+
show.textContent = area.classList.contains("hidden") ? "show as .env" : "hide .env";
|
|
8186
|
+
});
|
|
8187
|
+
var copy = node("button", null, "copy .env");
|
|
8188
|
+
copy.addEventListener("click", function () {
|
|
8189
|
+
area.classList.remove("hidden");
|
|
8190
|
+
area.select();
|
|
8191
|
+
try {
|
|
8192
|
+
document.execCommand("copy");
|
|
8193
|
+
copy.textContent = "copied";
|
|
8194
|
+
} catch (err) {
|
|
8195
|
+
copy.textContent = "select and copy";
|
|
8196
|
+
}
|
|
8197
|
+
});
|
|
8198
|
+
var actions = node("div", "row");
|
|
8199
|
+
actions.style.marginTop = "0.75rem";
|
|
8200
|
+
actions.appendChild(show);
|
|
8201
|
+
actions.appendChild(copy);
|
|
8202
|
+
panel.appendChild(actions);
|
|
8203
|
+
panel.appendChild(area);
|
|
8204
|
+
container.appendChild(panel);
|
|
8205
|
+
});
|
|
8206
|
+
|
|
8207
|
+
if (result.skipped.length > 0) {
|
|
8208
|
+
container.appendChild(
|
|
8209
|
+
node(
|
|
8210
|
+
"p",
|
|
8211
|
+
"muted",
|
|
8212
|
+
"skipped " + result.skipped.length +
|
|
8213
|
+
(result.skipped.length === 1 ? " environment" : " environments") +
|
|
8214
|
+
" this key holds no grant on: " + result.skipped.join(", ")
|
|
8215
|
+
)
|
|
8216
|
+
);
|
|
8217
|
+
}
|
|
8218
|
+
if (result.environments.length === 0) {
|
|
8219
|
+
container.appendChild(
|
|
8220
|
+
node("p", "bad", "this key opens none of the environments in the archive")
|
|
8221
|
+
);
|
|
8222
|
+
}
|
|
8223
|
+
}
|
|
8224
|
+
|
|
8225
|
+
el("file").addEventListener("change", function (event) {
|
|
8226
|
+
var file = event.target.files && event.target.files[0];
|
|
8227
|
+
if (!file) return;
|
|
8228
|
+
var reader = new FileReader();
|
|
8229
|
+
reader.onload = function () { loadArchive(String(reader.result)); };
|
|
8230
|
+
reader.readAsText(file);
|
|
8231
|
+
});
|
|
8232
|
+
|
|
8233
|
+
var drop = el("drop");
|
|
8234
|
+
["dragenter", "dragover"].forEach(function (name) {
|
|
8235
|
+
drop.addEventListener(name, function (event) {
|
|
8236
|
+
event.preventDefault();
|
|
8237
|
+
drop.classList.add("over");
|
|
8238
|
+
});
|
|
8239
|
+
});
|
|
8240
|
+
["dragleave", "drop"].forEach(function (name) {
|
|
8241
|
+
drop.addEventListener(name, function (event) {
|
|
8242
|
+
event.preventDefault();
|
|
8243
|
+
drop.classList.remove("over");
|
|
8244
|
+
});
|
|
8245
|
+
});
|
|
8246
|
+
drop.addEventListener("drop", function (event) {
|
|
8247
|
+
var file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
|
|
8248
|
+
if (!file) return;
|
|
8249
|
+
var reader = new FileReader();
|
|
8250
|
+
reader.onload = function () { loadArchive(String(reader.result)); };
|
|
8251
|
+
reader.readAsText(file);
|
|
8252
|
+
});
|
|
8253
|
+
|
|
8254
|
+
el("method").addEventListener("change", function () {
|
|
8255
|
+
var method = el("method").value;
|
|
8256
|
+
["passphrase", "token", "jwk", "shares"].forEach(function (name) {
|
|
8257
|
+
el("field-" + name).classList.toggle("hidden", name !== method);
|
|
8258
|
+
});
|
|
8259
|
+
});
|
|
8260
|
+
|
|
8261
|
+
el("decrypt").addEventListener("click", async function () {
|
|
8262
|
+
if (!archive) {
|
|
8263
|
+
setStatus("load an archive first", "warn");
|
|
8264
|
+
return;
|
|
8265
|
+
}
|
|
8266
|
+
setStatus("working...");
|
|
8267
|
+
try {
|
|
8268
|
+
var method = el("method").value;
|
|
8269
|
+
var key;
|
|
8270
|
+
if (method === "passphrase") key = await unlockPassphrase(archive, el("passphrase").value);
|
|
8271
|
+
else if (method === "token") key = await unlockToken(el("token").value);
|
|
8272
|
+
else if (method === "jwk") key = await unlockJwk(el("jwk").value);
|
|
8273
|
+
else key = await unlockShares(archive, el("shares").value);
|
|
8274
|
+
var result = await decryptAll(archive, key);
|
|
8275
|
+
showResults(result, key);
|
|
8276
|
+
setStatus(
|
|
8277
|
+
"decrypted " + result.environments.length +
|
|
8278
|
+
(result.environments.length === 1 ? " environment" : " environments"),
|
|
8279
|
+
"ok"
|
|
8280
|
+
);
|
|
8281
|
+
} catch (err) {
|
|
8282
|
+
setStatus(err && err.message ? err.message : "could not decrypt", "bad");
|
|
8283
|
+
}
|
|
8284
|
+
});
|
|
8285
|
+
})();
|
|
8286
|
+
<\/script>
|
|
8287
|
+
</body>
|
|
8288
|
+
</html>
|
|
8289
|
+
`;
|
|
8290
|
+
//#endregion
|
|
8291
|
+
//#region src/format.ts
|
|
8292
|
+
function shellQuote(value) {
|
|
8293
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
8294
|
+
}
|
|
8295
|
+
function formatSecrets(values, format) {
|
|
8296
|
+
const names = Object.keys(values).sort();
|
|
8297
|
+
switch (format) {
|
|
8298
|
+
case "json": return JSON.stringify(values, names, 2);
|
|
8299
|
+
case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
|
|
8300
|
+
case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
|
|
8301
|
+
}
|
|
8302
|
+
}
|
|
8303
|
+
//#endregion
|
|
8304
|
+
//#region src/archive.ts
|
|
8305
|
+
/**
|
|
8306
|
+
* Break-glass archives (docs/break-glass-export.md).
|
|
8307
|
+
*
|
|
8308
|
+
* `create` is the only subcommand that talks to the API. `info`, `verify`,
|
|
8309
|
+
* `share`, `decrypt`, and `decryptor` are **strictly offline**: they never build
|
|
8310
|
+
* a client, never read credentials, and never touch the network, because the day
|
|
8311
|
+
* you need them is the day seekrit may not be there. Keep it that way — a
|
|
8312
|
+
* `buildContext()` in any of them silently breaks the promise the feature makes.
|
|
8313
|
+
*/
|
|
8314
|
+
/** Collect a repeatable option into a list. */
|
|
8315
|
+
function collect$5(value, acc = []) {
|
|
8316
|
+
acc.push(value);
|
|
8317
|
+
return acc;
|
|
8318
|
+
}
|
|
8319
|
+
/** "1 secret" / "2 secrets" — these lines are read by people, not parsed. */
|
|
8320
|
+
function count(n, noun) {
|
|
8321
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
8322
|
+
}
|
|
8323
|
+
function readArchiveFile(path) {
|
|
8324
|
+
let text;
|
|
8325
|
+
try {
|
|
8326
|
+
text = readFileSync(path, "utf8");
|
|
8327
|
+
} catch (err) {
|
|
8328
|
+
return fail(`cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8329
|
+
}
|
|
8330
|
+
try {
|
|
8331
|
+
return parseArchive(text);
|
|
8332
|
+
} catch (err) {
|
|
8333
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
8334
|
+
}
|
|
8335
|
+
}
|
|
8336
|
+
function writeOut(path, contents) {
|
|
8337
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
8338
|
+
writeFileSync(path, contents, { mode: 384 });
|
|
8339
|
+
}
|
|
8340
|
+
/** One line per problem, for a verification that failed. */
|
|
8341
|
+
function verificationProblems(result) {
|
|
8342
|
+
const problems = [];
|
|
8343
|
+
if (!result.manifestDigestOk) problems.push("the manifest's digest does not cover its sections");
|
|
8344
|
+
for (const name of result.badSections) problems.push(`section "${name}" does not match its digest`);
|
|
8345
|
+
for (const name of result.missingSections) problems.push(`section "${name}" is missing`);
|
|
8346
|
+
for (const name of result.undeclaredSections) problems.push(`section "${name}" is present but undeclared`);
|
|
8347
|
+
if (result.signature === "invalid") problems.push(`signature is invalid${result.signatureNote ? ` (${result.signatureNote})` : ""}`);
|
|
8348
|
+
if (result.signature === "unsigned") problems.push("archive is unsigned — integrity is checkable, provenance is not");
|
|
8349
|
+
if (result.signature === "unverifiable") problems.push(`signature could not be checked: ${result.signatureNote ?? "unknown reason"}`);
|
|
8350
|
+
return problems;
|
|
8351
|
+
}
|
|
8352
|
+
function printVerification(result) {
|
|
8353
|
+
printFields([
|
|
8354
|
+
["integrity", result.badSections.length === 0 && result.manifestDigestOk ? "ok" : "FAILED"],
|
|
8355
|
+
["signature", result.signature],
|
|
8356
|
+
["truncated", result.truncatedSections.length > 0 ? result.truncatedSections.join(", ") : "no"]
|
|
8357
|
+
]);
|
|
8358
|
+
const problems = verificationProblems(result);
|
|
8359
|
+
if (problems.length > 0) {
|
|
8360
|
+
section("problems");
|
|
8361
|
+
for (const problem of problems) console.log(`- ${problem}`);
|
|
8362
|
+
}
|
|
8363
|
+
}
|
|
8364
|
+
/** `app/env`, or `group@env` for a group-owned environment. */
|
|
8365
|
+
function envLabel(archive, env) {
|
|
8366
|
+
if (env.groupId) return `${archive.data.groups.find((g) => g.id === env.groupId)?.slug ?? env.groupId}@${env.slug}`;
|
|
8367
|
+
return `${archive.data.applications.find((a) => a.id === env.applicationId)?.slug ?? env.applicationId}/${env.slug}`;
|
|
8368
|
+
}
|
|
8369
|
+
/** Filesystem path for an environment's output file, mirroring its label. */
|
|
8370
|
+
function envPath(archive, env, extension) {
|
|
8371
|
+
if (env.groupId) return join("groups", archive.data.groups.find((g) => g.id === env.groupId)?.slug ?? env.groupId, `${env.slug}.${extension}`);
|
|
8372
|
+
return join("apps", archive.data.applications.find((a) => a.id === env.applicationId)?.slug ?? env.applicationId ?? "unknown", `${env.slug}.${extension}`);
|
|
8373
|
+
}
|
|
8374
|
+
/**
|
|
8375
|
+
* Recover a private key to open the archive with, from (in order) a service
|
|
8376
|
+
* token, a private-key JWK file, a custodian quorum, or the archive's own
|
|
8377
|
+
* `keyMaterial` plus a passphrase.
|
|
8378
|
+
*/
|
|
8379
|
+
async function resolveDecryptKey(archive, options) {
|
|
8380
|
+
if (options.token) {
|
|
8381
|
+
if (!isServiceToken(options.token)) fail("--token expects a `skt_…` service token");
|
|
8382
|
+
const { tokenId, privateKey } = await parseServiceToken(options.token);
|
|
8383
|
+
return {
|
|
8384
|
+
privateKey,
|
|
8385
|
+
principalId: tokenId,
|
|
8386
|
+
how: `service token ${tokenId}`
|
|
8387
|
+
};
|
|
8388
|
+
}
|
|
8389
|
+
if (options.keyFile) {
|
|
8390
|
+
let jwk;
|
|
8391
|
+
try {
|
|
8392
|
+
jwk = readFileSync(options.keyFile, "utf8").trim();
|
|
8393
|
+
} catch (err) {
|
|
8394
|
+
fail(`cannot read ${options.keyFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8395
|
+
}
|
|
8396
|
+
return {
|
|
8397
|
+
privateKey: await importPrivateKey(jwk).catch(() => fail(`${options.keyFile} is not a P-256 private key JWK`)),
|
|
8398
|
+
principalId: null,
|
|
8399
|
+
how: `private key from ${options.keyFile}`
|
|
8400
|
+
};
|
|
8401
|
+
}
|
|
8402
|
+
if (options.share && options.share.length > 0) {
|
|
8403
|
+
const shares = options.share.map((path) => readShareFile(path));
|
|
8404
|
+
return {
|
|
8405
|
+
privateKey: await combineRecoveryShares(shares).catch((err) => fail(`could not reconstruct the recovery key from ${count(shares.length, "share")}: ${err instanceof Error ? err.message : String(err)}`)),
|
|
8406
|
+
principalId: archive.manifest.org.id,
|
|
8407
|
+
how: count(shares.length, "custodian share")
|
|
8408
|
+
};
|
|
8409
|
+
}
|
|
8410
|
+
const keyMaterial = archive.data.keyMaterial;
|
|
8411
|
+
if (!keyMaterial) fail("this archive carries no key material — decrypt it with --token, --key-file, or a custodian quorum (--share)");
|
|
8412
|
+
return {
|
|
8413
|
+
privateKey: await importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden(`Passphrase for ${keyMaterial.principalId}: `), keyMaterial.encryptedPrivateKey).catch((err) => fail(err instanceof Error ? err.message : String(err)))),
|
|
8414
|
+
principalId: keyMaterial.principalId,
|
|
8415
|
+
how: `${keyMaterial.principalType} ${keyMaterial.principalId} (passphrase)`
|
|
8416
|
+
};
|
|
8417
|
+
}
|
|
8418
|
+
const SHARE_FORMAT = "seekrit-recovery-share/v1";
|
|
8419
|
+
function readShareFile(path) {
|
|
8420
|
+
let parsed;
|
|
8421
|
+
try {
|
|
8422
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
8423
|
+
} catch (err) {
|
|
8424
|
+
return fail(`cannot read share ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8425
|
+
}
|
|
8426
|
+
if (parsed.format !== SHARE_FORMAT || typeof parsed.share !== "string") return fail(`${path} is not a ${SHARE_FORMAT} file`);
|
|
8427
|
+
try {
|
|
8428
|
+
return hexToBytes(parsed.share);
|
|
8429
|
+
} catch {
|
|
8430
|
+
return fail(`${path} holds a malformed share`);
|
|
8431
|
+
}
|
|
8432
|
+
}
|
|
8433
|
+
/**
|
|
8434
|
+
* Decrypt what one key can reach. Environments the key holds no grant on are
|
|
8435
|
+
* *skipped*, not failed: an archive spans the whole org and no single principal
|
|
8436
|
+
* is expected to open all of it.
|
|
8437
|
+
*/
|
|
8438
|
+
async function decryptArchive(archive, key, filter) {
|
|
8439
|
+
const environments = [];
|
|
8440
|
+
const skipped = [];
|
|
8441
|
+
const failures = [];
|
|
8442
|
+
for (const env of archive.data.environments) {
|
|
8443
|
+
const label = envLabel(archive, env);
|
|
8444
|
+
if (filter && label !== filter && env.slug !== filter && env.id !== filter) continue;
|
|
8445
|
+
const grants = archive.data.environmentKeys.filter((grant) => grant.environmentId === env.id && (key.principalId === null || grant.principalId === key.principalId));
|
|
8446
|
+
let dek = null;
|
|
8447
|
+
for (const grant of grants) try {
|
|
8448
|
+
dek = await unwrapDek(grant.wrappedDek, key.privateKey);
|
|
8449
|
+
break;
|
|
8450
|
+
} catch {}
|
|
8451
|
+
if (!dek) {
|
|
8452
|
+
skipped.push(label);
|
|
8453
|
+
continue;
|
|
8454
|
+
}
|
|
8455
|
+
const values = {};
|
|
8456
|
+
for (const secret of archive.data.secrets.filter((s) => s.environmentId === env.id)) try {
|
|
8457
|
+
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(secret.environmentId, secret.name));
|
|
8458
|
+
} catch (err) {
|
|
8459
|
+
failures.push(`${label} ${secret.name}: ${err instanceof Error ? err.message : "failed"}`);
|
|
8460
|
+
}
|
|
8461
|
+
environments.push({
|
|
8462
|
+
env,
|
|
8463
|
+
label,
|
|
8464
|
+
values
|
|
8465
|
+
});
|
|
8466
|
+
}
|
|
8467
|
+
return {
|
|
8468
|
+
environments,
|
|
8469
|
+
skipped,
|
|
8470
|
+
failures
|
|
8471
|
+
};
|
|
8472
|
+
}
|
|
8473
|
+
function registerArchiveCommands(program) {
|
|
8474
|
+
const archive = program.command("archive").description("export the whole org as one signed file, and open it offline");
|
|
8475
|
+
archive.command("create").description("download a signed archive of everything seekrit stores for the org").option("--org <slug>").option("-o, --out <file>", "write the archive here (default: ./seekrit-<org>-<date>.json)").option("--no-versions", "omit each secret's ciphertext history").option("--no-audit", "omit the audit trail").option("--audit-limit <n>", "keep at most this many of the newest audit rows", Number).option("--json", "print the archive to stdout instead of writing a file").action(async (options) => {
|
|
8476
|
+
const ctx = buildContext();
|
|
8477
|
+
const ref = await resolveOrg(ctx, options.org);
|
|
8478
|
+
const input = {
|
|
8479
|
+
includeVersions: options.versions,
|
|
8480
|
+
includeAudit: options.audit
|
|
8481
|
+
};
|
|
8482
|
+
if (options.auditLimit !== void 0) input.auditLimit = options.auditLimit;
|
|
8483
|
+
const result = await ctx.client.exportArchive(ref.id, input);
|
|
8484
|
+
if (options.json) {
|
|
8485
|
+
console.log(JSON.stringify(result, null, 2));
|
|
8486
|
+
return;
|
|
8487
|
+
}
|
|
8488
|
+
const stamp = result.manifest.createdAt.slice(0, 10);
|
|
8489
|
+
const path = options.out ?? `seekrit-${result.manifest.org.slug}-${stamp}.json`;
|
|
8490
|
+
writeOut(path, JSON.stringify(result, null, 2));
|
|
8491
|
+
const check = await verifyArchive(result);
|
|
8492
|
+
console.error(`wrote ${path}`);
|
|
8493
|
+
printTable(result.manifest.sections.filter((s) => s.count > 0), [
|
|
8494
|
+
{
|
|
8495
|
+
header: "section",
|
|
8496
|
+
value: (s) => s.name
|
|
8497
|
+
},
|
|
8498
|
+
{
|
|
8499
|
+
header: "rows",
|
|
8500
|
+
value: (s) => String(s.count)
|
|
8501
|
+
},
|
|
8502
|
+
{
|
|
8503
|
+
header: "truncated",
|
|
8504
|
+
value: (s) => s.truncated ? "yes" : ""
|
|
8505
|
+
}
|
|
8506
|
+
], "the archive is empty");
|
|
8507
|
+
console.error(check.ok ? `verified: digests ok, signed by key ${result.signature?.keyId}` : `WARNING: ${verificationProblems(check).join("; ")}`);
|
|
8508
|
+
console.error("keep it with `seekrit archive decryptor` — together they open without seekrit, offline.");
|
|
8509
|
+
});
|
|
8510
|
+
archive.command("info <file>").description("summarize an archive (offline)").option("--json", "print the manifest as JSON").action(async (file, options) => {
|
|
8511
|
+
const parsed = readArchiveFile(file);
|
|
8512
|
+
const check = await verifyArchive(parsed);
|
|
8513
|
+
emit(options, {
|
|
8514
|
+
manifest: parsed.manifest,
|
|
8515
|
+
verification: check
|
|
8516
|
+
}, () => {
|
|
8517
|
+
printFields([
|
|
8518
|
+
["archive", parsed.manifest.archiveId],
|
|
8519
|
+
["created", parsed.manifest.createdAt],
|
|
8520
|
+
["org", `${parsed.manifest.org.name} (${parsed.manifest.org.slug})`],
|
|
8521
|
+
["producer", `${parsed.manifest.producer.service} / ${parsed.manifest.producer.environment}`],
|
|
8522
|
+
["format", parsed.manifest.producer.formatVersion],
|
|
8523
|
+
["requested by", parsed.manifest.requestedBy.label ?? parsed.manifest.requestedBy.actorId],
|
|
8524
|
+
["key material", parsed.data.keyMaterial ? parsed.data.keyMaterial.principalId : "none"],
|
|
8525
|
+
["signature", parsed.signature ? `${parsed.signature.algorithm} / ${parsed.signature.keyId}` : "unsigned"]
|
|
8526
|
+
]);
|
|
8527
|
+
section("sections");
|
|
8528
|
+
printTable(parsed.manifest.sections, [
|
|
8529
|
+
{
|
|
8530
|
+
header: "section",
|
|
8531
|
+
value: (s) => s.name
|
|
8532
|
+
},
|
|
8533
|
+
{
|
|
8534
|
+
header: "rows",
|
|
8535
|
+
value: (s) => String(s.count)
|
|
8536
|
+
},
|
|
8537
|
+
{
|
|
8538
|
+
header: "truncated",
|
|
8539
|
+
value: (s) => s.truncated ? "yes" : ""
|
|
8540
|
+
}
|
|
8541
|
+
], "none");
|
|
8542
|
+
section("verification");
|
|
8543
|
+
printVerification(check);
|
|
8544
|
+
});
|
|
8545
|
+
});
|
|
8546
|
+
archive.command("verify <file>").description("check an archive's digests and signature (offline); exits non-zero if it fails").option("--key-id <id>", "require this signing key id (see /.well-known/seekrit-export-signing-key)").option("--skip-signature", "check digests only").option("--json", "print the verification result as JSON").action(async (file, options) => {
|
|
8547
|
+
const check = await verifyArchive(readArchiveFile(file), {
|
|
8548
|
+
expectKeyId: options.keyId,
|
|
8549
|
+
skipSignature: options.skipSignature
|
|
8550
|
+
});
|
|
8551
|
+
const integrityOk = check.badSections.length === 0 && check.missingSections.length === 0 && check.undeclaredSections.length === 0 && check.manifestDigestOk;
|
|
8552
|
+
const passed = options.skipSignature ? integrityOk : check.ok;
|
|
8553
|
+
emit(options, check, () => printVerification(check));
|
|
8554
|
+
if (!passed) process.exit(1);
|
|
8555
|
+
});
|
|
8556
|
+
archive.command("share <file>").description("unwrap your own recovery share from an archive, for an offline quorum (offline)").option("-o, --out <file>", "write the share here (default: stdout)").option("--token <skt_…>", "unwrap with a service token instead of a passphrase").option("--key-file <path>", "unwrap with a private key JWK file").action(async (file, options) => {
|
|
8557
|
+
const parsed = readArchiveFile(file);
|
|
8558
|
+
if (parsed.data.recoveryShares.length === 0) fail("this archive holds no recovery shares — customer-controlled recovery is not set up");
|
|
8559
|
+
const key = await resolveDecryptKey(parsed, {
|
|
8560
|
+
token: options.token,
|
|
8561
|
+
keyFile: options.keyFile
|
|
8562
|
+
});
|
|
8563
|
+
const candidates = parsed.data.recoveryShares.filter((share) => key.principalId === null || share.custodianId === key.principalId);
|
|
8564
|
+
for (const candidate of candidates) try {
|
|
8565
|
+
const bytes = await unwrapRecoveryShare(candidate.wrappedShare, key.privateKey);
|
|
8566
|
+
const payload = {
|
|
8567
|
+
format: SHARE_FORMAT,
|
|
8568
|
+
org: {
|
|
8569
|
+
id: parsed.manifest.org.id,
|
|
8570
|
+
slug: parsed.manifest.org.slug
|
|
8571
|
+
},
|
|
8572
|
+
custodianType: candidate.custodianType,
|
|
8573
|
+
custodianId: candidate.custodianId,
|
|
8574
|
+
shareIndex: candidate.shareIndex,
|
|
8575
|
+
share: bytesToHex(bytes),
|
|
8576
|
+
note: "Sensitive: a threshold of these shares reconstructs the org recovery key, which opens every environment. Destroy this file after the ceremony."
|
|
8577
|
+
};
|
|
8578
|
+
const text = JSON.stringify(payload, null, 2);
|
|
8579
|
+
if (options.out) {
|
|
8580
|
+
writeOut(options.out, text);
|
|
8581
|
+
console.error(`wrote share ${candidate.shareIndex} to ${options.out}`);
|
|
8582
|
+
} else console.log(text);
|
|
8583
|
+
console.error(`combine ${parsed.data.recoveryConfig?.threshold ?? "M"} shares with: seekrit archive decrypt ${file} --share … --share …`);
|
|
8584
|
+
return;
|
|
8585
|
+
} catch {}
|
|
8586
|
+
fail("none of the recovery shares in this archive unwrap with that key");
|
|
8587
|
+
});
|
|
8588
|
+
archive.command("decrypt <file>").description("decrypt an archive's secrets with your own key (offline)").option("-o, --out <dir>", "write one file per environment into this directory").option("--stdout", "print plaintext to stdout instead of writing files").option("--env <label>", "only this environment (app/env, group@env, slug, or id)").option("--format <format>", "dotenv | json | shell", "dotenv").option("--token <skt_…>", "decrypt as a service token instead of a passphrase").option("--key-file <path>", "decrypt with a private key JWK file").option("--share <file>", "custodian share for an offline quorum (repeatable)", collect$5).option("--yes", "skip the confirmation when printing plaintext to stdout").action(async (file, options) => {
|
|
8589
|
+
if (!options.out && !options.stdout) fail("choose a destination: --out <dir> to write files, or --stdout to print plaintext");
|
|
8590
|
+
if (![
|
|
8591
|
+
"dotenv",
|
|
8592
|
+
"json",
|
|
8593
|
+
"shell"
|
|
8594
|
+
].includes(options.format)) fail(`unknown --format "${options.format}" (dotenv | json | shell)`);
|
|
8595
|
+
const parsed = readArchiveFile(file);
|
|
8596
|
+
const check = await verifyArchive(parsed);
|
|
8597
|
+
if (check.badSections.length > 0 || !check.manifestDigestOk) fail(`refusing to decrypt: ${verificationProblems(check).join("; ")} — run \`seekrit archive verify ${file}\``);
|
|
8598
|
+
if (check.signature !== "valid") console.error(`warning: ${verificationProblems(check).join("; ")}`);
|
|
8599
|
+
if (options.stdout) await confirmDestructive(options.yes, "This prints decrypted secret values to stdout, where they may land in scrollback or CI logs. Continue?");
|
|
8600
|
+
const key = await resolveDecryptKey(parsed, options);
|
|
8601
|
+
const result = await decryptArchive(parsed, key, options.env);
|
|
8602
|
+
console.error(`unlocked with ${key.how}`);
|
|
8603
|
+
const extension = options.format === "json" ? "json" : options.format === "shell" ? "sh" : "env";
|
|
8604
|
+
let written = 0;
|
|
8605
|
+
for (const entry of result.environments) {
|
|
8606
|
+
const body = formatSecrets(entry.values, options.format);
|
|
8607
|
+
if (options.out) {
|
|
8608
|
+
const path = join(options.out, envPath(parsed, entry.env, extension));
|
|
8609
|
+
writeOut(path, `${body}\n`);
|
|
8610
|
+
written += 1;
|
|
8611
|
+
console.error(`${path} (${count(Object.keys(entry.values).length, "secret")})`);
|
|
8612
|
+
} else {
|
|
8613
|
+
console.log(`# ${entry.label}`);
|
|
8614
|
+
console.log(body);
|
|
8615
|
+
console.log("");
|
|
8616
|
+
}
|
|
8617
|
+
}
|
|
8618
|
+
if (result.skipped.length > 0) console.error(`skipped ${count(result.skipped.length, "environment")} this key holds no grant on: ${result.skipped.join(", ")}`);
|
|
8619
|
+
for (const failure of result.failures) console.error(`failed: ${failure}`);
|
|
8620
|
+
if (options.out) console.error(`wrote ${count(written, "file")} under ${options.out}`);
|
|
8621
|
+
if (result.environments.length === 0) fail("nothing decrypted — this key opens none of the environments in the archive");
|
|
8622
|
+
console.error(`note: values are as stored — \`\${REF}\` references are expanded at read time, not here.`);
|
|
8623
|
+
});
|
|
8624
|
+
archive.command("decryptor").description("write the standalone offline decryptor (a single HTML file, no install, no network)").option("-o, --out <file>", "where to write it", "seekrit-decrypt.html").action((options) => {
|
|
8625
|
+
writeOut(options.out, OFFLINE_DECRYPTOR_HTML);
|
|
8626
|
+
console.error(`wrote ${options.out}`);
|
|
8627
|
+
console.error("open it in any browser — it declares a Content-Security-Policy of `default-src 'none'`, so it cannot reach the network.");
|
|
8628
|
+
});
|
|
8629
|
+
}
|
|
8630
|
+
//#endregion
|
|
5900
8631
|
//#region src/audit.ts
|
|
5901
8632
|
/** The API's per-page ceiling (`auditQuerySchema.limit`). */
|
|
5902
8633
|
const MAX_PAGE = 200;
|
|
@@ -6423,19 +9154,6 @@ function message(err) {
|
|
|
6423
9154
|
return err instanceof Error ? err.message : String(err);
|
|
6424
9155
|
}
|
|
6425
9156
|
//#endregion
|
|
6426
|
-
//#region src/format.ts
|
|
6427
|
-
function shellQuote(value) {
|
|
6428
|
-
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
6429
|
-
}
|
|
6430
|
-
function formatSecrets(values, format) {
|
|
6431
|
-
const names = Object.keys(values).sort();
|
|
6432
|
-
switch (format) {
|
|
6433
|
-
case "json": return JSON.stringify(values, names, 2);
|
|
6434
|
-
case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
|
|
6435
|
-
case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
|
|
6436
|
-
}
|
|
6437
|
-
}
|
|
6438
|
-
//#endregion
|
|
6439
9157
|
//#region src/gcp.ts
|
|
6440
9158
|
/**
|
|
6441
9159
|
* `seekrit gcp` — temporary GCP credentials via IAM Credentials
|
|
@@ -7533,7 +10251,7 @@ function collect$2(value, acc) {
|
|
|
7533
10251
|
* release-please-config.json), so the pin follows the crate without anyone
|
|
7534
10252
|
* remembering to move it.
|
|
7535
10253
|
*/
|
|
7536
|
-
const PROXY_VERSION = "0.
|
|
10254
|
+
const PROXY_VERSION = "0.10.0";
|
|
7537
10255
|
const BIN = "seekrit-proxy";
|
|
7538
10256
|
/**
|
|
7539
10257
|
* Host → Rust target triple.
|
|
@@ -7569,7 +10287,7 @@ function versionPrefix(version) {
|
|
|
7569
10287
|
return version.startsWith("v") ? version : `v${version}`;
|
|
7570
10288
|
}
|
|
7571
10289
|
function resolveVersion(explicit) {
|
|
7572
|
-
return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.
|
|
10290
|
+
return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
|
|
7573
10291
|
}
|
|
7574
10292
|
function resolveBaseUrl(explicit) {
|
|
7575
10293
|
return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
|
|
@@ -7999,6 +10717,27 @@ function renderProxyConfig(plan) {
|
|
|
7999
10717
|
out.push(`max_ttl = ${tomlString(plan.control.maxTtl)}`);
|
|
8000
10718
|
out.push("");
|
|
8001
10719
|
}
|
|
10720
|
+
if (plan.tasks) {
|
|
10721
|
+
out.push("# Honour tasks dispatched through the API (`seekrit agents dispatch`): a run");
|
|
10722
|
+
out.push("# presents its skd_… token in the same header a local ticket uses, and this");
|
|
10723
|
+
out.push("# proxy asks the API what it authorizes. Opt-in, because it makes authorizing");
|
|
10724
|
+
out.push("# a new run depend on reaching seekrit — a refused or unreachable check denies");
|
|
10725
|
+
out.push("# the request rather than admitting it.");
|
|
10726
|
+
out.push("#");
|
|
10727
|
+
out.push("# cache_ttl bounds how long a revoked run keeps working. Short on purpose.");
|
|
10728
|
+
out.push("[tasks]");
|
|
10729
|
+
out.push(`cache_ttl = ${tomlString(plan.tasks.cacheTtl)}`);
|
|
10730
|
+
out.push("");
|
|
10731
|
+
}
|
|
10732
|
+
if (plan.activity) {
|
|
10733
|
+
out.push("# Report aggregate decisions back, so `seekrit agents review` can compare this");
|
|
10734
|
+
out.push("# policy against what the agent actually does. Counts only: hosts, methods,");
|
|
10735
|
+
out.push("# secret *names*, and which rule decided — never a request path, never a value.");
|
|
10736
|
+
out.push("# Full per-request detail stays in your own OTLP collector.");
|
|
10737
|
+
out.push("[activity]");
|
|
10738
|
+
out.push(`flush_interval = ${tomlString(plan.activity.flushInterval)}`);
|
|
10739
|
+
out.push("");
|
|
10740
|
+
}
|
|
8002
10741
|
if (plan.envHints.length > 0) {
|
|
8003
10742
|
out.push("# ---------------------------------------------------------------------------");
|
|
8004
10743
|
out.push("# Point the workload at the proxy (these go in its environment, not here):");
|
|
@@ -8077,6 +10816,8 @@ function planFromPresets(presets, options) {
|
|
|
8077
10816
|
...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
|
|
8078
10817
|
...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
|
|
8079
10818
|
...options.control ? { control: options.control } : {},
|
|
10819
|
+
...options.tasks ? { tasks: options.tasks } : {},
|
|
10820
|
+
...options.activity ? { activity: options.activity } : {},
|
|
8080
10821
|
envHints,
|
|
8081
10822
|
notes
|
|
8082
10823
|
};
|
|
@@ -8142,6 +10883,8 @@ function planFromPolicy(args, options) {
|
|
|
8142
10883
|
caKey: options.caKey,
|
|
8143
10884
|
...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
|
|
8144
10885
|
...options.control ? { control: options.control } : {},
|
|
10886
|
+
...options.tasks ? { tasks: options.tasks } : {},
|
|
10887
|
+
...options.activity ? { activity: options.activity } : {},
|
|
8145
10888
|
envHints,
|
|
8146
10889
|
notes
|
|
8147
10890
|
};
|
|
@@ -8300,7 +11043,9 @@ function planOptions(options) {
|
|
|
8300
11043
|
listen: options.control,
|
|
8301
11044
|
ttl: "1h",
|
|
8302
11045
|
maxTtl: "12h"
|
|
8303
|
-
} } : {}
|
|
11046
|
+
} } : {},
|
|
11047
|
+
...options.tasks || options.tasksCacheTtl ? { tasks: { cacheTtl: duration(options.tasksCacheTtl, "--tasks-cache-ttl") ?? "30s" } } : {},
|
|
11048
|
+
...options.activity || options.activityInterval ? { activity: { flushInterval: duration(options.activityInterval, "--activity-interval") ?? "60s" } } : {}
|
|
8304
11049
|
};
|
|
8305
11050
|
}
|
|
8306
11051
|
/**
|
|
@@ -8419,7 +11164,7 @@ async function buildPlan(options) {
|
|
|
8419
11164
|
}
|
|
8420
11165
|
/** Add the generation flags to a command, so `init` and `run` stay in step. */
|
|
8421
11166
|
function withGenerateOptions(cmd) {
|
|
8422
|
-
return cmd.option("--preset <name>", "gateway preset (repeatable; see `seekrit proxy presets`)", (value, acc = []) => [...acc, value]).option("--host <host[=SECRET,…]>", "ad-hoc rule: bare hostname, optionally the secrets it may receive (repeatable)", (value, acc = []) => [...acc, value]).option("--base-url <url>", "upstream base URL for an OpenAI-compatible gateway").option("--secret <NAME>", "override a preset's secret name").option("--prefix <path>", "override a preset's route prefix").option("--agent <slug>", "take the rules from published agent policy (server mode)").option("--agents <slug>", "additional identities this proxy may serve", (v, a = []) => [...a, v]).option("--org <slug>", "organization (for --agent)").option("--mode <reverse|forward|both>", "which data plane(s) to configure", "reverse").option("--listen <addr>", `reverse-proxy address (default: ${PLAN_DEFAULTS.listen})`).option("--forward-listen <addr>", `forward-proxy address (default: ${PLAN_DEFAULTS.forwardListen})`).option("--unmatched <tunnel|deny>", "what to do with an unruled host in forward mode").option("--ca-cert <path>", "interception CA certificate path (forward mode)").option("--ca-key <path>", "interception CA key path (forward mode)").option("--cache", "add a [cache] block so the proxy can start during an outage").option("--cache-max-age <dur>", "how stale a cached resolve may be (implies --cache)").option("--refresh <dur>", "re-resolve/re-fetch interval").option("--control <addr>", "add a [control] listener for per-agent session tickets");
|
|
11167
|
+
return cmd.option("--preset <name>", "gateway preset (repeatable; see `seekrit proxy presets`)", (value, acc = []) => [...acc, value]).option("--host <host[=SECRET,…]>", "ad-hoc rule: bare hostname, optionally the secrets it may receive (repeatable)", (value, acc = []) => [...acc, value]).option("--base-url <url>", "upstream base URL for an OpenAI-compatible gateway").option("--secret <NAME>", "override a preset's secret name").option("--prefix <path>", "override a preset's route prefix").option("--agent <slug>", "take the rules from published agent policy (server mode)").option("--agents <slug>", "additional identities this proxy may serve", (v, a = []) => [...a, v]).option("--org <slug>", "organization (for --agent)").option("--mode <reverse|forward|both>", "which data plane(s) to configure", "reverse").option("--listen <addr>", `reverse-proxy address (default: ${PLAN_DEFAULTS.listen})`).option("--forward-listen <addr>", `forward-proxy address (default: ${PLAN_DEFAULTS.forwardListen})`).option("--unmatched <tunnel|deny>", "what to do with an unruled host in forward mode").option("--ca-cert <path>", "interception CA certificate path (forward mode)").option("--ca-key <path>", "interception CA key path (forward mode)").option("--cache", "add a [cache] block so the proxy can start during an outage").option("--cache-max-age <dur>", "how stale a cached resolve may be (implies --cache)").option("--refresh <dur>", "re-resolve/re-fetch interval").option("--control <addr>", "add a [control] listener for per-agent session tickets").option("--tasks", "add a [tasks] block so this proxy honours runs dispatched with `seekrit agents dispatch`").option("--tasks-cache-ttl <dur>", "how long an introspected task is reused (implies --tasks)").option("--activity", "add an [activity] block so this proxy reports aggregate decisions for `seekrit agents review`").option("--activity-interval <dur>", "how often counts are flushed (implies --activity)");
|
|
8423
11168
|
}
|
|
8424
11169
|
function registerProxyCommands(program) {
|
|
8425
11170
|
const proxy = program.command("proxy").description("run and configure the agent egress proxy (`seekrit proxy --help`)");
|
|
@@ -8486,10 +11231,10 @@ Next:
|
|
|
8486
11231
|
});
|
|
8487
11232
|
console.log(path);
|
|
8488
11233
|
const { target } = detectTarget();
|
|
8489
|
-
process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.
|
|
11234
|
+
process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.10.0")} (${target})\n`);
|
|
8490
11235
|
});
|
|
8491
11236
|
proxy.command("where").description("show which binary `seekrit proxy run` would use, without fetching it").option("--proxy-version <version>", `version to report (default: ${PROXY_VERSION})`).option("--json", "machine-readable output").action((options) => {
|
|
8492
|
-
const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.
|
|
11237
|
+
const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
|
|
8493
11238
|
const override = process.env.SEEKRIT_PROXY_BIN;
|
|
8494
11239
|
const { target, exe } = detectTarget();
|
|
8495
11240
|
const path = override ?? proxyBinaryPath(version, target, exe);
|
|
@@ -8510,7 +11255,7 @@ Next:
|
|
|
8510
11255
|
process.stdout.write(renderComposeSnippet(plan, {
|
|
8511
11256
|
service: options.service,
|
|
8512
11257
|
workload: options.workload,
|
|
8513
|
-
image: options.image ?? `seekritdev/proxy:0.
|
|
11258
|
+
image: options.image ?? `seekritdev/proxy:0.10.0`,
|
|
8514
11259
|
publish: Boolean(options.publish)
|
|
8515
11260
|
}));
|
|
8516
11261
|
});
|
|
@@ -8668,17 +11413,6 @@ function collect$1(value, acc) {
|
|
|
8668
11413
|
* Rotated values are never printed here. Read them like any other secret
|
|
8669
11414
|
* (`seekrit secrets get NAME`), which decrypts locally.
|
|
8670
11415
|
*/
|
|
8671
|
-
/** Parse a duration like `30m`, `24h`, `90d`, or a bare seconds count. */
|
|
8672
|
-
function parseDurationSeconds(input, flag) {
|
|
8673
|
-
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
8674
|
-
if (!m) fail(`invalid ${flag} "${input}" (try 12h, 7d, 90d)`);
|
|
8675
|
-
return Number(m[1]) * ({
|
|
8676
|
-
s: 1,
|
|
8677
|
-
m: 60,
|
|
8678
|
-
h: 3600,
|
|
8679
|
-
d: 86400
|
|
8680
|
-
}[m[2] || "s"] ?? 1);
|
|
8681
|
-
}
|
|
8682
11416
|
function formatInterval(seconds) {
|
|
8683
11417
|
if (seconds % 86400 === 0) return `${seconds / 86400}d`;
|
|
8684
11418
|
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
|
|
@@ -10512,6 +13246,7 @@ registerMysqlCommands(program);
|
|
|
10512
13246
|
registerRedisCommands(program);
|
|
10513
13247
|
registerProvisionerCommands(program);
|
|
10514
13248
|
registerProxyCommands(program);
|
|
13249
|
+
registerAgentCommands(program);
|
|
10515
13250
|
registerSshCommands(program);
|
|
10516
13251
|
registerAwsCommands(program);
|
|
10517
13252
|
registerGcpCommands(program);
|
|
@@ -10525,6 +13260,7 @@ program.command("mcp").description("run an MCP server over stdio so AI agents ca
|
|
|
10525
13260
|
});
|
|
10526
13261
|
registerAuditCommands(program);
|
|
10527
13262
|
registerAccountCommands(program);
|
|
13263
|
+
registerArchiveCommands(program);
|
|
10528
13264
|
registerLogSinkCommands(program);
|
|
10529
13265
|
registerSyncCommands(program);
|
|
10530
13266
|
registerBillingCommands(program);
|