@seekrit/cli 0.15.0 → 0.17.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
@@ -13,7 +13,8 @@ z.enum([
13
13
  "ssh",
14
14
  "redis",
15
15
  "aws",
16
- "gcp"
16
+ "gcp",
17
+ "mongodb"
17
18
  ]);
18
19
  const executorModeSchema = z.enum(["in_do", "remote"]);
19
20
  /**
@@ -113,6 +114,22 @@ const p256PublicKeyJwkSchema = z.string().max(2048).refine((s) => {
113
114
  return false;
114
115
  }
115
116
  }, "must be a JWK-serialized P-256 public key");
117
+ z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "must be 1–64 chars of [A-Za-z0-9_-]");
118
+ /**
119
+ * A MongoDB database name — the db a preset role is granted on, or the
120
+ * authentication database a leased user is created in. MongoDB forbids
121
+ * `/\. "$*<>:|?` and the empty string in db names; we keep to a safe subset.
122
+ */
123
+ const mongoDatabaseNameSchema = z.string().regex(/^[A-Za-z0-9_-]{1,63}$/, "must be 1–63 chars of [A-Za-z0-9_-]");
124
+ /**
125
+ * A single MongoDB role grant `{ role, db }` for a `custom` target — e.g.
126
+ * `{ role: "readWrite", db: "app" }` or a user-defined role. Admin-supplied
127
+ * trusted input, set once at registration; still bounded structurally.
128
+ */
129
+ const mongoRoleSchema = z.object({
130
+ role: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "must be a role name"),
131
+ db: mongoDatabaseNameSchema
132
+ });
116
133
  const postgresAccessLevelSchema = z.enum([
117
134
  "readonly",
118
135
  "readwrite",
@@ -133,6 +150,11 @@ const redisAccessLevelSchema = z.enum([
133
150
  "readwrite",
134
151
  "custom"
135
152
  ]);
153
+ const mongoAccessLevelSchema = z.enum([
154
+ "readonly",
155
+ "readwrite",
156
+ "custom"
157
+ ]);
136
158
  const connectionSchema = z.object({
137
159
  host: z.string().min(1),
138
160
  port: z.number().int().min(1).max(65535),
@@ -210,13 +232,24 @@ const gcpTargetConfigSchema = z.object({
210
232
  delegates: z.array(gcpServiceAccountEmailSchema).max(8).optional(),
211
233
  maxTtlSeconds: z.number().int().min(60).max(GCP_MAX_TTL_SECONDS).optional()
212
234
  });
235
+ const mongoTargetConfigSchema = z.object({
236
+ provider: z.literal("mongodb"),
237
+ executor: z.literal("in_do"),
238
+ accessLevel: mongoAccessLevelSchema.optional(),
239
+ connection: connectionSchema,
240
+ authSource: mongoDatabaseNameSchema.optional(),
241
+ roles: z.array(mongoRoleSchema).min(1).max(32).optional(),
242
+ tls: z.boolean().optional(),
243
+ maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional()
244
+ });
213
245
  const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
214
246
  postgresTargetConfigSchema,
215
247
  mysqlTargetConfigSchema,
216
248
  redisTargetConfigSchema,
217
249
  sshTargetConfigSchema,
218
250
  awsTargetConfigSchema,
219
- gcpTargetConfigSchema
251
+ gcpTargetConfigSchema,
252
+ mongoTargetConfigSchema
220
253
  ]);
221
254
  z.object({
222
255
  name: z.string().trim().min(1).max(128),
@@ -309,13 +342,27 @@ const mintGcpLeaseSchema = z.object({
309
342
  recipientPublicKey: p256PublicKeyJwkSchema,
310
343
  ttlSeconds: z.number().int().min(60).max(GCP_MAX_TTL_SECONDS)
311
344
  });
345
+ /**
346
+ * Client → API: mint a MongoDB lease. Like AWS (tier 2), the client generates
347
+ * an ephemeral P-256 keypair locally and sends only the public key; the broker
348
+ * generates the password, runs `createUser`, and returns the credential wrapped
349
+ * to that key. The private key never leaves the requesting machine, so the
350
+ * plaintext credential is only decryptable there.
351
+ */
352
+ const mintMongoLeaseSchema = z.object({
353
+ provider: z.literal("mongodb"),
354
+ targetId: z.string().min(1),
355
+ recipientPublicKey: p256PublicKeyJwkSchema,
356
+ ttlSeconds: ttlSecondsSchema
357
+ });
312
358
  z.discriminatedUnion("provider", [
313
359
  mintPostgresLeaseSchema,
314
360
  mintMysqlLeaseSchema,
315
361
  mintRedisLeaseSchema,
316
362
  mintSshLeaseSchema,
317
363
  mintAwsLeaseSchema,
318
- mintGcpLeaseSchema
364
+ mintGcpLeaseSchema,
365
+ mintMongoLeaseSchema
319
366
  ]);
320
367
  //#endregion
321
368
  //#region ../../packages/core/src/providers/aws.ts
@@ -369,6 +416,32 @@ function gcpSetupInstructions(config) {
369
416
  ].join("\n");
370
417
  }
371
418
  //#endregion
419
+ //#region ../../packages/core/src/providers/mongodb.ts
420
+ /**
421
+ * The one-time setup an admin performs so seekrit can provision users: create a
422
+ * dedicated provisioning user with `userAdmin` on the target database (or
423
+ * `userAdminAnyDatabase` if leases span databases). Printed for the admin to run
424
+ * in `mongosh`, never executed by seekrit — the analogue of
425
+ * `awsTrustPolicyInstructions` / `sshHostSetupInstructions`.
426
+ */
427
+ function mongoAdminSetupInstructions(config) {
428
+ const authSource = config.authSource ?? "admin";
429
+ const db = config.connection.database;
430
+ return [
431
+ `// Run once in mongosh against the "${authSource}" database. Creates the`,
432
+ "// provisioning user whose connection string you register as the admin secret.",
433
+ `use ${authSource}`,
434
+ "db.createUser({",
435
+ " user: \"seekrit_provisioner\",",
436
+ " pwd: passwordPrompt(),",
437
+ ` roles: [{ role: "userAdmin", db: "${db}" }],`,
438
+ "})",
439
+ "",
440
+ `// Register it: seekrit mongodb target add --name ${db} \\`,
441
+ `// --uri "mongodb://seekrit_provisioner:<pwd>@${config.connection.host}:${config.connection.port}/?authSource=${authSource}"`
442
+ ].join("\n");
443
+ }
444
+ //#endregion
372
445
  //#region ../../packages/core/src/providers/postgres.ts
373
446
  /**
374
447
  * The one-time setup SQL an admin runs to create the shared group role that a
@@ -445,6 +518,8 @@ const NOTIFICATION_TYPES = [
445
518
  /** URL-safe identifier segment: `my-app`, `production`, … */
446
519
  const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
447
520
  const nameSchema = z.string().trim().min(1).max(128);
521
+ /** An email address, normalized to trimmed lowercase before validation. */
522
+ const emailSchema = z.string().trim().toLowerCase().pipe(z.email().max(320));
448
523
  /** Env-var style secret name: FOO, DATABASE_URL, apiKey2 … */
449
524
  const secretNameSchema = z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
450
525
  z.enum([
@@ -452,6 +527,8 @@ z.enum([
452
527
  "admin",
453
528
  "member"
454
529
  ]);
530
+ /** Role a person may be invited at — never `owner` (ownership isn't invitable). */
531
+ const inviteRoleSchema = z.enum(["admin", "member"]);
455
532
  const principalTypeSchema = z.enum(["user", "service_token"]);
456
533
  /** Org-level capability a service token can hold (never `owner`). */
457
534
  const serviceTokenRoleSchema = z.enum(["admin", "member"]);
@@ -463,6 +540,10 @@ z.object({
463
540
  name: nameSchema,
464
541
  slug: slugSchema
465
542
  });
543
+ z.object({
544
+ email: emailSchema,
545
+ role: inviteRoleSchema.default("member")
546
+ });
466
547
  z.object({
467
548
  name: nameSchema,
468
549
  slug: slugSchema
@@ -514,6 +595,44 @@ z.object({
514
595
  environmentId: z.string().min(1).nullish(),
515
596
  expiresAt: z.iso.datetime().nullish()
516
597
  });
598
+ const kmsKeyPurposeSchema = z.enum(["encrypt", "sign"]);
599
+ const kmsKeySpecSchema = z.enum(["aes-256-gcm", "ecdsa-p256"]);
600
+ /** A wrapped key grant supplied by the client (server never sees plaintext material). */
601
+ const kmsGrantInputSchema = z.object({
602
+ principalType: principalTypeSchema,
603
+ principalId: z.string().min(1),
604
+ /** Key material wrapped to the principal's public key (`wd1.` blob). */
605
+ wrappedKey: z.string().min(1)
606
+ });
607
+ z.object({
608
+ name: nameSchema,
609
+ purpose: kmsKeyPurposeSchema,
610
+ spec: kmsKeySpecSchema,
611
+ applicationId: z.string().min(1).nullish(),
612
+ groupId: z.string().min(1).nullish(),
613
+ /** ECDSA P-256 public key (JWK) for `sign` keys; omit for `encrypt` keys. */
614
+ publicKeyJwk: z.string().min(1).nullish(),
615
+ grants: z.array(kmsGrantInputSchema).min(1)
616
+ }).refine((v) => !(v.applicationId && v.groupId), {
617
+ message: "a key may be scoped to an application or a group, not both",
618
+ path: ["groupId"]
619
+ }).refine((v) => v.purpose === "sign" === (v.spec === "ecdsa-p256"), {
620
+ message: "sign keys require spec ecdsa-p256; encrypt keys require aes-256-gcm",
621
+ path: ["spec"]
622
+ }).refine((v) => v.purpose === "sign" === (v.publicKeyJwk != null), {
623
+ message: "sign keys require a publicKeyJwk; encrypt keys must omit it",
624
+ path: ["publicKeyJwk"]
625
+ });
626
+ z.object({
627
+ principalType: principalTypeSchema,
628
+ principalId: z.string().min(1),
629
+ /** Current-version key material wrapped to the principal's public key. */
630
+ wrappedKey: z.string().min(1)
631
+ });
632
+ z.object({
633
+ publicKeyJwk: z.string().min(1).nullish(),
634
+ grants: z.array(kmsGrantInputSchema).min(1)
635
+ });
517
636
  z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
518
637
  z.object({
519
638
  endpoint: z.url().max(2048),
@@ -582,7 +701,7 @@ function splitBlob(blob, prefix, segments) {
582
701
  //#endregion
583
702
  //#region ../../packages/crypto/src/aes.ts
584
703
  const SECRET_PREFIX = "sc1";
585
- const IV_LENGTH = 12;
704
+ const IV_LENGTH$1 = 12;
586
705
  /** Generate a fresh 256-bit data encryption key for an environment. */
587
706
  function generateDek() {
588
707
  return crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
@@ -599,7 +718,7 @@ async function importDek(dek, usage) {
599
718
  */
600
719
  async function encryptSecret(dek, plaintext, aad) {
601
720
  const key = await importDek(dek, "encrypt");
602
- const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
721
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH$1));
603
722
  const ciphertext = await crypto.subtle.encrypt({
604
723
  name: "AES-GCM",
605
724
  iv,
@@ -784,6 +903,172 @@ async function unwrapGcpCredential(wrapped, privateKeyJwk) {
784
903
  };
785
904
  }
786
905
  //#endregion
906
+ //#region ../../packages/crypto/src/kms.ts
907
+ /**
908
+ * Client-side KMS envelope encryption. A managed `encrypt` key is a 256-bit
909
+ * AES-GCM key whose material reaches the client only as a `wd1.` grant (wrapped
910
+ * to the principal's public key). These helpers operate on that material
911
+ * directly — the server never sees plaintext or key material, exactly as for
912
+ * environment DEKs.
913
+ *
914
+ * Blob formats:
915
+ * `ce1.<keyId>.<version>.<iv>.<ciphertext>` — Encrypt output
916
+ * `dk1.<keyId>.<version>.<iv>.<ciphertext>` — GenerateDataKey wrapped key
917
+ *
918
+ * The keyId + version travel in the blob (so Decrypt can select the right key
919
+ * version) and are folded into the AAD (so a blob can't be replayed under a
920
+ * different key/version). For `ce1` the caller's optional encryption *context*
921
+ * is also bound — Decrypt must supply the same context, mirroring AWS KMS.
922
+ */
923
+ const ENCRYPT_PREFIX = "ce1";
924
+ const DATAKEY_PREFIX = "dk1";
925
+ const IV_LENGTH = 12;
926
+ const KEY_LENGTH = 32;
927
+ /** Generate fresh 256-bit material for an `encrypt` KMS key. */
928
+ function generateEncryptKeyMaterial() {
929
+ return crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
930
+ }
931
+ async function importAesKey(material, usage) {
932
+ return crypto.subtle.importKey("raw", material, { name: "AES-GCM" }, false, [usage]);
933
+ }
934
+ function encryptAad(ref, context) {
935
+ return `${ref.keyId}/${ref.version}/${context}`;
936
+ }
937
+ function dataKeyAad(keyId, version) {
938
+ return `${keyId}/${version}`;
939
+ }
940
+ function parseVersion(versionStr) {
941
+ const version = Number(versionStr);
942
+ if (!Number.isInteger(version) || version < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid key version in KMS blob");
943
+ return version;
944
+ }
945
+ /** Read the key id + version a `ce1`/`dk1` blob was produced under. */
946
+ function kmsBlobKeyRef(blob) {
947
+ const prefix = blob.split(".")[0];
948
+ if (prefix !== ENCRYPT_PREFIX && prefix !== DATAKEY_PREFIX) throw new SeekritCryptoError("UNSUPPORTED_VERSION", `not a KMS blob: "${prefix ?? ""}"`);
949
+ const [keyId, versionStr] = splitBlob(blob, prefix, 4);
950
+ return {
951
+ keyId,
952
+ version: parseVersion(versionStr)
953
+ };
954
+ }
955
+ /** Encrypt a value under a managed key. `context` (bound as AAD) defaults to empty. */
956
+ async function kmsEncrypt(material, ref, plaintext, context = "") {
957
+ const key = await importAesKey(material, "encrypt");
958
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
959
+ const ciphertext = await crypto.subtle.encrypt({
960
+ name: "AES-GCM",
961
+ iv,
962
+ additionalData: utf8Encode(encryptAad(ref, context))
963
+ }, key, utf8Encode(plaintext));
964
+ return [
965
+ ENCRYPT_PREFIX,
966
+ ref.keyId,
967
+ String(ref.version),
968
+ toBase64Url(iv),
969
+ toBase64Url(new Uint8Array(ciphertext))
970
+ ].join(".");
971
+ }
972
+ /**
973
+ * Decrypt a `ce1` blob. The caller supplies the material for the key version
974
+ * named in the blob (see `kmsBlobKeyRef`) and the same `context` used to
975
+ * encrypt.
976
+ */
977
+ async function kmsDecrypt(material, blob, context = "") {
978
+ const [keyId, versionStr, ivB64, ctB64] = splitBlob(blob, ENCRYPT_PREFIX, 4);
979
+ const ref = {
980
+ keyId,
981
+ version: parseVersion(versionStr)
982
+ };
983
+ const key = await importAesKey(material, "decrypt");
984
+ try {
985
+ const plaintext = await crypto.subtle.decrypt({
986
+ name: "AES-GCM",
987
+ iv: fromBase64Url(ivB64),
988
+ additionalData: utf8Encode(encryptAad(ref, context))
989
+ }, key, fromBase64Url(ctB64));
990
+ return utf8Decode(new Uint8Array(plaintext));
991
+ } catch {
992
+ throw new SeekritCryptoError("DECRYPT_FAILED", "KMS decryption failed: wrong key/version, tampered data, or mismatched context");
993
+ }
994
+ }
995
+ /**
996
+ * Generate a fresh data key wrapped under a managed key — the envelope pattern
997
+ * for large payloads (AWS KMS GenerateDataKey). Encrypt bulk data with
998
+ * `plaintext`, store `wrapped` alongside it, and recover the key later with
999
+ * `decryptDataKey`.
1000
+ */
1001
+ async function generateDataKey(material, ref) {
1002
+ const plaintext = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
1003
+ const key = await importAesKey(material, "encrypt");
1004
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
1005
+ const ciphertext = await crypto.subtle.encrypt({
1006
+ name: "AES-GCM",
1007
+ iv,
1008
+ additionalData: utf8Encode(dataKeyAad(ref.keyId, String(ref.version)))
1009
+ }, key, plaintext);
1010
+ return {
1011
+ plaintext,
1012
+ wrapped: [
1013
+ DATAKEY_PREFIX,
1014
+ ref.keyId,
1015
+ String(ref.version),
1016
+ toBase64Url(iv),
1017
+ toBase64Url(new Uint8Array(ciphertext))
1018
+ ].join(".")
1019
+ };
1020
+ }
1021
+ /** Recover a data key previously produced by `generateDataKey`. */
1022
+ async function decryptDataKey(material, wrapped) {
1023
+ const [keyId, versionStr, ivB64, ctB64] = splitBlob(wrapped, DATAKEY_PREFIX, 4);
1024
+ const key = await importAesKey(material, "decrypt");
1025
+ try {
1026
+ const dk = await crypto.subtle.decrypt({
1027
+ name: "AES-GCM",
1028
+ iv: fromBase64Url(ivB64),
1029
+ additionalData: utf8Encode(dataKeyAad(keyId, versionStr))
1030
+ }, key, fromBase64Url(ctB64));
1031
+ return new Uint8Array(dk);
1032
+ } catch {
1033
+ throw new SeekritCryptoError("DECRYPT_FAILED", "data key unwrap failed: wrong key or tampered blob");
1034
+ }
1035
+ }
1036
+ //#endregion
1037
+ //#region ../../packages/crypto/src/mongodb.ts
1038
+ /** Generate the ephemeral P-256 keypair a client uses to receive one MongoDB lease. */
1039
+ async function generateMongoRecipientKeyPair() {
1040
+ return generateKeyPair();
1041
+ }
1042
+ /**
1043
+ * Unwrap the `wd1.` blob returned by a MongoDB lease mint using the ephemeral
1044
+ * private key generated for that lease, yielding the plaintext credential.
1045
+ */
1046
+ async function unwrapMongoCredential(wrapped, privateKeyJwk) {
1047
+ const bytes = await unwrapDek(wrapped, await importPrivateKey(privateKeyJwk));
1048
+ let parsed;
1049
+ try {
1050
+ parsed = JSON.parse(utf8Decode(bytes));
1051
+ } catch {
1052
+ throw new SeekritCryptoError("DECRYPT_FAILED", "unwrapped MongoDB credential was not valid JSON");
1053
+ }
1054
+ const c = parsed;
1055
+ if (typeof c.username !== "string" || typeof c.password !== "string" || typeof c.database !== "string" || typeof c.authSource !== "string" || typeof c.host !== "string" || typeof c.port !== "number" || typeof c.expiration !== "string") throw new SeekritCryptoError("DECRYPT_FAILED", "unwrapped MongoDB credential is missing fields");
1056
+ return {
1057
+ username: c.username,
1058
+ password: c.password,
1059
+ database: c.database,
1060
+ authSource: c.authSource,
1061
+ host: c.host,
1062
+ port: c.port,
1063
+ expiration: c.expiration,
1064
+ uri: mongoConnectionUri(c)
1065
+ };
1066
+ }
1067
+ /** Build a `mongodb://user:pass@host:port/db?authSource=…` URI from a credential. */
1068
+ function mongoConnectionUri(cred) {
1069
+ return `mongodb://${encodeURIComponent(cred.username)}:${encodeURIComponent(cred.password)}@${cred.host}:${cred.port}/${cred.database}?authSource=${cred.authSource}`;
1070
+ }
1071
+ //#endregion
787
1072
  //#region ../../packages/crypto/src/mysql.ts
788
1073
  /**
789
1074
  * Client-side construction of a MySQL/MariaDB `mysql_native_password`
@@ -1039,6 +1324,69 @@ async function generatePostgresCredential(options = {}) {
1039
1324
  };
1040
1325
  }
1041
1326
  //#endregion
1327
+ //#region ../../packages/crypto/src/sign.ts
1328
+ /**
1329
+ * Managed signing keys for the client-side KMS — ECDSA over P-256 (the curve
1330
+ * already used for principal keypairs; universal in WebCrypto). A `sign` key's
1331
+ * private half reaches the client only as a `wd1.` grant wrapping its PKCS8
1332
+ * bytes; the public half is published per version so verification needs no
1333
+ * grant. Signatures are `sg1.<keyId>.<version>.<signature>` — the keyId +
1334
+ * version let a verifier fetch the matching version's public key.
1335
+ */
1336
+ const ECDSA_PARAMS = {
1337
+ name: "ECDSA",
1338
+ namedCurve: "P-256"
1339
+ };
1340
+ const ECDSA_SIGN = {
1341
+ name: "ECDSA",
1342
+ hash: "SHA-256"
1343
+ };
1344
+ const SIGN_PREFIX = "sg1";
1345
+ /** Generate a fresh signing keypair for a `sign` KMS key (or a new version). */
1346
+ async function generateSigningKeyMaterial() {
1347
+ const pair = await crypto.subtle.generateKey(ECDSA_PARAMS, true, ["sign", "verify"]);
1348
+ const [publicJwk, pkcs8] = await Promise.all([crypto.subtle.exportKey("jwk", pair.publicKey), crypto.subtle.exportKey("pkcs8", pair.privateKey)]);
1349
+ return {
1350
+ publicKeyJwk: JSON.stringify(publicJwk),
1351
+ privateKeyPkcs8: new Uint8Array(pkcs8)
1352
+ };
1353
+ }
1354
+ /** Import the wrapped-then-unwrapped PKCS8 private key for signing. */
1355
+ async function importSigningKey(pkcs8) {
1356
+ return crypto.subtle.importKey("pkcs8", pkcs8, ECDSA_PARAMS, false, ["sign"]);
1357
+ }
1358
+ /** Import a published public key (JWK) for verification. */
1359
+ async function importVerifyingKey(publicKeyJwk) {
1360
+ return crypto.subtle.importKey("jwk", JSON.parse(publicKeyJwk), ECDSA_PARAMS, false, ["verify"]);
1361
+ }
1362
+ /** Sign a message with a managed signing key; returns an `sg1.` blob. */
1363
+ async function signMessage(privateKey, ref, message) {
1364
+ const data = typeof message === "string" ? utf8Encode(message) : message;
1365
+ const sig = new Uint8Array(await crypto.subtle.sign(ECDSA_SIGN, privateKey, data));
1366
+ return [
1367
+ SIGN_PREFIX,
1368
+ ref.keyId,
1369
+ String(ref.version),
1370
+ toBase64Url(sig)
1371
+ ].join(".");
1372
+ }
1373
+ /** Verify an `sg1.` signature over a message with the version's public key. */
1374
+ async function verifyMessage(publicKey, signature, message) {
1375
+ const [, , sigB64] = splitBlob(signature, SIGN_PREFIX, 3);
1376
+ const data = typeof message === "string" ? utf8Encode(message) : message;
1377
+ return crypto.subtle.verify(ECDSA_SIGN, publicKey, fromBase64Url(sigB64), data);
1378
+ }
1379
+ /** Read the key id + version an `sg1.` signature was produced under. */
1380
+ function signatureKeyRef(signature) {
1381
+ const [keyId, versionStr] = splitBlob(signature, SIGN_PREFIX, 3);
1382
+ const version = Number(versionStr);
1383
+ if (!Number.isInteger(version) || version < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid key version in signature");
1384
+ return {
1385
+ keyId,
1386
+ version
1387
+ };
1388
+ }
1389
+ //#endregion
1042
1390
  //#region ../../packages/crypto/src/ssh.ts
1043
1391
  /**
1044
1392
  * Client-side SSH certificate authority for minting *temporary SSH access*
@@ -1228,7 +1576,7 @@ function isServiceToken(value) {
1228
1576
  }
1229
1577
  //#endregion
1230
1578
  //#region package.json
1231
- var version = "0.15.0";
1579
+ var version = "0.17.0";
1232
1580
  //#endregion
1233
1581
  //#region ../../packages/api-client/src/index.ts
1234
1582
  var SeekritApiError = class extends Error {
@@ -1301,6 +1649,15 @@ var SeekritClient = class {
1301
1649
  listMembers(orgId) {
1302
1650
  return this.request("GET", `/v1/orgs/${orgId}/members`);
1303
1651
  }
1652
+ listInvites(orgId) {
1653
+ return this.request("GET", `/v1/orgs/${orgId}/invites`);
1654
+ }
1655
+ createInvite(orgId, input) {
1656
+ return this.request("POST", `/v1/orgs/${orgId}/invites`, input);
1657
+ }
1658
+ revokeInvite(orgId, inviteId) {
1659
+ return this.request("DELETE", `/v1/orgs/${orgId}/invites/${inviteId}`);
1660
+ }
1304
1661
  listApps(orgId) {
1305
1662
  return this.request("GET", `/v1/orgs/${orgId}/apps`);
1306
1663
  }
@@ -1391,6 +1748,42 @@ var SeekritClient = class {
1391
1748
  revokeToken(orgId, tokenId) {
1392
1749
  return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
1393
1750
  }
1751
+ /** Keys the caller can see: all org keys for admins, granted keys otherwise. */
1752
+ listKmsKeys(orgId) {
1753
+ return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
1754
+ }
1755
+ createKmsKey(orgId, input) {
1756
+ return this.request("POST", `/v1/orgs/${orgId}/kms/keys`, input);
1757
+ }
1758
+ /** Key metadata + every version (admin). */
1759
+ getKmsKey(orgId, keyId) {
1760
+ return this.request("GET", `/v1/orgs/${orgId}/kms/keys/${keyId}`);
1761
+ }
1762
+ /** The caller's wrapped key material for a key, across granted versions. */
1763
+ getMyKmsKey(orgId, keyId) {
1764
+ return this.request("GET", `/v1/orgs/${orgId}/kms/keys/${keyId}/key`);
1765
+ }
1766
+ /** Published public keys of a `sign` key (grant-free within the org). */
1767
+ getKmsPublicKeys(orgId, keyId) {
1768
+ return this.request("GET", `/v1/orgs/${orgId}/kms/keys/${keyId}/public`);
1769
+ }
1770
+ listKmsGrants(orgId, keyId) {
1771
+ return this.request("GET", `/v1/orgs/${orgId}/kms/keys/${keyId}/grants`);
1772
+ }
1773
+ grantKmsKey(orgId, keyId, input) {
1774
+ return this.request("POST", `/v1/orgs/${orgId}/kms/keys/${keyId}/grants`, input);
1775
+ }
1776
+ /** Revoke a principal entirely (all versions). */
1777
+ revokeKmsKey(orgId, keyId, principal) {
1778
+ const qs = new URLSearchParams(principal).toString();
1779
+ return this.request("DELETE", `/v1/orgs/${orgId}/kms/keys/${keyId}/grants?${qs}`);
1780
+ }
1781
+ rotateKmsKey(orgId, keyId, input) {
1782
+ return this.request("POST", `/v1/orgs/${orgId}/kms/keys/${keyId}/rotate`, input);
1783
+ }
1784
+ disableKmsKey(orgId, keyId) {
1785
+ return this.request("POST", `/v1/orgs/${orgId}/kms/keys/${keyId}/disable`);
1786
+ }
1394
1787
  /** The broker's public key — wrap the admin credential to it before registering a target. */
1395
1788
  getLeaseBrokerKey(orgId) {
1396
1789
  return this.request("GET", `/v1/orgs/${orgId}/leases/broker-key`);
@@ -1677,7 +2070,7 @@ async function resolveGroup(ctx, opts) {
1677
2070
  * `sts:AssumeRole` on the target role.
1678
2071
  */
1679
2072
  /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
1680
- function parseTtlSeconds$5(input) {
2073
+ function parseTtlSeconds$6(input) {
1681
2074
  const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1682
2075
  if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
1683
2076
  return Number(m[1]) * ({
@@ -1717,7 +2110,7 @@ function registerAwsCommands(program) {
1717
2110
  region: options.region,
1718
2111
  ...options.externalId ? { externalId: options.externalId } : {},
1719
2112
  ...sessionPolicy ? { sessionPolicy } : {},
1720
- ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
2113
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$6(options.maxTtl) } : {}
1721
2114
  };
1722
2115
  const baseCredential = resolveBaseCredential(options);
1723
2116
  const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
@@ -1765,7 +2158,7 @@ function registerAwsCommands(program) {
1765
2158
  if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
1766
2159
  const cfg = t.config;
1767
2160
  if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
1768
- const ttlSeconds = parseTtlSeconds$5(options.ttl);
2161
+ const ttlSeconds = parseTtlSeconds$6(options.ttl);
1769
2162
  if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
1770
2163
  const recipient = await generateAwsRecipientKeyPair();
1771
2164
  const { aws: leased } = await ctx.client.mintLease(org.id, {
@@ -1870,7 +2263,7 @@ function formatSecrets(values, format) {
1870
2263
  * `roles/iam.serviceAccountTokenCreator` on the target.
1871
2264
  */
1872
2265
  /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
1873
- function parseTtlSeconds$4(input) {
2266
+ function parseTtlSeconds$5(input) {
1874
2267
  const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1875
2268
  if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
1876
2269
  return Number(m[1]) * ({
@@ -1914,7 +2307,7 @@ function registerGcpCommands(program) {
1914
2307
  serviceAccount: options.serviceAccount,
1915
2308
  ...options.scope?.length ? { scopes: options.scope } : {},
1916
2309
  ...options.delegate?.length ? { delegates: options.delegate } : {},
1917
- ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$4(options.maxTtl) } : {}
2310
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$5(options.maxTtl) } : {}
1918
2311
  };
1919
2312
  const keyJson = resolveServiceAccountKey(options);
1920
2313
  const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
@@ -1961,7 +2354,7 @@ function registerGcpCommands(program) {
1961
2354
  const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
1962
2355
  if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
1963
2356
  if (t.config.provider !== "gcp") fail(`"${t.name}" is not a gcp target (see \`seekrit aws\`)`);
1964
- const ttlSeconds = parseTtlSeconds$4(options.ttl);
2357
+ const ttlSeconds = parseTtlSeconds$5(options.ttl);
1965
2358
  if (ttlSeconds < 60) fail(`--ttl must be at least 60s`);
1966
2359
  if (ttlSeconds > 43200) fail(`--ttl must be at most ${GCP_MAX_TTL_SECONDS / 3600}h`);
1967
2360
  const recipient = await generateGcpRecipientKeyPair();
@@ -1996,6 +2389,419 @@ function registerGcpCommands(program) {
1996
2389
  });
1997
2390
  }
1998
2391
  //#endregion
2392
+ //#region src/kms.ts
2393
+ /** Collect a repeatable option into a list. */
2394
+ function collect$5(value, acc = []) {
2395
+ acc.push(value);
2396
+ return acc;
2397
+ }
2398
+ /** The calling principal's identity + public key (for a self-grant). */
2399
+ async function kmsCallerIdentity(ctx) {
2400
+ if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
2401
+ const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
2402
+ const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
2403
+ return {
2404
+ principalType: "service_token",
2405
+ principalId: tokenId,
2406
+ publicKeyJwk: JSON.stringify(pub)
2407
+ };
2408
+ }
2409
+ const { user } = await ctx.client.me();
2410
+ if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
2411
+ return {
2412
+ principalType: "user",
2413
+ principalId: user.id,
2414
+ publicKeyJwk: user.publicKeyJwk
2415
+ };
2416
+ }
2417
+ /** Look up an org member (by email) or service token (by id) as a grant recipient. */
2418
+ async function kmsResolveRecipient(ctx, orgId, who) {
2419
+ if (who.user) {
2420
+ const { members } = await ctx.client.listMembers(orgId);
2421
+ const m = members.find((x) => x.email === who.user);
2422
+ if (!m) fail(`no member ${who.user}`);
2423
+ if (!m.publicKeyJwk) fail(`${who.user} has not completed key setup`);
2424
+ return {
2425
+ principalType: "user",
2426
+ principalId: m.userId,
2427
+ publicKeyJwk: m.publicKeyJwk
2428
+ };
2429
+ }
2430
+ if (who.token) {
2431
+ const { tokens } = await ctx.client.listTokens(orgId);
2432
+ const t = tokens.find((x) => x.id === who.token);
2433
+ if (!t) fail(`no service token ${who.token}`);
2434
+ return {
2435
+ principalType: "service_token",
2436
+ principalId: t.id,
2437
+ publicKeyJwk: t.publicKeyJwk
2438
+ };
2439
+ }
2440
+ fail("specify --user <email> or --token <id>");
2441
+ }
2442
+ async function kmsResolveKey(ctx, orgId, ref) {
2443
+ const { keys } = await ctx.client.listKmsKeys(orgId);
2444
+ const key = keys.find((k) => k.id === ref || k.name === ref);
2445
+ if (!key) fail(`no KMS key "${ref}"`);
2446
+ return key;
2447
+ }
2448
+ /** Recover a key's material for one version (default: current), for the caller. */
2449
+ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
2450
+ const mat = await ctx.client.getMyKmsKey(orgId, keyId);
2451
+ const v = version ?? mat.currentVersion;
2452
+ const grant = mat.grants.find((g) => g.version === v);
2453
+ if (!grant) fail(`no grant for version ${v} of this key`);
2454
+ return {
2455
+ material: await unwrapDek(grant.wrappedKey, await getPrivateKey(ctx)),
2456
+ version: v,
2457
+ currentVersion: mat.currentVersion
2458
+ };
2459
+ }
2460
+ function registerKmsCommands(program) {
2461
+ const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
2462
+ 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$5, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$5, []).action(async (options) => {
2463
+ if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
2464
+ if (options.app && options.group) fail("pass at most one of --app or --group");
2465
+ const ctx = buildContext();
2466
+ const org = await resolveOrg(ctx, options.org);
2467
+ let toWrap;
2468
+ let publicKeyJwk;
2469
+ if (options.purpose === "encrypt") toWrap = generateEncryptKeyMaterial();
2470
+ else {
2471
+ const km = await generateSigningKeyMaterial();
2472
+ toWrap = km.privateKeyPkcs8;
2473
+ publicKeyJwk = km.publicKeyJwk;
2474
+ }
2475
+ let applicationId;
2476
+ let groupId;
2477
+ if (options.app) {
2478
+ const { apps } = await ctx.client.listApps(org.id);
2479
+ const app = apps.find((a) => a.slug === options.app || a.id === options.app);
2480
+ if (!app) fail(`no app "${options.app}" in ${org.slug}`);
2481
+ applicationId = app.id;
2482
+ } else if (options.group) {
2483
+ const { groups } = await ctx.client.listGroups(org.id);
2484
+ const group = groups.find((g) => g.slug === options.group || g.id === options.group);
2485
+ if (!group) fail(`no group "${options.group}" in ${org.slug}`);
2486
+ groupId = group.id;
2487
+ }
2488
+ const recipients = [await kmsCallerIdentity(ctx)];
2489
+ for (const email of options.grantUser) recipients.push(await kmsResolveRecipient(ctx, org.id, { user: email }));
2490
+ for (const tokenId of options.grantToken) recipients.push(await kmsResolveRecipient(ctx, org.id, { token: tokenId }));
2491
+ const seen = /* @__PURE__ */ new Set();
2492
+ const grants = [];
2493
+ for (const r of recipients) {
2494
+ const dedupeKey = `${r.principalType}:${r.principalId}`;
2495
+ if (seen.has(dedupeKey)) continue;
2496
+ seen.add(dedupeKey);
2497
+ grants.push({
2498
+ principalType: r.principalType,
2499
+ principalId: r.principalId,
2500
+ wrappedKey: await wrapDek(toWrap, r.publicKeyJwk)
2501
+ });
2502
+ }
2503
+ const input = {
2504
+ name: options.name,
2505
+ purpose: options.purpose,
2506
+ spec: options.purpose === "sign" ? "ecdsa-p256" : "aes-256-gcm",
2507
+ ...applicationId ? { applicationId } : {},
2508
+ ...groupId ? { groupId } : {},
2509
+ ...publicKeyJwk ? { publicKeyJwk } : {},
2510
+ grants
2511
+ };
2512
+ const { key } = await ctx.client.createKmsKey(org.id, input);
2513
+ console.error(`created ${key.purpose} key ${key.name} (${key.id}), ${grants.length} grant(s)`);
2514
+ });
2515
+ kms.command("ls").description("list keys you can see").option("--org <slug>").action(async (options) => {
2516
+ const ctx = buildContext();
2517
+ const org = await resolveOrg(ctx, options.org);
2518
+ const { keys } = await ctx.client.listKmsKeys(org.id);
2519
+ if (keys.length === 0) {
2520
+ console.error("(no keys)");
2521
+ return;
2522
+ }
2523
+ for (const k of keys) {
2524
+ const scope = k.applicationId ? `app:${k.applicationId}` : k.groupId ? `group:${k.groupId}` : "org";
2525
+ const state = k.disabledAt ? " [disabled]" : "";
2526
+ console.log(`${k.name}\t${k.purpose}\tv${k.currentVersion}\t${scope}\t${k.id}${state}`);
2527
+ }
2528
+ });
2529
+ kms.command("grant").description("grant a principal use of a key's current version").requiredOption("--key <name>", "key name or id").option("--org <slug>").option("--user <email>", "grant an org member").option("--token <tokenId>", "grant a service token").action(async (options) => {
2530
+ if (!options.user === !options.token) fail("pass exactly one of --user or --token");
2531
+ const ctx = buildContext();
2532
+ const org = await resolveOrg(ctx, options.org);
2533
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2534
+ const recipient = await kmsResolveRecipient(ctx, org.id, options);
2535
+ const { material } = await kmsRecoverMaterial(ctx, org.id, key.id);
2536
+ await ctx.client.grantKmsKey(org.id, key.id, {
2537
+ principalType: recipient.principalType,
2538
+ principalId: recipient.principalId,
2539
+ wrappedKey: await wrapDek(material, recipient.publicKeyJwk)
2540
+ });
2541
+ console.error(`granted ${key.name} to ${recipient.principalId}`);
2542
+ });
2543
+ kms.command("revoke").description("revoke a principal from a key (all versions)").requiredOption("--key <name>", "key name or id").option("--org <slug>").option("--user <email>").option("--token <tokenId>").action(async (options) => {
2544
+ if (!options.user === !options.token) fail("pass exactly one of --user or --token");
2545
+ const ctx = buildContext();
2546
+ const org = await resolveOrg(ctx, options.org);
2547
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2548
+ const recipient = await kmsResolveRecipient(ctx, org.id, options);
2549
+ await ctx.client.revokeKmsKey(org.id, key.id, {
2550
+ principalType: recipient.principalType,
2551
+ principalId: recipient.principalId
2552
+ });
2553
+ console.error(`revoked ${recipient.principalId} from ${key.name}`);
2554
+ });
2555
+ kms.command("rotate").description("add a new key version and re-wrap it for every current grantee").requiredOption("--key <name>", "key name or id").option("--org <slug>").action(async (options) => {
2556
+ const ctx = buildContext();
2557
+ const org = await resolveOrg(ctx, options.org);
2558
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2559
+ let toWrap;
2560
+ let publicKeyJwk;
2561
+ if (key.purpose === "encrypt") toWrap = generateEncryptKeyMaterial();
2562
+ else {
2563
+ const km = await generateSigningKeyMaterial();
2564
+ toWrap = km.privateKeyPkcs8;
2565
+ publicKeyJwk = km.publicKeyJwk;
2566
+ }
2567
+ const { grants: current } = await ctx.client.listKmsGrants(org.id, key.id);
2568
+ const [{ members }, { tokens }] = await Promise.all([ctx.client.listMembers(org.id), ctx.client.listTokens(org.id)]);
2569
+ const grants = [];
2570
+ for (const g of current) {
2571
+ const pub = g.principalType === "user" ? members.find((m) => m.userId === g.principalId)?.publicKeyJwk : tokens.find((t) => t.id === g.principalId)?.publicKeyJwk;
2572
+ if (!pub) {
2573
+ console.error(`skipping ${g.principalType} ${g.principalId} (no public key)`);
2574
+ continue;
2575
+ }
2576
+ grants.push({
2577
+ principalType: g.principalType,
2578
+ principalId: g.principalId,
2579
+ wrappedKey: await wrapDek(toWrap, pub)
2580
+ });
2581
+ }
2582
+ if (grants.length === 0) fail("no grantees with public keys to re-wrap for");
2583
+ const { key: rotated } = await ctx.client.rotateKmsKey(org.id, key.id, {
2584
+ ...publicKeyJwk ? { publicKeyJwk } : {},
2585
+ grants
2586
+ });
2587
+ console.error(`rotated ${rotated.name} to v${rotated.currentVersion} (${grants.length} grantees)`);
2588
+ });
2589
+ kms.command("disable").description("disable a key (blocks new grants/rotations; existing data still decrypts)").requiredOption("--key <name>", "key name or id").option("--org <slug>").action(async (options) => {
2590
+ const ctx = buildContext();
2591
+ const org = await resolveOrg(ctx, options.org);
2592
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2593
+ await ctx.client.disableKmsKey(org.id, key.id);
2594
+ console.error(`disabled ${key.name}`);
2595
+ });
2596
+ kms.command("encrypt").description("encrypt stdin under a key (prints a ce1 ciphertext blob)").requiredOption("--key <name>", "key name or id").option("--org <slug>").option("--context <ctx>", "encryption context bound as AAD (required identically to decrypt)").action(async (options) => {
2597
+ const ctx = buildContext();
2598
+ const org = await resolveOrg(ctx, options.org);
2599
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2600
+ if (key.purpose !== "encrypt") fail(`${key.name} is a ${key.purpose} key`);
2601
+ const { material, currentVersion } = await kmsRecoverMaterial(ctx, org.id, key.id);
2602
+ const plaintext = (await readStdin()).replace(/\n$/, "");
2603
+ const blob = await kmsEncrypt(material, {
2604
+ keyId: key.id,
2605
+ version: currentVersion
2606
+ }, plaintext, options.context ?? "");
2607
+ console.log(blob);
2608
+ });
2609
+ kms.command("decrypt").description("decrypt a ce1 blob from stdin").requiredOption("--key <name>", "key name or id").option("--org <slug>").option("--context <ctx>", "the same encryption context used to encrypt").action(async (options) => {
2610
+ const ctx = buildContext();
2611
+ const org = await resolveOrg(ctx, options.org);
2612
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2613
+ const blob = (await readStdin()).trim();
2614
+ const ref = kmsBlobKeyRef(blob);
2615
+ const { material } = await kmsRecoverMaterial(ctx, org.id, key.id, ref.version);
2616
+ console.log(await kmsDecrypt(material, blob, options.context ?? ""));
2617
+ });
2618
+ kms.command("generate-data-key").description("generate a data key: prints JSON {plaintextBase64, wrapped}").requiredOption("--key <name>", "key name or id").option("--org <slug>").action(async (options) => {
2619
+ const ctx = buildContext();
2620
+ const org = await resolveOrg(ctx, options.org);
2621
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2622
+ if (key.purpose !== "encrypt") fail(`${key.name} is a ${key.purpose} key`);
2623
+ const { material, currentVersion } = await kmsRecoverMaterial(ctx, org.id, key.id);
2624
+ const dk = await generateDataKey(material, {
2625
+ keyId: key.id,
2626
+ version: currentVersion
2627
+ });
2628
+ console.log(JSON.stringify({
2629
+ plaintextBase64: toBase64(dk.plaintext),
2630
+ wrapped: dk.wrapped
2631
+ }));
2632
+ });
2633
+ kms.command("open-data-key").description("recover a data key from a dk1 blob on stdin (prints plaintext base64)").requiredOption("--key <name>", "key name or id").option("--org <slug>").action(async (options) => {
2634
+ const ctx = buildContext();
2635
+ const org = await resolveOrg(ctx, options.org);
2636
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2637
+ const wrapped = (await readStdin()).trim();
2638
+ const ref = kmsBlobKeyRef(wrapped);
2639
+ const { material } = await kmsRecoverMaterial(ctx, org.id, key.id, ref.version);
2640
+ console.log(toBase64(await decryptDataKey(material, wrapped)));
2641
+ });
2642
+ kms.command("sign").description("sign stdin with a signing key (prints an sg1 signature)").requiredOption("--key <name>", "key name or id").option("--org <slug>").action(async (options) => {
2643
+ const ctx = buildContext();
2644
+ const org = await resolveOrg(ctx, options.org);
2645
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2646
+ if (key.purpose !== "sign") fail(`${key.name} is a ${key.purpose} key`);
2647
+ const { material, currentVersion } = await kmsRecoverMaterial(ctx, org.id, key.id);
2648
+ const message = await readStdin();
2649
+ const privateKey = await importSigningKey(material);
2650
+ console.log(await signMessage(privateKey, {
2651
+ keyId: key.id,
2652
+ version: currentVersion
2653
+ }, message));
2654
+ });
2655
+ kms.command("verify").description("verify an sg1 signature over stdin (exit 0 = valid)").requiredOption("--key <name>", "key name or id").requiredOption("--signature <sg1>", "the signature blob").option("--org <slug>").action(async (options) => {
2656
+ const ctx = buildContext();
2657
+ const org = await resolveOrg(ctx, options.org);
2658
+ const key = await kmsResolveKey(ctx, org.id, options.key);
2659
+ const ref = signatureKeyRef(options.signature);
2660
+ const { versions } = await ctx.client.getKmsPublicKeys(org.id, key.id);
2661
+ const pub = versions.find((v) => v.version === ref.version)?.publicKeyJwk;
2662
+ if (!pub) fail(`no published public key for version ${ref.version}`);
2663
+ const message = await readStdin();
2664
+ if (await verifyMessage(await importVerifyingKey(pub), options.signature, message)) console.error("valid");
2665
+ else {
2666
+ console.error("INVALID");
2667
+ process.exitCode = 1;
2668
+ }
2669
+ });
2670
+ }
2671
+ //#endregion
2672
+ //#region src/mongodb.ts
2673
+ /**
2674
+ * `seekrit mongodb` — temporary MongoDB credentials (Vault-style dynamic
2675
+ * secrets), the tier-2 sibling of `seekrit aws`.
2676
+ *
2677
+ * MongoDB hashes the password server-side (no verifier injection), so minting
2678
+ * generates an ephemeral P-256 keypair on THIS machine and sends only the public
2679
+ * key; the broker creates the user and returns the credential wrapped to that
2680
+ * key, so the control plane only ever relays ciphertext and only this machine
2681
+ * can unwrap it. Registering a target wraps the admin connection string to the
2682
+ * broker's public key locally, so the control plane never sees it either.
2683
+ */
2684
+ /** Parse a duration like `30m`, `1h`, or a bare seconds count. */
2685
+ function parseTtlSeconds$4(input) {
2686
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
2687
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 8h)`);
2688
+ return Number(m[1]) * ({
2689
+ s: 1,
2690
+ m: 60,
2691
+ h: 3600,
2692
+ d: 86400
2693
+ }[m[2] || "s"] ?? 1);
2694
+ }
2695
+ /** Collect a repeatable option into an array. */
2696
+ function collect$4(value, previous) {
2697
+ return [...previous, value];
2698
+ }
2699
+ /** Parse `readWrite@app` → { role, db } for a custom target. */
2700
+ function parseRole(spec) {
2701
+ const [role, db] = spec.split("@");
2702
+ if (!role || !db) fail(`invalid --role "${spec}" (use role@database, e.g. readWrite@app)`);
2703
+ return {
2704
+ role,
2705
+ db
2706
+ };
2707
+ }
2708
+ /**
2709
+ * The admin connection string the broker provisions with. From `--uri` or
2710
+ * SEEKRIT_MONGODB_ADMIN_URL; never leaves this machine unwrapped — it is wrapped
2711
+ * to the broker key. Needs `userAdmin` on the target database.
2712
+ */
2713
+ function resolveAdminUri(uri) {
2714
+ const value = uri ?? process.env.SEEKRIT_MONGODB_ADMIN_URL;
2715
+ if (!value) fail("provide the admin connection string via --uri or SEEKRIT_MONGODB_ADMIN_URL (mongodb://user:pass@host:port/?authSource=admin; needs userAdmin on the database)");
2716
+ return value;
2717
+ }
2718
+ function registerMongoCommands(program) {
2719
+ const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
2720
+ const target = mongo.command("target").description("manage MongoDB targets");
2721
+ target.command("add").description("register a MongoDB cluster to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-app").requiredOption("--database <db>", "the database leased users get access to, e.g. app").option("--uri <uri>", "admin connection string (else SEEKRIT_MONGODB_ADMIN_URL)").option("--org <slug>").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--role <role@db>", "grant for a custom target (repeatable)", collect$4, []).option("--auth-source <db>", "authentication database (default admin)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--no-tls", "disable TLS to the cluster (TLS is on by default)").action(async (options) => {
2722
+ const ctx = buildContext();
2723
+ const org = await resolveOrg(ctx, options.org);
2724
+ const adminUri = resolveAdminUri(options.uri);
2725
+ const url = new URL(adminUri);
2726
+ const accessLevel = options.access;
2727
+ if (accessLevel === "custom" && options.role.length === 0) fail("--access custom requires at least one --role role@db");
2728
+ const config = {
2729
+ provider: "mongodb",
2730
+ executor: "in_do",
2731
+ accessLevel,
2732
+ connection: {
2733
+ host: url.hostname,
2734
+ port: url.port ? Number(url.port) : 27017,
2735
+ database: options.database
2736
+ },
2737
+ ...options.authSource ? { authSource: options.authSource } : {},
2738
+ ...accessLevel === "custom" ? { roles: options.role.map(parseRole) } : {},
2739
+ ...options.tls ? {} : { tls: false }
2740
+ };
2741
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
2742
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminUri), publicKeyJwk);
2743
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
2744
+ name: options.name,
2745
+ config,
2746
+ wrappedAdminSecret
2747
+ });
2748
+ console.error(`registered MongoDB target ${created.name} (${created.id})`);
2749
+ console.error("\nEnsure a provisioning user exists, then `seekrit mongodb lease`:\n");
2750
+ console.log(mongoAdminSetupInstructions(config));
2751
+ });
2752
+ target.command("list").description("list MongoDB targets").option("--org <slug>").action(async (options) => {
2753
+ const ctx = buildContext();
2754
+ const org = await resolveOrg(ctx, options.org);
2755
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
2756
+ for (const t of targets) {
2757
+ const cfg = t.config;
2758
+ if (cfg.provider !== "mongodb") continue;
2759
+ const host = `${cfg.connection.host}:${cfg.connection.port}`;
2760
+ console.log(`${t.id}\t${t.name}\t${host}\t${cfg.connection.database}\t${cfg.accessLevel ?? "readonly"}`);
2761
+ }
2762
+ });
2763
+ target.command("rm <targetId>").description("delete a MongoDB target").option("--org <slug>").action(async (targetId, options) => {
2764
+ const ctx = buildContext();
2765
+ const org = await resolveOrg(ctx, options.org);
2766
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
2767
+ console.error(`deleted ${targetId}`);
2768
+ });
2769
+ mongo.command("lease <target>").description("mint short-lived MongoDB credentials; prints a ready-to-use connection URI").option("--org <slug>").option("--ttl <duration>", "credential lifetime, e.g. 30m, 1h, 8h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
2770
+ const ctx = buildContext();
2771
+ const org = await resolveOrg(ctx, options.org);
2772
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
2773
+ const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
2774
+ if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
2775
+ if (t.config.provider !== "mongodb") fail(`"${t?.name}" is not a mongodb target (see \`seekrit aws\`)`);
2776
+ const recipient = await generateMongoRecipientKeyPair();
2777
+ const { mongodb: leased } = await ctx.client.mintLease(org.id, {
2778
+ provider: "mongodb",
2779
+ targetId: t.id,
2780
+ recipientPublicKey: recipient.publicKeyJwk,
2781
+ ttlSeconds: parseTtlSeconds$4(options.ttl)
2782
+ });
2783
+ const cred = await unwrapMongoCredential(leased.wrappedCredential, recipient.privateKeyJwk);
2784
+ console.error(`leased ${cred.username} on ${cred.host}:${cred.port}/${cred.database} — expires ${cred.expiration}`);
2785
+ if (options.json) console.log(JSON.stringify(cred, null, 2));
2786
+ else console.log(`export MONGODB_URI='${cred.uri}'`);
2787
+ });
2788
+ mongo.command("leases").description("list MongoDB leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
2789
+ const ctx = buildContext();
2790
+ const org = await resolveOrg(ctx, options.org);
2791
+ const { leases } = await ctx.client.listLeases(org.id);
2792
+ for (const l of leases) {
2793
+ if (l.provider !== "mongodb") continue;
2794
+ console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
2795
+ }
2796
+ });
2797
+ mongo.command("revoke <leaseId>").description("revoke a lease now (drops the MongoDB user immediately)").option("--org <slug>").action(async (leaseId, options) => {
2798
+ const ctx = buildContext();
2799
+ const org = await resolveOrg(ctx, options.org);
2800
+ await ctx.client.revokeLease(org.id, leaseId);
2801
+ console.error(`revoked ${leaseId} (the MongoDB user has been dropped)`);
2802
+ });
2803
+ }
2804
+ //#endregion
1999
2805
  //#region src/provisioner.ts
2000
2806
  /**
2001
2807
  * `seekrit provisioner` — helpers for the self-hosted **remote executor**
@@ -3087,8 +3893,10 @@ registerProvisionerCommands(program);
3087
3893
  registerSshCommands(program);
3088
3894
  registerAwsCommands(program);
3089
3895
  registerGcpCommands(program);
3896
+ registerMongoCommands(program);
3897
+ registerKmsCommands(program);
3090
3898
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
3091
- const { runMcpServer } = await import("./mcp-ARBuneH3.js");
3899
+ const { runMcpServer } = await import("./mcp-CVhEQDfd.js");
3092
3900
  await runMcpServer();
3093
3901
  });
3094
3902
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -3102,4 +3910,4 @@ program.parseAsync(argv).catch((err) => {
3102
3910
  fail(err instanceof Error ? err.message : String(err));
3103
3911
  });
3104
3912
  //#endregion
3105
- 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 };
3913
+ export { generateEncryptKeyMaterial as A, importVerifyingKey as C, generatePostgresCredential as D, verifyMessage as E, generateDek as F, toBase64 as I, kmsDecrypt as M, kmsEncrypt as N, generateMysqlCredential as O, wrapDek as P, importSigningKey as S, signatureKeyRef as T, version as _, kmsRecoverMaterial as a, parseServiceToken as b, resolveAppEnv as c, resolveOrg as d, getDek as f, writeProjectConfig as g, setFailThrows as h, kmsCallerIdentity as i, kmsBlobKeyRef as j, generateDataKey as k, resolveEnvTarget as l, tryBuildContext as m, fetchDecryptedSecrets as n, kmsResolveKey as o, isTokenAuth as p, materializeEnv as r, kmsResolveRecipient as s, encryptAndSetSecret as t, resolveGroup as u, createServiceToken as v, signMessage as w, generateSigningKeyMaterial as x, isServiceToken as y };
@@ -1,4 +1,4 @@
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";
1
+ import { A as generateEncryptKeyMaterial, C as importVerifyingKey, D as generatePostgresCredential, E as verifyMessage, F as generateDek, I as toBase64, M as kmsDecrypt, N as kmsEncrypt, O as generateMysqlCredential, P as wrapDek, S as importSigningKey, T as signatureKeyRef, _ as version, a as kmsRecoverMaterial, b as parseServiceToken, c as resolveAppEnv, d as resolveOrg, f as getDek, g as writeProjectConfig, h as setFailThrows, i as kmsCallerIdentity, j as kmsBlobKeyRef, k as generateDataKey, l as resolveEnvTarget, m as tryBuildContext, n as fetchDecryptedSecrets, o as kmsResolveKey, p as isTokenAuth, r as materializeEnv, s as kmsResolveRecipient, t as encryptAndSetSecret, u as resolveGroup, v as createServiceToken, w as signMessage, x as generateSigningKeyMaterial, y as isServiceToken } 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";
@@ -206,6 +206,158 @@ async function runMcpServer() {
206
206
  const orgRef = await resolveOrg(ctx, org);
207
207
  return (await ctx.client.listMembers(orgRef.id)).members;
208
208
  });
209
+ tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", { org: z.string().optional() }, async ({ org }) => {
210
+ const ctx = getCtx();
211
+ const orgRef = await resolveOrg(ctx, org);
212
+ return (await ctx.client.listKmsKeys(orgRef.id)).keys;
213
+ });
214
+ tool("kms_create_key", "Create an org-scoped managed key. Material is generated locally and wrapped to each grantee (self plus any listed users/tokens); the server never sees it. Use the CLI for app/group-scoped keys.", {
215
+ org: z.string().optional(),
216
+ name: z.string(),
217
+ purpose: z.enum(["encrypt", "sign"]),
218
+ grantUsers: z.array(z.string()).optional(),
219
+ grantTokens: z.array(z.string()).optional()
220
+ }, async ({ org, name, purpose, grantUsers, grantTokens }) => {
221
+ const ctx = getCtx();
222
+ const orgRef = await resolveOrg(ctx, org);
223
+ let toWrap;
224
+ let publicKeyJwk;
225
+ if (purpose === "encrypt") toWrap = generateEncryptKeyMaterial();
226
+ else {
227
+ const km = await generateSigningKeyMaterial();
228
+ toWrap = km.privateKeyPkcs8;
229
+ publicKeyJwk = km.publicKeyJwk;
230
+ }
231
+ const recipients = [await kmsCallerIdentity(ctx)];
232
+ for (const u of grantUsers ?? []) recipients.push(await kmsResolveRecipient(ctx, orgRef.id, { user: u }));
233
+ for (const t of grantTokens ?? []) recipients.push(await kmsResolveRecipient(ctx, orgRef.id, { token: t }));
234
+ const seen = /* @__PURE__ */ new Set();
235
+ const grants = [];
236
+ for (const r of recipients) {
237
+ const dedupeKey = `${r.principalType}:${r.principalId}`;
238
+ if (seen.has(dedupeKey)) continue;
239
+ seen.add(dedupeKey);
240
+ grants.push({
241
+ principalType: r.principalType,
242
+ principalId: r.principalId,
243
+ wrappedKey: await wrapDek(toWrap, r.publicKeyJwk)
244
+ });
245
+ }
246
+ const { key } = await ctx.client.createKmsKey(orgRef.id, {
247
+ name,
248
+ purpose,
249
+ spec: purpose === "sign" ? "ecdsa-p256" : "aes-256-gcm",
250
+ ...publicKeyJwk ? { publicKeyJwk } : {},
251
+ grants
252
+ });
253
+ return key;
254
+ });
255
+ tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", {
256
+ org: z.string().optional(),
257
+ key: z.string(),
258
+ user: z.string().optional(),
259
+ token: z.string().optional()
260
+ }, async ({ org, key, user, token }) => {
261
+ const ctx = getCtx();
262
+ ensureDecryptable(ctx);
263
+ const orgRef = await resolveOrg(ctx, org);
264
+ const k = await kmsResolveKey(ctx, orgRef.id, key);
265
+ const recipient = await kmsResolveRecipient(ctx, orgRef.id, {
266
+ user,
267
+ token
268
+ });
269
+ const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id);
270
+ await ctx.client.grantKmsKey(orgRef.id, k.id, {
271
+ principalType: recipient.principalType,
272
+ principalId: recipient.principalId,
273
+ wrappedKey: await wrapDek(material, recipient.publicKeyJwk)
274
+ });
275
+ return {
276
+ granted: recipient.principalId,
277
+ key: k.name
278
+ };
279
+ });
280
+ tool("kms_encrypt", "Encrypt a value under a managed encrypt key; returns a ce1 ciphertext blob. `context` (if given) is bound as AAD and must be supplied identically to decrypt.", {
281
+ org: z.string().optional(),
282
+ key: z.string(),
283
+ plaintext: z.string(),
284
+ context: z.string().optional()
285
+ }, async ({ org, key, plaintext, context }) => {
286
+ const ctx = getCtx();
287
+ ensureDecryptable(ctx);
288
+ const orgRef = await resolveOrg(ctx, org);
289
+ const k = await kmsResolveKey(ctx, orgRef.id, key);
290
+ if (k.purpose !== "encrypt") throw new Error(`${k.name} is a ${k.purpose} key`);
291
+ const { material, currentVersion } = await kmsRecoverMaterial(ctx, orgRef.id, k.id);
292
+ return { ciphertext: await kmsEncrypt(material, {
293
+ keyId: k.id,
294
+ version: currentVersion
295
+ }, plaintext, context ?? "") };
296
+ });
297
+ tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", {
298
+ org: z.string().optional(),
299
+ key: z.string(),
300
+ ciphertext: z.string(),
301
+ context: z.string().optional()
302
+ }, async ({ org, key, ciphertext, context }) => {
303
+ const ctx = getCtx();
304
+ ensureDecryptable(ctx);
305
+ const orgRef = await resolveOrg(ctx, org);
306
+ const k = await kmsResolveKey(ctx, orgRef.id, key);
307
+ const ref = kmsBlobKeyRef(ciphertext);
308
+ const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id, ref.version);
309
+ return { plaintext: await kmsDecrypt(material, ciphertext, context ?? "") };
310
+ });
311
+ tool("kms_generate_data_key", "Generate a data key under a managed encrypt key (envelope encryption). Returns the plaintext key (base64) and its wrapped form to store.", {
312
+ org: z.string().optional(),
313
+ key: z.string()
314
+ }, async ({ org, key }) => {
315
+ const ctx = getCtx();
316
+ ensureDecryptable(ctx);
317
+ const orgRef = await resolveOrg(ctx, org);
318
+ const k = await kmsResolveKey(ctx, orgRef.id, key);
319
+ if (k.purpose !== "encrypt") throw new Error(`${k.name} is a ${k.purpose} key`);
320
+ const { material, currentVersion } = await kmsRecoverMaterial(ctx, orgRef.id, k.id);
321
+ const dk = await generateDataKey(material, {
322
+ keyId: k.id,
323
+ version: currentVersion
324
+ });
325
+ return {
326
+ plaintextBase64: toBase64(dk.plaintext),
327
+ wrapped: dk.wrapped
328
+ };
329
+ });
330
+ tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", {
331
+ org: z.string().optional(),
332
+ key: z.string(),
333
+ message: z.string()
334
+ }, async ({ org, key, message }) => {
335
+ const ctx = getCtx();
336
+ ensureDecryptable(ctx);
337
+ const orgRef = await resolveOrg(ctx, org);
338
+ const k = await kmsResolveKey(ctx, orgRef.id, key);
339
+ if (k.purpose !== "sign") throw new Error(`${k.name} is a ${k.purpose} key`);
340
+ const { material, currentVersion } = await kmsRecoverMaterial(ctx, orgRef.id, k.id);
341
+ return { signature: await signMessage(await importSigningKey(material), {
342
+ keyId: k.id,
343
+ version: currentVersion
344
+ }, message) };
345
+ });
346
+ tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", {
347
+ org: z.string().optional(),
348
+ key: z.string(),
349
+ signature: z.string(),
350
+ message: z.string()
351
+ }, async ({ org, key, signature, message }) => {
352
+ const ctx = getCtx();
353
+ const orgRef = await resolveOrg(ctx, org);
354
+ const k = await kmsResolveKey(ctx, orgRef.id, key);
355
+ const ref = signatureKeyRef(signature);
356
+ const { versions } = await ctx.client.getKmsPublicKeys(orgRef.id, k.id);
357
+ const pub = versions.find((v) => v.version === ref.version)?.publicKeyJwk;
358
+ if (!pub) throw new Error(`no published public key for version ${ref.version}`);
359
+ return { valid: await verifyMessage(await importVerifyingKey(pub), signature, message) };
360
+ });
209
361
  tool("list_secrets", "List secret names + versions in an environment (never values).", targetShape, async (o) => {
210
362
  const ctx = getCtx();
211
363
  const { orgId, envId } = await resolveTargetEnv(ctx, o);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -23,9 +23,9 @@
23
23
  "devDependencies": {
24
24
  "@types/node": "^26.1.0",
25
25
  "tsdown": "^0.22.3",
26
+ "@seekrit/api-client": "0.0.1",
26
27
  "@seekrit/core": "0.0.1",
27
- "@seekrit/crypto": "0.0.1",
28
- "@seekrit/api-client": "0.0.1"
28
+ "@seekrit/crypto": "0.0.1"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsdown",