@seekrit/cli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,7 +11,8 @@ z.enum([
11
11
  "postgres",
12
12
  "mysql",
13
13
  "ssh",
14
- "redis"
14
+ "redis",
15
+ "aws"
15
16
  ]);
16
17
  const executorModeSchema = z.enum(["in_do", "remote"]);
17
18
  /**
@@ -66,6 +67,35 @@ const redisUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3
66
67
  * The alphabet is bare hex, so it is a safe bare command token.
67
68
  */
68
69
  const redisSha256VerifierSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase-hex SHA-256 digest (64 chars)");
70
+ /**
71
+ * An IAM role ARN the broker is allowed to assume. Bounded and structurally
72
+ * validated: `arn:<partition>:iam::<account>:role/<path-and-name>`. Partition
73
+ * covers commercial (`aws`), GovCloud (`aws-us-gov`), and China (`aws-cn`).
74
+ */
75
+ const awsRoleArnSchema = z.string().regex(/^arn:aws(?:-us-gov|-cn)?:iam::\d{12}:role\/[\w+=,.@/-]{1,512}$/, "must be an IAM role ARN (arn:aws:iam::<account>:role/<name>)");
76
+ /** An AWS region id, e.g. `us-east-1`, `eu-west-2`, `us-gov-west-1`. */
77
+ const awsRegionSchema = z.string().regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/, "must be an AWS region id (e.g. us-east-1)");
78
+ /**
79
+ * An STS external id — the shared string a role's trust policy can require so a
80
+ * confused-deputy can't assume it. AWS allows a broad charset; we keep to the
81
+ * documented safe set and bound the length.
82
+ */
83
+ const awsExternalIdSchema = z.string().regex(/^[\w+=,.@:/-]{2,1224}$/, "must be a valid STS external id");
84
+ z.string().regex(/^[\w+=,.@-]{2,64}$/, "must be 2–64 chars of [A-Za-z0-9_+=,.@-]");
85
+ /**
86
+ * The consumer's ephemeral P-256 public key (JWK-serialized) that a tier-2
87
+ * credential is wrapped to before it is returned. Validated structurally here;
88
+ * the executor imports it defensively before wrapping. Bounded so a giant blob
89
+ * can't be pushed through the control plane.
90
+ */
91
+ const p256PublicKeyJwkSchema = z.string().max(2048).refine((s) => {
92
+ try {
93
+ const jwk = JSON.parse(s);
94
+ return jwk.kty === "EC" && jwk.crv === "P-256" && !!jwk.x && !!jwk.y;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }, "must be a JWK-serialized P-256 public key");
69
99
  const postgresAccessLevelSchema = z.enum([
70
100
  "readonly",
71
101
  "readwrite",
@@ -144,11 +174,22 @@ const sshTargetConfigSchema = z.object({
144
174
  user: sshPrincipalSchema.optional()
145
175
  }).optional()
146
176
  });
177
+ const AWS_MAX_TTL_SECONDS = 3600 * 12;
178
+ const awsTargetConfigSchema = z.object({
179
+ provider: z.literal("aws"),
180
+ executor: z.literal("in_do"),
181
+ roleArn: awsRoleArnSchema,
182
+ region: awsRegionSchema,
183
+ externalId: awsExternalIdSchema.optional(),
184
+ sessionPolicy: z.string().min(1).max(4e3).optional(),
185
+ maxTtlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS).optional()
186
+ });
147
187
  const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
148
188
  postgresTargetConfigSchema,
149
189
  mysqlTargetConfigSchema,
150
190
  redisTargetConfigSchema,
151
- sshTargetConfigSchema
191
+ sshTargetConfigSchema,
192
+ awsTargetConfigSchema
152
193
  ]);
153
194
  z.object({
154
195
  name: z.string().trim().min(1).max(128),
@@ -210,13 +251,57 @@ const mintSshLeaseSchema = z.object({
210
251
  principals: z.array(sshPrincipalSchema).min(1).max(32),
211
252
  ttlSeconds: ttlSecondsSchema
212
253
  });
254
+ /**
255
+ * Client → API: mint an AWS lease. The client generates an ephemeral P-256
256
+ * keypair locally and sends only the public key; STS mints the credential and
257
+ * the broker returns it wrapped to that key. The private key never leaves the
258
+ * requesting machine, so the plaintext credential is only decryptable there.
259
+ *
260
+ * TTL bounds are STS's own `DurationSeconds` limits (15 min – 12 h), not the
261
+ * generic lease bounds — STS rejects anything below 900 seconds.
262
+ */
263
+ const mintAwsLeaseSchema = z.object({
264
+ provider: z.literal("aws"),
265
+ targetId: z.string().min(1),
266
+ recipientPublicKey: p256PublicKeyJwkSchema,
267
+ ttlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS)
268
+ });
213
269
  z.discriminatedUnion("provider", [
214
270
  mintPostgresLeaseSchema,
215
271
  mintMysqlLeaseSchema,
216
272
  mintRedisLeaseSchema,
217
- mintSshLeaseSchema
273
+ mintSshLeaseSchema,
274
+ mintAwsLeaseSchema
218
275
  ]);
219
276
  //#endregion
277
+ //#region ../../packages/core/src/providers/aws.ts
278
+ /**
279
+ * The one-time IAM setup an admin performs so seekrit can assume the target
280
+ * role. Analogue of `sshHostSetupInstructions` / `postgresGroupBootstrapSql` —
281
+ * printed for the admin to apply, never executed by seekrit. Shows the trust
282
+ * policy the role needs so the broker's base IAM principal may assume it.
283
+ */
284
+ function awsTrustPolicyInstructions(config) {
285
+ return [
286
+ "# Attach a trust policy to the target role so the IAM principal whose",
287
+ "# access key you registered as the admin secret may assume it. Replace",
288
+ "# <ADMIN_PRINCIPAL_ARN> with that principal (user or role) ARN.",
289
+ "{",
290
+ " \"Version\": \"2012-10-17\",",
291
+ " \"Statement\": [",
292
+ " {",
293
+ " \"Effect\": \"Allow\",",
294
+ " \"Principal\": { \"AWS\": \"<ADMIN_PRINCIPAL_ARN>\" },",
295
+ ` "Action": "sts:AssumeRole"${config.externalId ? `,\n "Condition": { "StringEquals": { "sts:ExternalId": "${config.externalId}" } }` : ""}`,
296
+ " }",
297
+ " ]",
298
+ "}",
299
+ "",
300
+ "# The admin principal also needs an identity policy allowing sts:AssumeRole",
301
+ `# on ${config.roleArn}. seekrit only ever calls AssumeRole on this role.`
302
+ ].join("\n");
303
+ }
304
+ //#endregion
220
305
  //#region ../../packages/core/src/providers/postgres.ts
221
306
  /**
222
307
  * The one-time setup SQL an admin runs to create the shared group role that a
@@ -508,6 +593,103 @@ async function importPrivateKeyPkcs8(pkcs8) {
508
593
  }, true, ["deriveBits"]);
509
594
  }
510
595
  //#endregion
596
+ //#region ../../packages/crypto/src/wrap.ts
597
+ /**
598
+ * ECIES-style key wrapping: an ephemeral P-256 keypair performs ECDH against
599
+ * the recipient's public key; the shared secret is run through HKDF-SHA256 to
600
+ * derive a one-time AES-256-GCM wrapping key. Only the holder of the
601
+ * recipient private key can unwrap.
602
+ *
603
+ * Blob format: `wd1.<ephemeral pub (raw)>.<hkdf salt>.<iv>.<ciphertext>`
604
+ */
605
+ const WRAP_PREFIX = "wd1";
606
+ const HKDF_INFO = "seekrit/wrap-dek/v1";
607
+ async function deriveWrappingKey(ownPrivateKey, peerPublicKey, salt, usage) {
608
+ const ecdh = {
609
+ name: "ECDH",
610
+ public: peerPublicKey
611
+ };
612
+ const sharedBits = await crypto.subtle.deriveBits(ecdh, ownPrivateKey, 256);
613
+ const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
614
+ return crypto.subtle.deriveKey({
615
+ name: "HKDF",
616
+ hash: "SHA-256",
617
+ salt,
618
+ info: utf8Encode(HKDF_INFO)
619
+ }, hkdfKey, {
620
+ name: "AES-GCM",
621
+ length: 256
622
+ }, false, [usage]);
623
+ }
624
+ /** Wrap an environment DEK to a principal's public key. */
625
+ async function wrapDek(dek, recipientPublicKeyJwk) {
626
+ const recipientKey = await importPublicKey(recipientPublicKeyJwk);
627
+ const ephemeral = await crypto.subtle.generateKey({
628
+ name: "ECDH",
629
+ namedCurve: "P-256"
630
+ }, true, ["deriveBits"]);
631
+ const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
632
+ const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, recipientKey, salt, "encrypt");
633
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
634
+ const ciphertext = await crypto.subtle.encrypt({
635
+ name: "AES-GCM",
636
+ iv
637
+ }, wrappingKey, dek);
638
+ const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
639
+ return [
640
+ WRAP_PREFIX,
641
+ toBase64Url(ephemeralRaw),
642
+ toBase64Url(salt),
643
+ toBase64Url(iv),
644
+ toBase64Url(new Uint8Array(ciphertext))
645
+ ].join(".");
646
+ }
647
+ /** Unwrap an environment DEK with the principal's private ECDH key. */
648
+ async function unwrapDek(wrapped, privateKey) {
649
+ const [ephB64, saltB64, ivB64, ctB64] = splitBlob(wrapped, WRAP_PREFIX, 4);
650
+ const wrappingKey = await deriveWrappingKey(privateKey, await crypto.subtle.importKey("raw", fromBase64Url(ephB64), {
651
+ name: "ECDH",
652
+ namedCurve: "P-256"
653
+ }, false, []), fromBase64Url(saltB64), "decrypt");
654
+ try {
655
+ const dek = await crypto.subtle.decrypt({
656
+ name: "AES-GCM",
657
+ iv: fromBase64Url(ivB64)
658
+ }, wrappingKey, fromBase64Url(ctB64));
659
+ return new Uint8Array(dek);
660
+ } catch {
661
+ throw new SeekritCryptoError("DECRYPT_FAILED", "DEK unwrap failed: wrong private key or tampered grant");
662
+ }
663
+ }
664
+ //#endregion
665
+ //#region ../../packages/crypto/src/aws.ts
666
+ /** Generate the ephemeral P-256 keypair a client uses to receive one AWS lease. */
667
+ async function generateAwsRecipientKeyPair() {
668
+ return generateKeyPair();
669
+ }
670
+ /**
671
+ * Unwrap the `wd1.` blob returned by an AWS lease mint using the ephemeral
672
+ * private key generated for that lease, yielding the plaintext STS credential.
673
+ */
674
+ async function unwrapAwsCredential(wrapped, privateKeyJwk) {
675
+ const bytes = await unwrapDek(wrapped, await importPrivateKey(privateKeyJwk));
676
+ let parsed;
677
+ try {
678
+ parsed = JSON.parse(utf8Decode(bytes));
679
+ } catch {
680
+ throw new SeekritCryptoError("DECRYPT_FAILED", "unwrapped AWS credential was not valid JSON");
681
+ }
682
+ const c = parsed;
683
+ if (typeof c.accessKeyId !== "string" || typeof c.secretAccessKey !== "string" || typeof c.sessionToken !== "string" || typeof c.expiration !== "string" || typeof c.region !== "string") throw new SeekritCryptoError("DECRYPT_FAILED", "unwrapped AWS credential is missing fields");
684
+ return {
685
+ accessKeyId: c.accessKeyId,
686
+ secretAccessKey: c.secretAccessKey,
687
+ sessionToken: c.sessionToken,
688
+ expiration: c.expiration,
689
+ region: c.region
690
+ };
691
+ }
692
+ //#endregion
511
693
  //#region ../../packages/crypto/src/mysql.ts
512
694
  /**
513
695
  * Client-side construction of a MySQL/MariaDB `mysql_native_password`
@@ -951,111 +1133,8 @@ function isServiceToken(value) {
951
1133
  return value.startsWith(`${TOKEN_PREFIX}_`);
952
1134
  }
953
1135
  //#endregion
954
- //#region ../../packages/crypto/src/wrap.ts
955
- /**
956
- * ECIES-style key wrapping: an ephemeral P-256 keypair performs ECDH against
957
- * the recipient's public key; the shared secret is run through HKDF-SHA256 to
958
- * derive a one-time AES-256-GCM wrapping key. Only the holder of the
959
- * recipient private key can unwrap.
960
- *
961
- * Blob format: `wd1.<ephemeral pub (raw)>.<hkdf salt>.<iv>.<ciphertext>`
962
- */
963
- const WRAP_PREFIX = "wd1";
964
- const HKDF_INFO = "seekrit/wrap-dek/v1";
965
- async function deriveWrappingKey(ownPrivateKey, peerPublicKey, salt, usage) {
966
- const ecdh = {
967
- name: "ECDH",
968
- public: peerPublicKey
969
- };
970
- const sharedBits = await crypto.subtle.deriveBits(ecdh, ownPrivateKey, 256);
971
- const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
972
- return crypto.subtle.deriveKey({
973
- name: "HKDF",
974
- hash: "SHA-256",
975
- salt,
976
- info: utf8Encode(HKDF_INFO)
977
- }, hkdfKey, {
978
- name: "AES-GCM",
979
- length: 256
980
- }, false, [usage]);
981
- }
982
- /** Wrap an environment DEK to a principal's public key. */
983
- async function wrapDek(dek, recipientPublicKeyJwk) {
984
- const recipientKey = await importPublicKey(recipientPublicKeyJwk);
985
- const ephemeral = await crypto.subtle.generateKey({
986
- name: "ECDH",
987
- namedCurve: "P-256"
988
- }, true, ["deriveBits"]);
989
- const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
990
- const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, recipientKey, salt, "encrypt");
991
- const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
992
- const ciphertext = await crypto.subtle.encrypt({
993
- name: "AES-GCM",
994
- iv
995
- }, wrappingKey, dek);
996
- const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
997
- return [
998
- WRAP_PREFIX,
999
- toBase64Url(ephemeralRaw),
1000
- toBase64Url(salt),
1001
- toBase64Url(iv),
1002
- toBase64Url(new Uint8Array(ciphertext))
1003
- ].join(".");
1004
- }
1005
- /** Unwrap an environment DEK with the principal's private ECDH key. */
1006
- async function unwrapDek(wrapped, privateKey) {
1007
- const [ephB64, saltB64, ivB64, ctB64] = splitBlob(wrapped, WRAP_PREFIX, 4);
1008
- const wrappingKey = await deriveWrappingKey(privateKey, await crypto.subtle.importKey("raw", fromBase64Url(ephB64), {
1009
- name: "ECDH",
1010
- namedCurve: "P-256"
1011
- }, false, []), fromBase64Url(saltB64), "decrypt");
1012
- try {
1013
- const dek = await crypto.subtle.decrypt({
1014
- name: "AES-GCM",
1015
- iv: fromBase64Url(ivB64)
1016
- }, wrappingKey, fromBase64Url(ctB64));
1017
- return new Uint8Array(dek);
1018
- } catch {
1019
- throw new SeekritCryptoError("DECRYPT_FAILED", "DEK unwrap failed: wrong private key or tampered grant");
1020
- }
1021
- }
1022
- //#endregion
1023
1136
  //#region package.json
1024
- var version = "0.13.0";
1025
- const PROJECT_FILE = "seekrit.json";
1026
- function globalConfigPath() {
1027
- return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
1028
- }
1029
- function readGlobalConfig() {
1030
- const path = globalConfigPath();
1031
- if (!existsSync(path)) return {};
1032
- return JSON.parse(readFileSync(path, "utf8"));
1033
- }
1034
- function writeGlobalConfig(update) {
1035
- const path = globalConfigPath();
1036
- const merged = {
1037
- ...readGlobalConfig(),
1038
- ...update
1039
- };
1040
- mkdirSync(dirname(path), { recursive: true });
1041
- writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
1042
- }
1043
- /** Walk up from cwd looking for seekrit.json. */
1044
- function findProjectConfig(startDir = process.cwd()) {
1045
- let dir = startDir;
1046
- const { root } = parse(dir);
1047
- while (true) {
1048
- const candidate = join(dir, PROJECT_FILE);
1049
- if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8"));
1050
- if (dir === root) return null;
1051
- dir = dirname(dir);
1052
- }
1053
- }
1054
- function writeProjectConfig(config, dir = process.cwd()) {
1055
- const path = join(dir, PROJECT_FILE);
1056
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
1057
- return path;
1058
- }
1137
+ var version = "0.14.0";
1059
1138
  //#endregion
1060
1139
  //#region ../../packages/api-client/src/index.ts
1061
1140
  var SeekritApiError = class extends Error {
@@ -1263,6 +1342,40 @@ var SeekritClient = class {
1263
1342
  return this.request("POST", `/v1/orgs/${orgId}/log-sink/test`);
1264
1343
  }
1265
1344
  };
1345
+ const PROJECT_FILE = "seekrit.json";
1346
+ function globalConfigPath() {
1347
+ return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
1348
+ }
1349
+ function readGlobalConfig() {
1350
+ const path = globalConfigPath();
1351
+ if (!existsSync(path)) return {};
1352
+ return JSON.parse(readFileSync(path, "utf8"));
1353
+ }
1354
+ function writeGlobalConfig(update) {
1355
+ const path = globalConfigPath();
1356
+ const merged = {
1357
+ ...readGlobalConfig(),
1358
+ ...update
1359
+ };
1360
+ mkdirSync(dirname(path), { recursive: true });
1361
+ writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
1362
+ }
1363
+ /** Walk up from cwd looking for seekrit.json. */
1364
+ function findProjectConfig(startDir = process.cwd()) {
1365
+ let dir = startDir;
1366
+ const { root } = parse(dir);
1367
+ while (true) {
1368
+ const candidate = join(dir, PROJECT_FILE);
1369
+ if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8"));
1370
+ if (dir === root) return null;
1371
+ dir = dirname(dir);
1372
+ }
1373
+ }
1374
+ function writeProjectConfig(config, dir = process.cwd()) {
1375
+ const path = join(dir, PROJECT_FILE);
1376
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
1377
+ return path;
1378
+ }
1266
1379
  //#endregion
1267
1380
  //#region src/io.ts
1268
1381
  let failThrows = false;
@@ -1368,6 +1481,235 @@ async function getDek(ctx, orgId, envId) {
1368
1481
  return unwrapDek(wrappedDek, privateKey);
1369
1482
  }
1370
1483
  //#endregion
1484
+ //#region src/target.ts
1485
+ /** Resolve the target org from a flag, the committed config, or a lone org. */
1486
+ async function resolveOrg(ctx, orgSlug) {
1487
+ const wanted = orgSlug ?? findProjectConfig()?.org;
1488
+ const { orgs } = await ctx.client.listOrgs();
1489
+ if (wanted) {
1490
+ const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
1491
+ if (!org) fail(`no accessible org "${wanted}"`);
1492
+ return {
1493
+ id: org.id,
1494
+ slug: org.slug
1495
+ };
1496
+ }
1497
+ const only = orgs[0];
1498
+ if (orgs.length === 1 && only) return {
1499
+ id: only.id,
1500
+ slug: only.slug
1501
+ };
1502
+ fail("specify --org (or run `seekrit init`)");
1503
+ }
1504
+ /**
1505
+ * Resolve an environment to operate on — an application env (`--app --env`,
1506
+ * or the config's app + `--env`) or a group env (`--group --env`).
1507
+ */
1508
+ async function resolveEnvTarget(ctx, opts) {
1509
+ const org = await resolveOrg(ctx, opts.org);
1510
+ if (!opts.env) fail("specify --env");
1511
+ if (opts.group) {
1512
+ const { groups } = await ctx.client.listGroups(org.id);
1513
+ const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1514
+ if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1515
+ const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
1516
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1517
+ if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
1518
+ return {
1519
+ orgId: org.id,
1520
+ envId: env.id,
1521
+ label: `${group.slug}@${env.slug}`
1522
+ };
1523
+ }
1524
+ const appSlug = opts.app ?? findProjectConfig()?.app;
1525
+ if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
1526
+ const { apps } = await ctx.client.listApps(org.id);
1527
+ const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1528
+ if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1529
+ const { environments } = await ctx.client.listEnvs(org.id, app.id);
1530
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1531
+ if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1532
+ return {
1533
+ orgId: org.id,
1534
+ envId: env.id,
1535
+ label: `${app.slug}/${env.slug}`
1536
+ };
1537
+ }
1538
+ /** Resolve an application environment, keeping ids + slugs (for token binding). */
1539
+ async function resolveAppEnv(ctx, opts) {
1540
+ const org = await resolveOrg(ctx, opts.org);
1541
+ const appSlug = opts.app ?? findProjectConfig()?.app;
1542
+ if (!appSlug) fail("specify --app (or run `seekrit init`)");
1543
+ if (!opts.env) fail("specify --env");
1544
+ const { apps } = await ctx.client.listApps(org.id);
1545
+ const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1546
+ if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1547
+ const { environments } = await ctx.client.listEnvs(org.id, app.id);
1548
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1549
+ if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1550
+ return {
1551
+ orgId: org.id,
1552
+ appId: app.id,
1553
+ appSlug: app.slug,
1554
+ envId: env.id,
1555
+ envSlug: env.slug
1556
+ };
1557
+ }
1558
+ /** Resolve a group by slug within the target org. */
1559
+ async function resolveGroup(ctx, opts) {
1560
+ const org = await resolveOrg(ctx, opts.org);
1561
+ if (!opts.group) fail("specify --group");
1562
+ const { groups } = await ctx.client.listGroups(org.id);
1563
+ const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1564
+ if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1565
+ return {
1566
+ orgId: org.id,
1567
+ id: group.id,
1568
+ slug: group.slug
1569
+ };
1570
+ }
1571
+ //#endregion
1572
+ //#region src/aws.ts
1573
+ /**
1574
+ * `seekrit aws` — temporary AWS credentials via STS AssumeRole (Vault-style
1575
+ * dynamic secrets, the tier-2 sibling of `seekrit pg`).
1576
+ *
1577
+ * Zero-knowledge for the leased credential: minting generates an ephemeral P-256
1578
+ * keypair on THIS machine and sends only the public key; STS mints the
1579
+ * credential and the broker returns it wrapped to that key, so the control plane
1580
+ * only ever relays ciphertext and only this machine can unwrap it. Registering a
1581
+ * target wraps the base IAM credential to the broker's public key locally, so
1582
+ * the control plane never sees it either — its only needed permission is
1583
+ * `sts:AssumeRole` on the target role.
1584
+ */
1585
+ /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
1586
+ function parseTtlSeconds$4(input) {
1587
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1588
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
1589
+ return Number(m[1]) * ({
1590
+ s: 1,
1591
+ m: 60,
1592
+ h: 3600,
1593
+ d: 86400
1594
+ }[m[2] || "s"] ?? 1);
1595
+ }
1596
+ /**
1597
+ * The base IAM credential the broker assumes the role with. From flags or the
1598
+ * standard AWS env vars; JSON-serialized so the executor can parse it. Never the
1599
+ * plaintext leaves this machine unwrapped — it is wrapped to the broker key.
1600
+ */
1601
+ function resolveBaseCredential(opts) {
1602
+ const accessKeyId = opts.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID;
1603
+ const secretAccessKey = opts.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY;
1604
+ const sessionToken = process.env.AWS_SESSION_TOKEN;
1605
+ if (!accessKeyId || !secretAccessKey) fail("provide the base IAM credential via --access-key-id/--secret-access-key or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (it needs only sts:AssumeRole on the role)");
1606
+ return JSON.stringify({
1607
+ accessKeyId,
1608
+ secretAccessKey,
1609
+ ...sessionToken ? { sessionToken } : {}
1610
+ });
1611
+ }
1612
+ function registerAwsCommands(program) {
1613
+ const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
1614
+ const target = aws.command("target").description("manage AWS role targets");
1615
+ target.command("add").description("register an assumable IAM role to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--role-arn <arn>", "the IAM role to assume, arn:aws:iam::<acct>:role/<name>").requiredOption("--region <region>", "region whose STS endpoint to call, e.g. us-east-1").option("--org <slug>").option("--external-id <id>", "STS ExternalId the role's trust policy requires").option("--session-policy <file>", "path to an inline session policy JSON (further restricts)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--access-key-id <id>", "base IAM access key id (else AWS_ACCESS_KEY_ID)").option("--secret-access-key <secret>", "base IAM secret (else AWS_SECRET_ACCESS_KEY)").action(async (options) => {
1616
+ const ctx = buildContext();
1617
+ const org = await resolveOrg(ctx, options.org);
1618
+ const sessionPolicy = options.sessionPolicy ? readFileSync(options.sessionPolicy, "utf8").trim() : void 0;
1619
+ const config = {
1620
+ provider: "aws",
1621
+ executor: "in_do",
1622
+ roleArn: options.roleArn,
1623
+ region: options.region,
1624
+ ...options.externalId ? { externalId: options.externalId } : {},
1625
+ ...sessionPolicy ? { sessionPolicy } : {},
1626
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$4(options.maxTtl) } : {}
1627
+ };
1628
+ const baseCredential = resolveBaseCredential(options);
1629
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
1630
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
1631
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
1632
+ name: options.name,
1633
+ config,
1634
+ wrappedAdminSecret
1635
+ });
1636
+ console.error(`registered AWS target ${created.name} (${created.id})`);
1637
+ console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
1638
+ console.log(awsTrustPolicyInstructions(config));
1639
+ });
1640
+ target.command("list").description("list AWS role targets").option("--org <slug>").action(async (options) => {
1641
+ const ctx = buildContext();
1642
+ const org = await resolveOrg(ctx, options.org);
1643
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1644
+ for (const t of targets) {
1645
+ const cfg = t.config;
1646
+ if (cfg.provider !== "aws") continue;
1647
+ console.log(`${t.id}\t${t.name}\t${cfg.region}\t${cfg.roleArn}`);
1648
+ }
1649
+ });
1650
+ target.command("trust <targetId>").description("reprint the IAM trust-policy setup for an AWS target").option("--org <slug>").action(async (targetId, options) => {
1651
+ const ctx = buildContext();
1652
+ const org = await resolveOrg(ctx, options.org);
1653
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1654
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
1655
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
1656
+ const cfg = t.config;
1657
+ if (cfg.provider !== "aws") fail("not an aws target (see `seekrit pg`/`seekrit ssh`)");
1658
+ console.log(awsTrustPolicyInstructions(cfg));
1659
+ });
1660
+ target.command("rm <targetId>").description("delete an AWS role target").option("--org <slug>").action(async (targetId, options) => {
1661
+ const ctx = buildContext();
1662
+ const org = await resolveOrg(ctx, options.org);
1663
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
1664
+ console.error(`deleted ${targetId}`);
1665
+ });
1666
+ aws.command("lease <target>").description("mint short-lived AWS credentials; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "credential lifetime, e.g. 15m, 1h, 12h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
1667
+ const ctx = buildContext();
1668
+ const org = await resolveOrg(ctx, options.org);
1669
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1670
+ const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
1671
+ if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
1672
+ const cfg = t.config;
1673
+ if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
1674
+ const ttlSeconds = parseTtlSeconds$4(options.ttl);
1675
+ if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
1676
+ const recipient = await generateAwsRecipientKeyPair();
1677
+ const { aws: leased } = await ctx.client.mintLease(org.id, {
1678
+ provider: "aws",
1679
+ targetId: t.id,
1680
+ recipientPublicKey: recipient.publicKeyJwk,
1681
+ ttlSeconds
1682
+ });
1683
+ const cred = await unwrapAwsCredential(leased.wrappedCredential, recipient.privateKeyJwk);
1684
+ console.error(`leased ${cfg.roleArn} in ${cred.region} — expires ${cred.expiration}`);
1685
+ if (options.json) console.log(JSON.stringify({
1686
+ ...cred,
1687
+ roleArn: cfg.roleArn
1688
+ }, null, 2));
1689
+ else {
1690
+ console.log(`export AWS_ACCESS_KEY_ID=${cred.accessKeyId}`);
1691
+ console.log(`export AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey}`);
1692
+ console.log(`export AWS_SESSION_TOKEN=${cred.sessionToken}`);
1693
+ console.log(`export AWS_REGION=${cred.region}`);
1694
+ }
1695
+ });
1696
+ aws.command("leases").description("list AWS leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
1697
+ const ctx = buildContext();
1698
+ const org = await resolveOrg(ctx, options.org);
1699
+ const { leases } = await ctx.client.listLeases(org.id);
1700
+ for (const l of leases) {
1701
+ if (l.provider !== "aws") continue;
1702
+ console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
1703
+ }
1704
+ });
1705
+ aws.command("revoke <leaseId>").description("mark a lease revoked in the ledger (STS credentials stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
1706
+ const ctx = buildContext();
1707
+ const org = await resolveOrg(ctx, options.org);
1708
+ await ctx.client.revokeLease(org.id, leaseId);
1709
+ console.error(`revoked ${leaseId} (issued credentials remain valid until they expire)`);
1710
+ });
1711
+ }
1712
+ //#endregion
1371
1713
  //#region src/dotenv.ts
