@seekrit/cli 0.17.2 → 0.18.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.
Files changed (2) hide show
  1. package/dist/index.js +506 -14
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -682,7 +682,13 @@ z.object({
682
682
  name: nameSchema,
683
683
  slug: slugSchema,
684
684
  /** Environment DEK wrapped to the creator's public key — created client-side. */
685
- wrappedDek: z.string().min(1)
685
+ wrappedDek: z.string().min(1),
686
+ /**
687
+ * When the org has recovery enabled, the same DEK additionally wrapped to the
688
+ * org recovery public key, so the environment is recovery-protected from
689
+ * creation. Omitted when recovery is off (backfilled later by `recovery sync`).
690
+ */
691
+ recoveryWrappedDek: z.string().min(1).nullish()
686
692
  });
687
693
  z.object({
688
694
  /** Opaque versioned ciphertext blob from @seekrit/crypto. */
@@ -758,6 +764,55 @@ z.object({
758
764
  publicKeyJwk: z.string().min(1).nullish(),
759
765
  grants: z.array(kmsGrantInputSchema).min(1)
760
766
  });
767
+ /**
768
+ * One custodian's wrapped Shamir share of the org recovery private key. The
769
+ * client generates the recovery keypair, splits the private half M-of-N, and
770
+ * wraps each share to a custodian's public key — the server stores only the
771
+ * opaque `wrappedShare` and can reconstruct nothing.
772
+ */
773
+ const recoveryShareInputSchema = z.object({
774
+ principalType: principalTypeSchema,
775
+ principalId: z.string().min(1),
776
+ /** Shamir x-coordinate carried by the share (1..255). */
777
+ shareIndex: z.number().int().min(1).max(255),
778
+ /** The recovery-key share wrapped to the custodian's public key (`wd1.`). */
779
+ wrappedShare: z.string().min(1)
780
+ });
781
+ /** An environment DEK additionally wrapped to the org recovery public key. */
782
+ const recoveryEnvGrantSchema = z.object({
783
+ environmentId: z.string().min(1),
784
+ /** The environment's DEK wrapped to the recovery public key (`wd1.`). */
785
+ wrappedDek: z.string().min(1)
786
+ });
787
+ z.object({
788
+ recoveryPublicKeyJwk: z.string().min(1),
789
+ threshold: z.number().int().min(1).max(255),
790
+ shares: z.array(recoveryShareInputSchema).min(1).max(255),
791
+ grants: z.array(recoveryEnvGrantSchema).default([])
792
+ }).refine((v) => v.threshold <= v.shares.length, {
793
+ message: "threshold cannot exceed the number of custodians",
794
+ path: ["threshold"]
795
+ }).refine((v) => new Set(v.shares.map((s) => `${s.principalType}:${s.principalId}`)).size === v.shares.length, {
796
+ message: "custodians must be distinct",
797
+ path: ["shares"]
798
+ });
799
+ z.object({ grants: z.array(recoveryEnvGrantSchema).min(1).max(500) });
800
+ z.object({
801
+ targetPublicKeyJwk: z.string().min(1),
802
+ targetType: principalTypeSchema.nullish(),
803
+ targetId: z.string().min(1).nullish(),
804
+ reason: z.string().max(500).nullish()
805
+ });
806
+ z.object({
807
+ shareIndex: z.number().int().min(1).max(255),
808
+ /** The custodian's share re-wrapped to the target public key (`wd1.`). */
809
+ contributedShare: z.string().min(1)
810
+ });
811
+ z.object({
812
+ principalType: principalTypeSchema,
813
+ principalId: z.string().min(1),
814
+ grants: z.array(recoveryEnvGrantSchema).min(1).max(500)
815
+ });
761
816
  z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
762
817
  z.object({
763
818
  endpoint: z.url().max(2048),
@@ -1343,6 +1398,191 @@ async function decryptPrivateKey(passphrase, blob) {
1343
1398
  }
1344
1399
  }
1345
1400
  //#endregion
1401
+ //#region ../../packages/crypto/src/shamir.ts
1402
+ /**
1403
+ * Shamir's Secret Sharing over GF(2^8) — the same field AES uses, with the
1404
+ * reduction polynomial x^8 + x^4 + x^3 + x + 1 (0x11b). Splits a byte string
1405
+ * into `shares` shares such that any `threshold` of them reconstruct the secret
1406
+ * exactly and any fewer reveal nothing about it.
1407
+ *
1408
+ * This is the one hand-rolled primitive behind org recovery (P0-1): the org
1409
+ * recovery private key is split into shares, each wrapped to a designated
1410
+ * custodian's public key, so a quorum — never seekrit, never any single
1411
+ * custodian below the threshold — can reconstruct it. See recovery.ts for the
1412
+ * composition with key wrapping.
1413
+ *
1414
+ * Each secret byte gets its own degree-(threshold-1) polynomial whose constant
1415
+ * term is that byte; a share is that polynomial family evaluated at one distinct
1416
+ * nonzero x-coordinate. Reconstruction is Lagrange interpolation back to x = 0.
1417
+ *
1418
+ * Share wire format: a Uint8Array whose first byte is the share's distinct
1419
+ * nonzero x-coordinate and whose remaining bytes are the evaluations p_j(x) for
1420
+ * each secret byte j. Self-describing, so combineSecret() needs no external
1421
+ * index — the x-coordinate survives being wrapped/unwrapped/re-wrapped intact.
1422
+ */
1423
+ /** Russian-peasant multiply in GF(2^8) (mod 0x11b) — used only to seed tables. */
1424
+ function peasantMul(a, b) {
1425
+ let product = 0;
1426
+ let x = a;
1427
+ let y = b;
1428
+ for (let i = 0; i < 8; i++) {
1429
+ if (y & 1) product ^= x;
1430
+ const carry = x & 128;
1431
+ x = x << 1 & 255;
1432
+ if (carry) x ^= 27;
1433
+ y >>= 1;
1434
+ }
1435
+ return product;
1436
+ }
1437
+ const EXP = /* @__PURE__ */ new Uint8Array(512);
1438
+ const LOG = /* @__PURE__ */ new Uint8Array(256);
1439
+ {
1440
+ let a = 1;
1441
+ for (let i = 0; i < 255; i++) {
1442
+ EXP[i] = a;
1443
+ LOG[a] = i;
1444
+ a = peasantMul(a, 3);
1445
+ }
1446
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
1447
+ }
1448
+ /** Multiply in GF(2^8) via the log/exp tables. */
1449
+ function mul(a, b) {
1450
+ if (a === 0 || b === 0) return 0;
1451
+ return EXP[LOG[a] + LOG[b]];
1452
+ }
1453
+ /** Divide in GF(2^8) (a / b). Caller guarantees b !== 0. */
1454
+ function div(a, b) {
1455
+ if (a === 0) return 0;
1456
+ return EXP[LOG[a] + 255 - LOG[b]];
1457
+ }
1458
+ /** Evaluate a polynomial (coeffs low-degree-first) at x, via Horner's rule. */
1459
+ function evalPoly(coeffs, x) {
1460
+ let result = 0;
1461
+ for (let i = coeffs.length - 1; i >= 0; i--) result = mul(result, x) ^ coeffs[i];
1462
+ return result;
1463
+ }
1464
+ /**
1465
+ * Split `secret` into `shares` shares, any `threshold` of which reconstruct it.
1466
+ * x-coordinates are 1..shares, so shares must be in 1..255 and threshold in
1467
+ * 1..shares. threshold = shares means every share is needed; threshold = 1 is
1468
+ * the degenerate case where each share equals the secret (the "single recovery
1469
+ * admin" configuration).
1470
+ */
1471
+ function splitSecret(secret, threshold, shares) {
1472
+ if (secret.length < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "cannot split an empty secret");
1473
+ if (!Number.isInteger(threshold) || !Number.isInteger(shares) || threshold < 1 || shares < threshold || shares > 255) throw new SeekritCryptoError("MALFORMED_BLOB", `invalid Shamir parameters: need 1 <= threshold (${threshold}) <= shares (${shares}) <= 255`);
1474
+ const out = [];
1475
+ for (let s = 0; s < shares; s++) {
1476
+ const share = new Uint8Array(1 + secret.length);
1477
+ share[0] = s + 1;
1478
+ out.push(share);
1479
+ }
1480
+ const randomCoeffs = threshold > 1 ? crypto.getRandomValues(new Uint8Array(secret.length * (threshold - 1))) : /* @__PURE__ */ new Uint8Array(0);
1481
+ const coeffs = new Uint8Array(threshold);
1482
+ for (let j = 0; j < secret.length; j++) {
1483
+ coeffs[0] = secret[j];
1484
+ for (let k = 1; k < threshold; k++) coeffs[k] = randomCoeffs[j * (threshold - 1) + (k - 1)];
1485
+ for (const share of out) share[1 + j] = evalPoly(coeffs, share[0]);
1486
+ }
1487
+ return out;
1488
+ }
1489
+ /**
1490
+ * Reconstruct a secret from shares in the {@link splitSecret} wire format.
1491
+ * Supplying at least `threshold` valid shares returns the original secret;
1492
+ * fewer returns a plausible-but-wrong value (that is the security property —
1493
+ * downstream key use is what actually verifies the result). Shares must be the
1494
+ * same length and carry distinct nonzero x-coordinates.
1495
+ */
1496
+ function combineSecret(shares) {
1497
+ if (shares.length < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "need at least one share to combine");
1498
+ const width = shares[0].length;
1499
+ if (width < 2) throw new SeekritCryptoError("MALFORMED_BLOB", "share is too short to carry a secret");
1500
+ const xs = new Uint8Array(shares.length);
1501
+ for (let i = 0; i < shares.length; i++) {
1502
+ const share = shares[i];
1503
+ if (share.length !== width) throw new SeekritCryptoError("MALFORMED_BLOB", "shares have mismatched lengths");
1504
+ const x = share[0];
1505
+ if (x === 0) throw new SeekritCryptoError("MALFORMED_BLOB", "share has an invalid zero x-coordinate");
1506
+ for (let p = 0; p < i; p++) if (xs[p] === x) throw new SeekritCryptoError("MALFORMED_BLOB", "shares have duplicate x-coordinates");
1507
+ xs[i] = x;
1508
+ }
1509
+ const basis = new Uint8Array(shares.length);
1510
+ for (let i = 0; i < shares.length; i++) {
1511
+ const xi = xs[i];
1512
+ let num = 1;
1513
+ let den = 1;
1514
+ for (let m = 0; m < shares.length; m++) {
1515
+ if (m === i) continue;
1516
+ const xm = xs[m];
1517
+ num = mul(num, xm);
1518
+ den = mul(den, xi ^ xm);
1519
+ }
1520
+ basis[i] = div(num, den);
1521
+ }
1522
+ const secret = new Uint8Array(width - 1);
1523
+ for (let j = 0; j < secret.length; j++) {
1524
+ let acc = 0;
1525
+ for (let i = 0; i < shares.length; i++) acc ^= mul(shares[i][1 + j], basis[i]);
1526
+ secret[j] = acc;
1527
+ }
1528
+ return secret;
1529
+ }
1530
+ //#endregion
1531
+ //#region ../../packages/crypto/src/recovery.ts
1532
+ /** Generate a fresh org recovery keypair (P-256, same as any principal). */
1533
+ function generateRecoveryKey() {
1534
+ return generateKeyPair();
1535
+ }
1536
+ /**
1537
+ * Split a recovery private key into one wrapped share per custodian, such that
1538
+ * any `threshold` custodians can reconstruct it. The share count is the number
1539
+ * of custodians.
1540
+ */
1541
+ async function splitRecoveryKey(privateKeyJwk, threshold, custodians) {
1542
+ if (custodians.length < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "recovery needs at least one custodian");
1543
+ const shares = splitSecret(utf8Encode(privateKeyJwk), threshold, custodians.length);
1544
+ return Promise.all(custodians.map(async (custodian, i) => {
1545
+ const share = shares[i];
1546
+ return {
1547
+ principalType: custodian.principalType,
1548
+ principalId: custodian.principalId,
1549
+ shareIndex: share[0],
1550
+ wrappedShare: await wrapDek(share, custodian.publicKeyJwk)
1551
+ };
1552
+ }));
1553
+ }
1554
+ /**
1555
+ * Unwrap a single recovery share with the holder's private key, returning the
1556
+ * raw share bytes. Used by a custodian to unlock their own share, and by a
1557
+ * recovery target to unlock a share re-wrapped to them — both are plain
1558
+ * {@link unwrapDek}, since a share is just wrapped bytes.
1559
+ */
1560
+ function unwrapRecoveryShare(wrappedShare, privateKey) {
1561
+ return unwrapDek(wrappedShare, privateKey);
1562
+ }
1563
+ /**
1564
+ * Re-wrap an already-unwrapped share to a recovery target's public key. This is
1565
+ * the custodian's contribution during a recovery ceremony: the server collects
1566
+ * a quorum of shares all wrapped to the target, none of which it can read.
1567
+ */
1568
+ function rewrapRecoveryShare(shareBytes, targetPublicKeyJwk) {
1569
+ return wrapDek(shareBytes, targetPublicKeyJwk);
1570
+ }
1571
+ /**
1572
+ * Combine a quorum of unwrapped shares back into the recovery private key,
1573
+ * imported and ready to unwrap DEK recovery grants. Fewer than the threshold, or
1574
+ * shares from different recovery keys, yield garbage that fails to import or
1575
+ * decrypt — surfaced as DECRYPT_FAILED, never a silent wrong key.
1576
+ */
1577
+ async function combineRecoveryShares(shareBytes) {
1578
+ const privateKeyJwk = utf8Decode(combineSecret(shareBytes));
1579
+ try {
1580
+ return await importPrivateKey(privateKeyJwk);
1581
+ } catch {
1582
+ throw new SeekritCryptoError("DECRYPT_FAILED", "recovery reconstruction failed: wrong, insufficient, or mismatched shares");
1583
+ }
1584
+ }
1585
+ //#endregion
1346
1586
  //#region ../../packages/crypto/src/redis.ts
1347
1587
  /**
1348
1588
  * Client-side construction of a Redis (6+) ACL password verifier, for minting
@@ -1721,7 +1961,7 @@ function isServiceToken(value) {
1721
1961
  }
1722
1962
  //#endregion
1723
1963
  //#region package.json
1724
- var version = "0.17.2";
1964
+ var version = "0.18.0";
1725
1965
  //#endregion
1726
1966
  //#region ../../packages/api-client/src/index.ts
1727
1967
  var SeekritApiError = class extends Error {
@@ -1896,6 +2136,53 @@ var SeekritClient = class {
1896
2136
  revokeEnvKey(orgId, envId, grantId) {
1897
2137
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/keys/${grantId}`);
1898
2138
  }
2139
+ /** Org recovery status: threshold, custodians, and environment coverage. */
2140
+ getRecovery(orgId) {
2141
+ return this.request("GET", `/v1/orgs/${orgId}/recovery`);
2142
+ }
2143
+ /** Enable recovery with the recovery public key, custodian shares, and grants. */
2144
+ configureRecovery(orgId, input) {
2145
+ return this.request("POST", `/v1/orgs/${orgId}/recovery`, input);
2146
+ }
2147
+ /** Rotate the recovery key: new keypair, custodian set, and env re-wraps. */
2148
+ rotateRecovery(orgId, input) {
2149
+ return this.request("POST", `/v1/orgs/${orgId}/recovery/rotate`, input);
2150
+ }
2151
+ disableRecovery(orgId) {
2152
+ return this.request("DELETE", `/v1/orgs/${orgId}/recovery`);
2153
+ }
2154
+ /** The calling principal's own wrapped recovery share (custodian only). */
2155
+ getMyRecoveryShare(orgId) {
2156
+ return this.request("GET", `/v1/orgs/${orgId}/recovery/share`);
2157
+ }
2158
+ /** Every environment DEK wrapped to the org recovery key (admin only). */
2159
+ getRecoveryEnvKeys(orgId) {
2160
+ return this.request("GET", `/v1/orgs/${orgId}/recovery/env-keys`);
2161
+ }
2162
+ /** Backfill recovery grants for environments the caller can decrypt. */
2163
+ uploadRecoveryGrants(orgId, input) {
2164
+ return this.request("POST", `/v1/orgs/${orgId}/recovery/grants`, input);
2165
+ }
2166
+ listRecoveryRequests(orgId) {
2167
+ return this.request("GET", `/v1/orgs/${orgId}/recovery/requests`);
2168
+ }
2169
+ createRecoveryRequest(orgId, input) {
2170
+ return this.request("POST", `/v1/orgs/${orgId}/recovery/requests`, input);
2171
+ }
2172
+ getRecoveryRequest(orgId, requestId) {
2173
+ return this.request("GET", `/v1/orgs/${orgId}/recovery/requests/${requestId}`);
2174
+ }
2175
+ /** A custodian contributes their share, re-wrapped to the request target. */
2176
+ contributeRecoveryShare(orgId, requestId, input) {
2177
+ return this.request("POST", `/v1/orgs/${orgId}/recovery/requests/${requestId}/shares`, input);
2178
+ }
2179
+ /** The target finalizes recovery, re-granting itself the recovered DEKs. */
2180
+ completeRecoveryRequest(orgId, requestId, input) {
2181
+ return this.request("POST", `/v1/orgs/${orgId}/recovery/requests/${requestId}/complete`, input);
2182
+ }
2183
+ cancelRecoveryRequest(orgId, requestId) {
2184
+ return this.request("POST", `/v1/orgs/${orgId}/recovery/requests/${requestId}/cancel`);
2185
+ }
1899
2186
  listTokens(orgId) {
1900
2187
  return this.request("GET", `/v1/orgs/${orgId}/tokens`);
1901
2188
  }
@@ -2586,7 +2873,7 @@ function registerGcpCommands(program) {
2586
2873
  //#endregion
2587
2874
  //#region src/kms.ts
2588
2875
  /** Collect a repeatable option into a list. */
2589
- function collect$5(value, acc = []) {
2876
+ function collect$6(value, acc = []) {
2590
2877
  acc.push(value);
2591
2878
  return acc;
2592
2879
  }
@@ -2654,7 +2941,7 @@ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
2654
2941
  }
2655
2942
  function registerKmsCommands(program) {
2656
2943
  const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
2657
- 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) => {
2944
+ kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$6, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$6, []).action(async (options) => {
2658
2945
  if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
2659
2946
  if (options.app && options.group) fail("pass at most one of --app or --group");
2660
2947
  const ctx = buildContext();
@@ -2888,7 +3175,7 @@ function parseTtlSeconds$4(input) {
2888
3175
  }[m[2] || "s"] ?? 1);
2889
3176
  }
2890
3177
  /** Collect a repeatable option into an array. */
2891
- function collect$4(value, previous) {
3178
+ function collect$5(value, previous) {
2892
3179
  return [...previous, value];
2893
3180
  }
2894
3181
  /** Parse `readWrite@app` → { role, db } for a custom target. */
@@ -2913,7 +3200,7 @@ function resolveAdminUri(uri) {
2913
3200
  function registerMongoCommands(program) {
2914
3201
  const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
2915
3202
  const target = mongo.command("target").description("manage MongoDB targets");
2916
- 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) => {
3203
+ 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$5, []).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) => {
2917
3204
  const ctx = buildContext();
2918
3205
  const org = await resolveOrg(ctx, options.org);
2919
3206
  const adminUri = resolveAdminUri(options.uri);
@@ -3073,7 +3360,7 @@ function generateUserName$1(prefix = "tmp") {
3073
3360
  function registerMysqlCommands(program) {
3074
3361
  const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
3075
3362
  const target = mysql.command("target").description("manage provisioning targets");
3076
- target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$3, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$3, []).action(async (options) => {
3363
+ target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$4, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$4, []).action(async (options) => {
3077
3364
  const ctx = buildContext();
3078
3365
  const org = await resolveOrg(ctx, options.org);
3079
3366
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3174,7 +3461,7 @@ function registerMysqlCommands(program) {
3174
3461
  });
3175
3462
  }
3176
3463
  /** Collect a repeatable option into an array. */
3177
- function collect$3(value, acc) {
3464
+ function collect$4(value, acc) {
3178
3465
  acc.push(value);
3179
3466
  return acc;
3180
3467
  }
@@ -3211,7 +3498,7 @@ function generateRoleName(prefix = "tmp") {
3211
3498
  function registerPgCommands(program) {
3212
3499
  const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
3213
3500
  const target = pg.command("target").description("manage provisioning targets");
3214
- target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$2, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$2, []).action(async (options) => {
3501
+ target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$3, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$3, []).action(async (options) => {
3215
3502
  const ctx = buildContext();
3216
3503
  const org = await resolveOrg(ctx, options.org);
3217
3504
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3325,11 +3612,209 @@ function registerPgCommands(program) {
3325
3612
  });
3326
3613
  }
3327
3614
  /** Collect a repeatable option into an array. */
3328
- function collect$2(value, acc) {
3615
+ function collect$3(value, acc) {
3329
3616
  acc.push(value);
3330
3617
  return acc;
3331
3618
  }
3332
3619
  //#endregion
3620
+ //#region src/recovery.ts
3621
+ /** Collect a repeatable option into a list. */
3622
+ function collect$2(value, acc = []) {
3623
+ acc.push(value);
3624
+ return acc;
3625
+ }
3626
+ /** Resolve a custodian reference: a `skt_…` token id, otherwise a member email. */
3627
+ function resolveCustodian(ctx, orgId, ref) {
3628
+ return ref.startsWith("skt_") ? kmsResolveRecipient(ctx, orgId, { token: ref }) : kmsResolveRecipient(ctx, orgId, { user: ref });
3629
+ }
3630
+ /**
3631
+ * The env DEK additionally wrapped to the org recovery key, when recovery is
3632
+ * enabled — so a newly created environment is recovery-protected from birth.
3633
+ * Returns undefined when recovery is off (the env is backfilled by `recovery
3634
+ * sync` later).
3635
+ */
3636
+ async function recoveryWrapForNewEnv(ctx, orgId, dek) {
3637
+ let recoveryPublicKeyJwk;
3638
+ try {
3639
+ const { recovery } = await ctx.client.getRecovery(orgId);
3640
+ recoveryPublicKeyJwk = recovery.enabled ? recovery.recoveryPublicKeyJwk : null;
3641
+ } catch (e) {
3642
+ if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) return void 0;
3643
+ throw e;
3644
+ }
3645
+ if (!recoveryPublicKeyJwk) return void 0;
3646
+ return wrapDek(dek, recoveryPublicKeyJwk);
3647
+ }
3648
+ /**
3649
+ * Wrap every environment the caller can decrypt but that lacks a recovery grant,
3650
+ * and upload the grants. Idempotent — safe to re-run and to run from several
3651
+ * admins to complete coverage.
3652
+ */
3653
+ async function syncRecoveryGrants(ctx, orgId) {
3654
+ const { recovery } = await ctx.client.getRecovery(orgId);
3655
+ if (!recovery.enabled || !recovery.recoveryPublicKeyJwk) fail("recovery is not enabled");
3656
+ const recoveryPublicKeyJwk = recovery.recoveryPublicKeyJwk;
3657
+ const privateKey = await getPrivateKey(ctx);
3658
+ const grants = [];
3659
+ let skipped = 0;
3660
+ for (const environmentId of recovery.coverage.unprotectedEnvIds) {
3661
+ let wrappedDek;
3662
+ try {
3663
+ ({wrappedDek} = await ctx.client.getMyEnvKey(orgId, environmentId));
3664
+ } catch (e) {
3665
+ if (e instanceof SeekritApiError && (e.status === 403 || e.status === 404)) {
3666
+ skipped++;
3667
+ continue;
3668
+ }
3669
+ throw e;
3670
+ }
3671
+ const dek = await unwrapDek(wrappedDek, privateKey);
3672
+ grants.push({
3673
+ environmentId,
3674
+ wrappedDek: await wrapDek(dek, recoveryPublicKeyJwk)
3675
+ });
3676
+ }
3677
+ if (grants.length > 0) await ctx.client.uploadRecoveryGrants(orgId, { grants });
3678
+ return {
3679
+ wrapped: grants.length,
3680
+ skipped
3681
+ };
3682
+ }
3683
+ /** Generate + split a fresh recovery key across the given custodians. */
3684
+ async function buildRecoveryConfig(ctx, orgId, thresholdRaw, custodianRefs) {
3685
+ const threshold = Number.parseInt(thresholdRaw, 10);
3686
+ if (!Number.isInteger(threshold) || threshold < 1) fail("--threshold must be a positive integer");
3687
+ if (custodianRefs.length === 0) fail("pass at least one --custodian <email|skt_id>");
3688
+ if (threshold > custodianRefs.length) fail("--threshold cannot exceed the number of custodians");
3689
+ const custodians = await Promise.all(custodianRefs.map((ref) => resolveCustodian(ctx, orgId, ref)));
3690
+ const recovery = await generateRecoveryKey();
3691
+ const shares = await splitRecoveryKey(recovery.privateKeyJwk, threshold, custodians);
3692
+ return {
3693
+ recoveryPublicKeyJwk: recovery.publicKeyJwk,
3694
+ threshold,
3695
+ shares: shares.map((s) => ({
3696
+ principalType: s.principalType,
3697
+ principalId: s.principalId,
3698
+ shareIndex: s.shareIndex,
3699
+ wrappedShare: s.wrappedShare
3700
+ }))
3701
+ };
3702
+ }
3703
+ function registerRecoveryCommands(program) {
3704
+ const recovery = program.command("recovery").description("customer-controlled M-of-N recovery (zero-knowledge)");
3705
+ recovery.command("status").description("show recovery configuration and environment coverage").option("--org <slug>").action(async (options) => {
3706
+ const ctx = buildContext();
3707
+ const org = await resolveOrg(ctx, options.org);
3708
+ const { recovery: status } = await ctx.client.getRecovery(org.id);
3709
+ if (!status.enabled) {
3710
+ console.log("recovery: disabled");
3711
+ return;
3712
+ }
3713
+ console.log(`recovery: enabled (${status.threshold}-of-${status.shareCount})`);
3714
+ console.log(`coverage: ${status.coverage.protected}/${status.coverage.total} environments protected`);
3715
+ console.log("custodians:");
3716
+ for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
3717
+ if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
3718
+ });
3719
+ recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$2, []).option("--org <slug>").action(async (options) => {
3720
+ const ctx = buildContext();
3721
+ const org = await resolveOrg(ctx, options.org);
3722
+ const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
3723
+ await ctx.client.configureRecovery(org.id, {
3724
+ ...config,
3725
+ grants: []
3726
+ });
3727
+ console.error(`recovery enabled: ${config.threshold}-of-${config.shares.length}`);
3728
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
3729
+ console.error(`recovery-protected ${wrapped} environment(s) you can decrypt`);
3730
+ if (skipped > 0) console.error(`${skipped} environment(s) need another admin to run \`seekrit recovery sync\``);
3731
+ });
3732
+ recovery.command("sync").description("recovery-protect environments you can decrypt but that aren't yet covered").option("--org <slug>").action(async (options) => {
3733
+ const ctx = buildContext();
3734
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
3735
+ console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
3736
+ });
3737
+ recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$2, []).option("--org <slug>").action(async (options) => {
3738
+ const ctx = buildContext();
3739
+ const org = await resolveOrg(ctx, options.org);
3740
+ const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
3741
+ await ctx.client.rotateRecovery(org.id, {
3742
+ ...config,
3743
+ grants: []
3744
+ });
3745
+ console.error(`recovery rotated: ${config.threshold}-of-${config.shares.length}`);
3746
+ const { wrapped, skipped } = await syncRecoveryGrants(ctx, org.id);
3747
+ console.error(`re-wrapped ${wrapped} environment(s) you can decrypt to the new recovery key`);
3748
+ if (skipped > 0) console.error(`${skipped} environment(s) still need another admin to run \`seekrit recovery sync\``);
3749
+ });
3750
+ recovery.command("disable").description("disable recovery and remove all recovery grants").option("--org <slug>").action(async (options) => {
3751
+ const ctx = buildContext();
3752
+ const org = await resolveOrg(ctx, options.org);
3753
+ await ctx.client.disableRecovery(org.id);
3754
+ console.error("recovery disabled; recovery grants removed");
3755
+ });
3756
+ recovery.command("request").description("start a recovery ceremony (defaults to recovering access for yourself)").option("--target-user <email>", "recover access for another member").option("--target-token <id>", "recover access for a service token").option("--reason <text>", "note recorded in the audit trail").option("--org <slug>").action(async (options) => {
3757
+ const ctx = buildContext();
3758
+ const org = await resolveOrg(ctx, options.org);
3759
+ const target = options.targetUser || options.targetToken ? await kmsResolveRecipient(ctx, org.id, {
3760
+ user: options.targetUser,
3761
+ token: options.targetToken
3762
+ }) : await kmsCallerIdentity(ctx);
3763
+ const { request } = await ctx.client.createRecoveryRequest(org.id, {
3764
+ targetPublicKeyJwk: target.publicKeyJwk,
3765
+ targetType: target.principalType,
3766
+ targetId: target.principalId,
3767
+ reason: options.reason
3768
+ });
3769
+ console.error(`recovery request ${request.id} created (needs ${request.threshold} custodians)`);
3770
+ console.error(` custodians run: seekrit recovery approve ${request.id}`);
3771
+ console.error(` then the target: seekrit recovery complete ${request.id}`);
3772
+ });
3773
+ recovery.command("approve").description("as a custodian, contribute your share to a recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3774
+ const ctx = buildContext();
3775
+ const org = await resolveOrg(ctx, options.org);
3776
+ const { request } = await ctx.client.getRecoveryRequest(org.id, requestId);
3777
+ const myShare = await ctx.client.getMyRecoveryShare(org.id);
3778
+ const privateKey = await getPrivateKey(ctx);
3779
+ const contributedShare = await rewrapRecoveryShare(await unwrapRecoveryShare(myShare.wrappedShare, privateKey), request.targetPublicKeyJwk);
3780
+ const res = await ctx.client.contributeRecoveryShare(org.id, requestId, {
3781
+ shareIndex: myShare.shareIndex,
3782
+ contributedShare
3783
+ });
3784
+ console.error(`contributed share #${myShare.shareIndex}: ${res.contributed}/${res.threshold} collected${res.quorumReached ? " — quorum reached" : ""}`);
3785
+ });
3786
+ recovery.command("complete").description("as the recovery target, reconstruct the key and restore your access").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3787
+ const ctx = buildContext();
3788
+ const org = await resolveOrg(ctx, options.org);
3789
+ const { request, contributions, quorumReached } = await ctx.client.getRecoveryRequest(org.id, requestId);
3790
+ if (!quorumReached) fail(`only ${contributions.length}/${request.threshold} custodians have contributed`);
3791
+ const me = await kmsCallerIdentity(ctx);
3792
+ const targetPrivateKey = await getPrivateKey(ctx);
3793
+ const recoveryPrivateKey = await combineRecoveryShares(await Promise.all(contributions.map((cont) => unwrapRecoveryShare(cont.contributedShare, targetPrivateKey))));
3794
+ const { grants: recoveryEnvKeys } = await ctx.client.getRecoveryEnvKeys(org.id);
3795
+ const restored = [];
3796
+ for (const g of recoveryEnvKeys) {
3797
+ const dek = await unwrapDek(g.wrappedDek, recoveryPrivateKey);
3798
+ restored.push({
3799
+ environmentId: g.environmentId,
3800
+ wrappedDek: await wrapDek(dek, me.publicKeyJwk)
3801
+ });
3802
+ }
3803
+ await ctx.client.completeRecoveryRequest(org.id, requestId, {
3804
+ principalType: me.principalType,
3805
+ principalId: me.principalId,
3806
+ grants: restored
3807
+ });
3808
+ console.error(`recovery complete: restored access to ${restored.length} environment(s)`);
3809
+ });
3810
+ recovery.command("cancel").description("cancel an open recovery request").argument("<requestId>").option("--org <slug>").action(async (requestId, options) => {
3811
+ const ctx = buildContext();
3812
+ const org = await resolveOrg(ctx, options.org);
3813
+ await ctx.client.cancelRecoveryRequest(org.id, requestId);
3814
+ console.error(`recovery request ${requestId} canceled`);
3815
+ });
3816
+ }
3817
+ //#endregion
3333
3818
  //#region src/redis.ts
3334
3819
  /**
3335
3820
  * `seekrit redis` — temporary Redis (6+) credentials (Vault-style dynamic
@@ -3792,11 +4277,14 @@ env.command("create").description("create an application environment (generates
3792
4277
  if (!appRow) fail(`no app "${options.app}" in ${orgRef.slug}`);
3793
4278
  const { user } = await ctx.client.me();
3794
4279
  if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
3795
- const wrappedDek = await wrapDek(generateDek(), user.publicKeyJwk);
4280
+ const dek = generateDek();
4281
+ const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
4282
+ const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, orgRef.id, dek);
3796
4283
  const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
3797
4284
  name: options.name,
3798
4285
  slug: options.slug,
3799
- wrappedDek
4286
+ wrappedDek,
4287
+ recoveryWrappedDek
3800
4288
  });
3801
4289
  console.error(`created environment ${created.environment.slug} (${created.environment.id})`);
3802
4290
  });
@@ -3848,11 +4336,14 @@ group.command("env").description("manage a group’s environments (per-slug valu
3848
4336
  });
3849
4337
  const { user } = await ctx.client.me();
3850
4338
  if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
3851
- const wrappedDek = await wrapDek(generateDek(), user.publicKeyJwk);
4339
+ const dek = generateDek();
4340
+ const wrappedDek = await wrapDek(dek, user.publicKeyJwk);
4341
+ const recoveryWrappedDek = await recoveryWrapForNewEnv(ctx, groupRef.orgId, dek);
3852
4342
  const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
3853
4343
  name: options.name,
3854
4344
  slug: options.slug,
3855
- wrappedDek
4345
+ wrappedDek,
4346
+ recoveryWrappedDek
3856
4347
  });
3857
4348
  console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
3858
4349
  });
@@ -4096,6 +4587,7 @@ registerAwsCommands(program);
4096
4587
  registerGcpCommands(program);
4097
4588
  registerMongoCommands(program);
4098
4589
  registerKmsCommands(program);
4590
+ registerRecoveryCommands(program);
4099
4591
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
4100
4592
  const { runMcpServer } = await import("./mcp-CVhEQDfd.js");
4101
4593
  await runMcpServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {