@seekrit/cli 0.17.1 → 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 +707 -14
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,6 +7,128 @@ import { homedir, tmpdir } from "node:os";
7
7
  import { dirname, join, parse } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
9
  import { Writable } from "node:stream";
10
+ /** All catalog keys as a runtime array (for iteration / zod enums). */
11
+ const ENTITLEMENT_KEYS = Object.keys({
12
+ "feature.kms": {
13
+ kind: "feature",
14
+ label: "Managed keys (KMS)",
15
+ description: "Client-side managed keys for application-layer encryption and signing.",
16
+ default: true
17
+ },
18
+ "feature.leases": {
19
+ kind: "feature",
20
+ label: "Temporary access",
21
+ description: "Vault-style short-lived database and cloud credentials.",
22
+ default: true
23
+ },
24
+ "feature.log_sink": {
25
+ kind: "feature",
26
+ label: "Audit log export (SIEM)",
27
+ description: "Stream the audit log to an external OTLP collector.",
28
+ default: true
29
+ },
30
+ "feature.proxy": {
31
+ kind: "feature",
32
+ label: "Agent egress proxy",
33
+ description: "Substitute secrets into outbound requests for untrusted workloads.",
34
+ default: true
35
+ },
36
+ "feature.sso": {
37
+ kind: "feature",
38
+ label: "SSO / SAML",
39
+ description: "Single sign-on beyond the built-in providers.",
40
+ default: true
41
+ },
42
+ "apps.max": {
43
+ kind: "limit",
44
+ label: "Applications",
45
+ description: "Maximum applications in the organization.",
46
+ default: null
47
+ },
48
+ "envs.per_app.max": {
49
+ kind: "limit",
50
+ label: "Environments per application",
51
+ description: "Maximum environments under a single application.",
52
+ default: null
53
+ },
54
+ "secrets.per_env.max": {
55
+ kind: "limit",
56
+ label: "Secrets per environment",
57
+ description: "Maximum secrets in a single environment.",
58
+ default: null
59
+ },
60
+ "groups.max": {
61
+ kind: "limit",
62
+ label: "Groups",
63
+ description: "Maximum reusable secret groups in the organization.",
64
+ default: null
65
+ },
66
+ "tokens.max": {
67
+ kind: "limit",
68
+ label: "Service tokens",
69
+ description: "Maximum active service tokens in the organization.",
70
+ default: null
71
+ },
72
+ "kms.keys.max": {
73
+ kind: "limit",
74
+ label: "Managed keys",
75
+ description: "Maximum managed KMS keys in the organization.",
76
+ default: null
77
+ },
78
+ "lease.targets.max": {
79
+ kind: "limit",
80
+ label: "Lease targets",
81
+ description: "Maximum registered temporary-access targets.",
82
+ default: null
83
+ },
84
+ members: {
85
+ kind: "metered",
86
+ label: "Members",
87
+ description: "Users in the organization. Included in the plan, then billed per seat.",
88
+ default: null,
89
+ metric: "member_count"
90
+ },
91
+ "resolves.monthly": {
92
+ kind: "metered",
93
+ label: "Monthly resolves",
94
+ description: "Secret resolutions per month. Included in the plan, then billed per unit.",
95
+ default: null,
96
+ metric: "monthly_resolves"
97
+ }
98
+ });
99
+ const PLAN_FAMILY_IDS = Object.keys({
100
+ free: {
101
+ id: "free",
102
+ name: "Free",
103
+ description: "Get started with the essentials.",
104
+ current: 1
105
+ },
106
+ pro: {
107
+ id: "pro",
108
+ name: "Pro",
109
+ description: "For teams running secrets in production.",
110
+ current: 1
111
+ },
112
+ enterprise: {
113
+ id: "enterprise",
114
+ name: "Enterprise",
115
+ description: "Unlimited scale with advanced governance.",
116
+ current: 1
117
+ }
118
+ });
119
+ //#endregion
120
+ //#region ../../packages/core/src/billing.ts
121
+ /**
122
+ * Lifecycle states a subscription can be in. Mirrors the biller's own states
123
+ * (Stripe) but is provider-neutral so a different biller could map onto it.
124
+ */
125
+ const SUBSCRIPTION_STATUSES = [
126
+ "trialing",
127
+ "active",
128
+ "past_due",
129
+ "canceled",
130
+ "paused"
131
+ ];
10
132
  z.enum([
11
133
  "postgres",
12
134
  "mysql",
@@ -540,6 +662,9 @@ z.object({
540
662
  name: nameSchema,
541
663
  slug: slugSchema
542
664
  });
665
+ z.object({ name: nameSchema });
666
+ z.object({ name: nameSchema });
667
+ z.object({ name: nameSchema });
543
668
  z.object({
544
669
  email: emailSchema,
545
670
  role: inviteRoleSchema.default("member")
@@ -557,7 +682,13 @@ z.object({
557
682
  name: nameSchema,
558
683
  slug: slugSchema,
559
684
  /** Environment DEK wrapped to the creator's public key — created client-side. */
560
- 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()
561
692
  });
562
693
  z.object({
563
694
  /** Opaque versioned ciphertext blob from @seekrit/crypto. */
@@ -633,12 +764,81 @@ z.object({
633
764
  publicKeyJwk: z.string().min(1).nullish(),
634
765
  grants: z.array(kmsGrantInputSchema).min(1)
635
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
+ });
636
816
  z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
637
817
  z.object({
638
818
  endpoint: z.url().max(2048),
639
819
  headers: z.record(z.string().min(1).max(256), z.string().max(4096)).optional(),
640
820
  enabled: z.boolean().default(true)
641
821
  });
822
+ const planFamilySchema = z.enum(PLAN_FAMILY_IDS);
823
+ const subscriptionStatusSchema = z.enum(SUBSCRIPTION_STATUSES);
824
+ z.enum(ENTITLEMENT_KEYS);
825
+ /** An entitlement value: boolean (features), number or null/unlimited (limits, metered). */
826
+ const entitlementValueSchema = z.union([
827
+ z.boolean(),
828
+ z.number(),
829
+ z.null()
830
+ ]);
831
+ z.object({
832
+ family: planFamilySchema,
833
+ version: z.number().int().positive().optional(),
834
+ status: subscriptionStatusSchema.default("active")
835
+ });
836
+ z.object({
837
+ value: entitlementValueSchema,
838
+ note: z.string().max(500).nullish(),
839
+ expiresAt: z.iso.datetime().nullish()
840
+ });
841
+ z.object({ family: planFamilySchema });
642
842
  z.object({
643
843
  cursor: z.string().optional(),
644
844
  limit: z.coerce.number().int().min(1).max(200).default(50),
@@ -1198,6 +1398,191 @@ async function decryptPrivateKey(passphrase, blob) {
1198
1398
  }
1199
1399
  }
1200
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
1201
1586
  //#region ../../packages/crypto/src/redis.ts
1202
1587
  /**
1203
1588
  * Client-side construction of a Redis (6+) ACL password verifier, for minting
@@ -1576,7 +1961,7 @@ function isServiceToken(value) {
1576
1961
  }
1577
1962
  //#endregion
1578
1963
  //#region package.json
1579
- var version = "0.17.1";
1964
+ var version = "0.18.0";
1580
1965
  //#endregion
1581
1966
  //#region ../../packages/api-client/src/index.ts
1582
1967
  var SeekritApiError = class extends Error {
@@ -1646,6 +2031,10 @@ var SeekritClient = class {
1646
2031
  getOrg(orgId) {
1647
2032
  return this.request("GET", `/v1/orgs/${orgId}`);
1648
2033
  }
2034
+ /** Rename an organization (display name only — the slug is immutable). */
2035
+ updateOrg(orgId, input) {
2036
+ return this.request("PATCH", `/v1/orgs/${orgId}`, input);
2037
+ }
1649
2038
  listMembers(orgId) {
1650
2039
  return this.request("GET", `/v1/orgs/${orgId}/members`);
1651
2040
  }
@@ -1667,6 +2056,10 @@ var SeekritClient = class {
1667
2056
  getApp(orgId, appId) {
1668
2057
  return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}`);
1669
2058
  }
2059
+ /** Rename an application (display name only — the slug is immutable). */
2060
+ updateApp(orgId, appId, input) {
2061
+ return this.request("PATCH", `/v1/orgs/${orgId}/apps/${appId}`, input);
2062
+ }
1670
2063
  deleteApp(orgId, appId) {
1671
2064
  return this.request("DELETE", `/v1/orgs/${orgId}/apps/${appId}`);
1672
2065
  }
@@ -1691,6 +2084,10 @@ var SeekritClient = class {
1691
2084
  getGroup(orgId, groupId) {
1692
2085
  return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}`);
1693
2086
  }
2087
+ /** Rename a group (display name only — the slug is immutable). */
2088
+ updateGroup(orgId, groupId, input) {
2089
+ return this.request("PATCH", `/v1/orgs/${orgId}/groups/${groupId}`, input);
2090
+ }
1694
2091
  deleteGroup(orgId, groupId) {
1695
2092
  return this.request("DELETE", `/v1/orgs/${orgId}/groups/${groupId}`);
1696
2093
  }
@@ -1739,6 +2136,53 @@ var SeekritClient = class {
1739
2136
  revokeEnvKey(orgId, envId, grantId) {
1740
2137
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/keys/${grantId}`);
1741
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
+ }
1742
2186
  listTokens(orgId) {
1743
2187
  return this.request("GET", `/v1/orgs/${orgId}/tokens`);
1744
2188
  }
@@ -1748,6 +2192,10 @@ var SeekritClient = class {
1748
2192
  revokeToken(orgId, tokenId) {
1749
2193
  return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
1750
2194
  }
2195
+ /** Permanently delete a token. Only allowed once it has been revoked. */
2196
+ deleteToken(orgId, tokenId) {
2197
+ return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}/permanent`);
2198
+ }
1751
2199
  /** Keys the caller can see: all org keys for admins, granted keys otherwise. */
1752
2200
  listKmsKeys(orgId) {
1753
2201
  return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
@@ -1828,6 +2276,40 @@ var SeekritClient = class {
1828
2276
  testLogSink(orgId) {
1829
2277
  return this.request("POST", `/v1/orgs/${orgId}/log-sink/test`);
1830
2278
  }
2279
+ /**
2280
+ * The org's plan, effective entitlements, current metered usage ("X of Y"),
2281
+ * overrides, and which self-serve actions are available (`manage`). Readable
2282
+ * by any member; `enforced` reports whether limits are currently active
2283
+ * (false until plans are turned on, when every org has full access).
2284
+ */
2285
+ getBilling(orgId) {
2286
+ return this.request("GET", `/v1/orgs/${orgId}/billing`);
2287
+ }
2288
+ /**
2289
+ * Start self-serve checkout to upgrade the org to a plan family. Returns a
2290
+ * biller-hosted URL to redirect the browser to. Admin-only; the biller must
2291
+ * be configured (see `getBilling().manage`). The resulting subscription links
2292
+ * back to the org via the checkout webhook.
2293
+ */
2294
+ startCheckout(orgId, input) {
2295
+ return this.request("POST", `/v1/orgs/${orgId}/billing/checkout`, input);
2296
+ }
2297
+ /**
2298
+ * Open the biller's Billing Portal to manage the existing subscription
2299
+ * (update card, change plan, cancel). Returns a URL to redirect to. Admin-only
2300
+ * and only once a biller customer is linked (see `getBilling().manage.portal`).
2301
+ */
2302
+ openBillingPortal(orgId) {
2303
+ return this.request("POST", `/v1/orgs/${orgId}/billing/portal`);
2304
+ }
2305
+ /**
2306
+ * Downgrade the org to the Free (default) plan: cancels any active paid
2307
+ * subscription in the biller and reverts the org to Free immediately.
2308
+ * Admin-only. Refetch `getBilling` for the new state.
2309
+ */
2310
+ cancelSubscription(orgId) {
2311
+ return this.request("POST", `/v1/orgs/${orgId}/billing/cancel`);
2312
+ }
1831
2313
  };
1832
2314
  const PROJECT_FILE = "seekrit.json";
1833
2315
  function globalConfigPath() {
@@ -2391,7 +2873,7 @@ function registerGcpCommands(program) {
2391
2873
  //#endregion
2392
2874
  //#region src/kms.ts
2393
2875
  /** Collect a repeatable option into a list. */
2394
- function collect$5(value, acc = []) {
2876
+ function collect$6(value, acc = []) {
2395
2877
  acc.push(value);
2396
2878
  return acc;
2397
2879
  }
@@ -2459,7 +2941,7 @@ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
2459
2941
  }
2460
2942
  function registerKmsCommands(program) {
2461
2943
  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) => {
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) => {
2463
2945
  if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
2464
2946
  if (options.app && options.group) fail("pass at most one of --app or --group");
2465
2947
  const ctx = buildContext();
@@ -2693,7 +3175,7 @@ function parseTtlSeconds$4(input) {
2693
3175
  }[m[2] || "s"] ?? 1);
2694
3176
  }
2695
3177
  /** Collect a repeatable option into an array. */
2696
- function collect$4(value, previous) {
3178
+ function collect$5(value, previous) {
2697
3179
  return [...previous, value];
2698
3180
  }
2699
3181
  /** Parse `readWrite@app` → { role, db } for a custom target. */
@@ -2718,7 +3200,7 @@ function resolveAdminUri(uri) {
2718
3200
  function registerMongoCommands(program) {
2719
3201
  const mongo = program.command("mongodb").description("temporary MongoDB credentials (createUser, zero-knowledge delivery)");
2720
3202
  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) => {
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) => {
2722
3204
  const ctx = buildContext();
2723
3205
  const org = await resolveOrg(ctx, options.org);
2724
3206
  const adminUri = resolveAdminUri(options.uri);
@@ -2878,7 +3360,7 @@ function generateUserName$1(prefix = "tmp") {
2878
3360
  function registerMysqlCommands(program) {
2879
3361
  const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
2880
3362
  const target = mysql.command("target").description("manage provisioning targets");
2881
- 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) => {
2882
3364
  const ctx = buildContext();
2883
3365
  const org = await resolveOrg(ctx, options.org);
2884
3366
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -2979,7 +3461,7 @@ function registerMysqlCommands(program) {
2979
3461
  });
2980
3462
  }
2981
3463
  /** Collect a repeatable option into an array. */
2982
- function collect$3(value, acc) {
3464
+ function collect$4(value, acc) {
2983
3465
  acc.push(value);
2984
3466
  return acc;
2985
3467
  }
@@ -3016,7 +3498,7 @@ function generateRoleName(prefix = "tmp") {
3016
3498
  function registerPgCommands(program) {
3017
3499
  const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
3018
3500
  const target = pg.command("target").description("manage provisioning targets");
3019
- 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) => {
3020
3502
  const ctx = buildContext();
3021
3503
  const org = await resolveOrg(ctx, options.org);
3022
3504
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -3130,11 +3612,209 @@ function registerPgCommands(program) {
3130
3612
  });
3131
3613
  }
3132
3614
  /** Collect a repeatable option into an array. */
3133
- function collect$2(value, acc) {
3615
+ function collect$3(value, acc) {
3134
3616
  acc.push(value);
3135
3617
  return acc;
3136
3618
  }
3137
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
3138
3818
  //#region src/redis.ts
3139
3819
  /**
3140
3820
  * `seekrit redis` — temporary Redis (6+) credentials (Vault-style dynamic
@@ -3597,11 +4277,14 @@ env.command("create").description("create an application environment (generates
3597
4277
  if (!appRow) fail(`no app "${options.app}" in ${orgRef.slug}`);
3598
4278
  const { user } = await ctx.client.me();
3599
4279
  if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
3600
- 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);
3601
4283
  const created = await ctx.client.createEnv(orgRef.id, appRow.id, {
3602
4284
  name: options.name,
3603
4285
  slug: options.slug,
3604
- wrappedDek
4286
+ wrappedDek,
4287
+ recoveryWrappedDek
3605
4288
  });
3606
4289
  console.error(`created environment ${created.environment.slug} (${created.environment.id})`);
3607
4290
  });
@@ -3653,11 +4336,14 @@ group.command("env").description("manage a group’s environments (per-slug valu
3653
4336
  });
3654
4337
  const { user } = await ctx.client.me();
3655
4338
  if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
3656
- 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);
3657
4342
  const created = await ctx.client.createGroupEnv(groupRef.orgId, groupRef.id, {
3658
4343
  name: options.name,
3659
4344
  slug: options.slug,
3660
- wrappedDek
4345
+ wrappedDek,
4346
+ recoveryWrappedDek
3661
4347
  });
3662
4348
  console.error(`created ${groupRef.slug}@${created.environment.slug} (${created.environment.id})`);
3663
4349
  });
@@ -3886,6 +4572,12 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
3886
4572
  await ctx.client.revokeToken(orgRef.id, tokenId);
3887
4573
  console.error(`${tokenId} revoked`);
3888
4574
  });
4575
+ token.command("delete <tokenId>").description("permanently delete a revoked service token (revoke it first)").option("--org <slug>").action(async (tokenId, options) => {
4576
+ const ctx = buildContext();
4577
+ const orgRef = await resolveOrg(ctx, options.org);
4578
+ await ctx.client.deleteToken(orgRef.id, tokenId);
4579
+ console.error(`${tokenId} deleted`);
4580
+ });
3889
4581
  registerPgCommands(program);
3890
4582
  registerMysqlCommands(program);
3891
4583
  registerRedisCommands(program);
@@ -3895,6 +4587,7 @@ registerAwsCommands(program);
3895
4587
  registerGcpCommands(program);
3896
4588
  registerMongoCommands(program);
3897
4589
  registerKmsCommands(program);
4590
+ registerRecoveryCommands(program);
3898
4591
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
3899
4592
  const { runMcpServer } = await import("./mcp-CVhEQDfd.js");
3900
4593
  await runMcpServer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.17.1",
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": {