1372
1714
  /**
1373
1715
  * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
@@ -1462,94 +1804,6 @@ function resolveLeaseAdminSecret(opts) {
1462
1804
  return adminUrl;
1463
1805
  }
1464
1806
  //#endregion
1465
- //#region src/target.ts
1466
- /** Resolve the target org from a flag, the committed config, or a lone org. */
1467
- async function resolveOrg(ctx, orgSlug) {
1468
- const wanted = orgSlug ?? findProjectConfig()?.org;
1469
- const { orgs } = await ctx.client.listOrgs();
1470
- if (wanted) {
1471
- const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
1472
- if (!org) fail(`no accessible org "${wanted}"`);
1473
- return {
1474
- id: org.id,
1475
- slug: org.slug
1476
- };
1477
- }
1478
- const only = orgs[0];
1479
- if (orgs.length === 1 && only) return {
1480
- id: only.id,
1481
- slug: only.slug
1482
- };
1483
- fail("specify --org (or run `seekrit init`)");
1484
- }
1485
- /**
1486
- * Resolve an environment to operate on — an application env (`--app --env`,
1487
- * or the config's app + `--env`) or a group env (`--group --env`).
1488
- */
1489
- async function resolveEnvTarget(ctx, opts) {
1490
- const org = await resolveOrg(ctx, opts.org);
1491
- if (!opts.env) fail("specify --env");
1492
- if (opts.group) {
1493
- const { groups } = await ctx.client.listGroups(org.id);
1494
- const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1495
- if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1496
- const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
1497
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1498
- if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
1499
- return {
1500
- orgId: org.id,
1501
- envId: env.id,
1502
- label: `${group.slug}@${env.slug}`
1503
- };
1504
- }
1505
- const appSlug = opts.app ?? findProjectConfig()?.app;
1506
- if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
1507
- const { apps } = await ctx.client.listApps(org.id);
1508
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1509
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1510
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
1511
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1512
- if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1513
- return {
1514
- orgId: org.id,
1515
- envId: env.id,
1516
- label: `${app.slug}/${env.slug}`
1517
- };
1518
- }
1519
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
1520
- async function resolveAppEnv(ctx, opts) {
1521
- const org = await resolveOrg(ctx, opts.org);
1522
- const appSlug = opts.app ?? findProjectConfig()?.app;
1523
- if (!appSlug) fail("specify --app (or run `seekrit init`)");
1524
- if (!opts.env) fail("specify --env");
1525
- const { apps } = await ctx.client.listApps(org.id);
1526
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1527
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1528
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
1529
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1530
- if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1531
- return {
1532
- orgId: org.id,
1533
- appId: app.id,
1534
- appSlug: app.slug,
1535
- envId: env.id,
1536
- envSlug: env.slug
1537
- };
1538
- }
1539
- /** Resolve a group by slug within the target org. */
1540
- async function resolveGroup(ctx, opts) {
1541
- const org = await resolveOrg(ctx, opts.org);
1542
- if (!opts.group) fail("specify --group");
1543
- const { groups } = await ctx.client.listGroups(org.id);
1544
- const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1545
- if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1546
- return {
1547
- orgId: org.id,
1548
- id: group.id,
1549
- slug: group.slug
1550
- };
1551
- }
1552
- //#endregion
1553
1807
  //#region src/mysql.ts
1554
1808
  /**
1555
1809
  * `seekrit mysql` — temporary MySQL/MariaDB credentials (Vault-style dynamic
@@ -2596,8 +2850,9 @@ registerMysqlCommands(program);
2596
2850
  registerRedisCommands(program);
2597
2851
  registerProvisionerCommands(program);
2598
2852
  registerSshCommands(program);
2853
+ registerAwsCommands(program);
2599
2854
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
2600
- const { runMcpServer } = await import("./mcp-BEcV_KpM.js");
2855
+ const { runMcpServer } = await import("./mcp-ARBuneH3.js");
2601
2856
  await runMcpServer();
2602
2857
  });
2603
2858
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -2611,4 +2866,4 @@ program.parseAsync(argv).catch((err) => {
2611
2866
  fail(err instanceof Error ? err.message : String(err));
2612
2867
  });
2613
2868
  //#endregion
2614
- export { parseServiceToken as _, resolveEnvTarget as a, generateDek as b, getDek as c, setFailThrows as d, writeProjectConfig as f, isServiceToken as g, createServiceToken as h, resolveAppEnv as i, isTokenAuth as l, wrapDek as m, fetchDecryptedSecrets as n, resolveGroup as o, version as p, materializeEnv as r, resolveOrg as s, encryptAndSetSecret as t, tryBuildContext as u, generatePostgresCredential as v, generateMysqlCredential as y };
2869
+ export { generatePostgresCredential as _, resolveEnvTarget as a, generateDek as b, getDek as c, setFailThrows as d, writeProjectConfig as f, parseServiceToken as g, isServiceToken as h, resolveAppEnv as i, isTokenAuth as l, createServiceToken as m, fetchDecryptedSecrets as n, resolveGroup as o, version as p, materializeEnv as r, resolveOrg as s, encryptAndSetSecret as t, tryBuildContext as u, generateMysqlCredential as v, wrapDek as y };
@@ -1,4 +1,4 @@
1
- import { _ as parseServiceToken, a as resolveEnvTarget, b as generateDek, c as getDek, d as setFailThrows, f as writeProjectConfig, g as isServiceToken, h as createServiceToken, i as resolveAppEnv, l as isTokenAuth, m as wrapDek, n as fetchDecryptedSecrets, o as resolveGroup, p as version, r as materializeEnv, s as resolveOrg, t as encryptAndSetSecret, u as tryBuildContext, v as generatePostgresCredential, y as generateMysqlCredential } from "./index.js";
1
+ import { _ as generatePostgresCredential, a as resolveEnvTarget, b as generateDek, c as getDek, d as setFailThrows, f as writeProjectConfig, g as parseServiceToken, h as isServiceToken, i as resolveAppEnv, l as isTokenAuth, m as createServiceToken, n as fetchDecryptedSecrets, o as resolveGroup, p as version, r as materializeEnv, s as resolveOrg, t as encryptAndSetSecret, u as tryBuildContext, v as generateMysqlCredential, y as wrapDek } from "./index.js";
2
2
  import { spawn } from "node:child_process";
3
3
  import { z } from "zod";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {