@seekrit/cli 0.12.0 → 0.13.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 +274 -16
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@ import { Writable } from "node:stream";
10
10
  z.enum([
11
11
  "postgres",
12
12
  "mysql",
13
- "ssh"
13
+ "ssh",
14
+ "redis"
14
15
  ]);
15
16
  const executorModeSchema = z.enum(["in_do", "remote"]);
16
17
  /**
@@ -49,6 +50,22 @@ const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3
49
50
  * alphabet contains no single quote, so it is safe in a quoted SQL literal.
50
51
  */
51
52
  const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
53
+ /**
54
+ * A Redis ACL user name we are willing to create. Interpolated into a Redis
55
+ * command line as a bare token (`ACL SETUSER <name> …`), so it is kept strict —
56
+ * plain alphanumerics/underscore, no whitespace to split the arg or ACL rule
57
+ * characters (`~ + @ # & %`) that could be read as a permission.
58
+ */
59
+ const redisUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
60
+ /**
61
+ * A Redis password verifier — the lowercase-hex SHA-256 of the password, as
62
+ * produced by @seekrit/crypto `redisSha256Verifier`. `ACL SETUSER … on #<hex>`
63
+ * stores this digest verbatim, and it cannot authenticate: Redis `AUTH` hashes
64
+ * the *plaintext* it receives with SHA-256 and compares, so the stored digest
65
+ * is preimage-resistant (the password is high-entropy and machine-generated).
66
+ * The alphabet is bare hex, so it is a safe bare command token.
67
+ */
68
+ const redisSha256VerifierSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase-hex SHA-256 digest (64 chars)");
52
69
  const postgresAccessLevelSchema = z.enum([
53
70
  "readonly",
54
71
  "readwrite",
@@ -64,6 +81,11 @@ const mysqlAccessLevelSchema = z.enum([
64
81
  "readwrite",
65
82
  "custom"
66
83
  ]);
84
+ const redisAccessLevelSchema = z.enum([
85
+ "readonly",
86
+ "readwrite",
87
+ "custom"
88
+ ]);
67
89
  const connectionSchema = z.object({
68
90
  host: z.string().min(1),
69
91
  port: z.number().int().min(1).max(65535),
@@ -95,6 +117,21 @@ const mysqlTargetConfigSchema = z.object({
95
117
  createStatements: z.array(statementSchema).max(16).optional(),
96
118
  revokeStatements: z.array(statementSchema).max(16).optional()
97
119
  });
120
+ const redisConnectionSchema = z.object({
121
+ host: z.string().min(1),
122
+ port: z.number().int().min(1).max(65535),
123
+ /** Redis logical database index (the `/<n>` in a connection URL). */
124
+ db: z.number().int().min(0).max(15).optional()
125
+ });
126
+ const redisTargetConfigSchema = z.object({
127
+ provider: z.literal("redis"),
128
+ executor: executorModeSchema,
129
+ accessLevel: redisAccessLevelSchema.optional(),
130
+ connection: redisConnectionSchema,
131
+ provisionerUrl: z.url().optional(),
132
+ createStatements: z.array(statementSchema).max(16).optional(),
133
+ revokeStatements: z.array(statementSchema).max(16).optional()
134
+ });
98
135
  const sshTargetConfigSchema = z.object({
99
136
  provider: z.literal("ssh"),
100
137
  executor: z.literal("in_do"),
@@ -110,6 +147,7 @@ const sshTargetConfigSchema = z.object({
110
147
  const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
111
148
  postgresTargetConfigSchema,
112
149
  mysqlTargetConfigSchema,
150
+ redisTargetConfigSchema,
113
151
  sshTargetConfigSchema
114
152
  ]);
115
153
  z.object({
@@ -149,6 +187,18 @@ const mintMysqlLeaseSchema = z.object({
149
187
  ttlSeconds: ttlSecondsSchema
150
188
  });
151
189
  /**
190
+ * Client → API: mint a Redis lease. The client generates the password and its
191
+ * SHA-256 hex digest locally and sends only the digest — the plaintext password
192
+ * never leaves the requesting machine.
193
+ */
194
+ const mintRedisLeaseSchema = z.object({
195
+ provider: z.literal("redis"),
196
+ targetId: z.string().min(1),
197
+ roleName: redisUserNameSchema,
198
+ verifier: redisSha256VerifierSchema,
199
+ ttlSeconds: ttlSecondsSchema
200
+ });
201
+ /**
152
202
  * Client → API: mint an SSH lease. The client generates an ephemeral keypair
153
203
  * locally and sends only the public key; the signed certificate comes back in
154
204
  * the response. The private key never leaves the requesting machine.
@@ -163,6 +213,7 @@ const mintSshLeaseSchema = z.object({
163
213
  z.discriminatedUnion("provider", [
164
214
  mintPostgresLeaseSchema,
165
215
  mintMysqlLeaseSchema,
216
+ mintRedisLeaseSchema,
166
217
  mintSshLeaseSchema
167
218
  ]);
168
219
  //#endregion
@@ -485,8 +536,8 @@ async function importPrivateKeyPkcs8(pkcs8) {
485
536
  * browser, the CLI, the MCP server, and Workers — so this runs everywhere the
486
537
  * SCRAM helper does, with no hand-rolled hash primitive.
487
538
  */
488
- const DEFAULT_PASSWORD_LENGTH$1 = 32;
489
- const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
539
+ const DEFAULT_PASSWORD_LENGTH$2 = 32;
540
+ const PASSWORD_ALPHABET$2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
490
541
  async function sha1(data) {
491
542
  return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
492
543
  }
@@ -495,12 +546,12 @@ function toUpperHex(bytes) {
495
546
  for (const b of bytes) hex += b.toString(16).padStart(2, "0");
496
547
  return hex.toUpperCase();
497
548
  }
498
- function randomPassword$1(length) {
549
+ function randomPassword$2(length) {
499
550
  let out = "";
500
551
  while (out.length < length) {
501
552
  const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
502
553
  for (const byte of bytes) {
503
- if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
554
+ if (byte < 248) out += PASSWORD_ALPHABET$2[byte % 62];
504
555
  if (out.length === length) break;
505
556
  }
506
557
  }
@@ -519,7 +570,7 @@ async function mysqlNativePasswordVerifier(password) {
519
570
  * — the client-side half of a Vault-style dynamic MySQL credential.
520
571
  */
521
572
  async function generateMysqlCredential(options = {}) {
522
- const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
573
+ const password = randomPassword$2(options.length ?? DEFAULT_PASSWORD_LENGTH$2);
523
574
  return {
524
575
  password,
525
576
  verifier: await mysqlNativePasswordVerifier(password)
@@ -585,6 +636,73 @@ async function decryptPrivateKey(passphrase, blob) {
585
636
  throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
586
637
  }
587
638
  }
639
+ //#endregion
640
+ //#region ../../packages/crypto/src/redis.ts
641
+ /**
642
+ * Client-side construction of a Redis (6+) ACL password verifier, for minting
643
+ * *temporary Redis login credentials* without the password plaintext ever
644
+ * reaching seekrit's control plane OR Redis itself.
645
+ *
646
+ * The trick mirrors the Postgres SCRAM (scram.ts) and MySQL (mysql.ts) ones:
647
+ * `ACL SETUSER <name> on #<hex>` stores the lowercase-hex SHA-256 of the
648
+ * password verbatim — Redis does NOT re-hash it. So the flow is:
649
+ *
650
+ * 1. the machine that will connect generates a random password locally,
651
+ * 2. computes this digest locally,
652
+ * 3. sends only the digest to the broker → `ACL SETUSER … on #<digest>`,
653
+ * 4. connects directly to Redis with the plaintext it never shared.
654
+ *
655
+ * Zero-knowledge at both layers: the control plane relays only the digest, and
656
+ * the digest is NOT sufficient to authenticate. Redis `AUTH <user> <password>`
657
+ * hashes the *plaintext* it receives with SHA-256 and compares it to the stored
658
+ * digest — verifying a client needs the password, not the digest, and SHA-256
659
+ * is preimage-resistant for a high-entropy machine-generated password. A dump of
660
+ * the ACL rules (`ACL GETUSER`, `CONFIG REWRITE`'d aclfile) therefore cannot log
661
+ * in.
662
+ *
663
+ * SHA-256 is available via WebCrypto (`crypto.subtle.digest`) in the browser,
664
+ * the CLI, the MCP server, and Workers — so this runs everywhere the SCRAM
665
+ * helper does, with no hand-rolled hash primitive.
666
+ */
667
+ const DEFAULT_PASSWORD_LENGTH$1 = 32;
668
+ const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
669
+ async function sha256$1(data) {
670
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
671
+ }
672
+ function toLowerHex(bytes) {
673
+ let hex = "";
674
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
675
+ return hex;
676
+ }
677
+ function randomPassword$1(length) {
678
+ let out = "";
679
+ while (out.length < length) {
680
+ const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
681
+ for (const byte of bytes) {
682
+ if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
683
+ if (out.length === length) break;
684
+ }
685
+ }
686
+ return out;
687
+ }
688
+ /**
689
+ * Compute the Redis ACL password verifier `LOWER(HEX(SHA256(password)))` for a
690
+ * known password. Pass the result straight to `ACL SETUSER … on #<verifier>`.
691
+ */
692
+ async function redisSha256Verifier(password) {
693
+ return toLowerHex(await sha256$1(utf8Encode(password)));
694
+ }
695
+ /**
696
+ * Mint a fresh random password and its SHA-256 hex digest in one step — the
697
+ * client-side half of a Vault-style dynamic Redis credential.
698
+ */
699
+ async function generateRedisCredential(options = {}) {
700
+ const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
701
+ return {
702
+ password,
703
+ verifier: await redisSha256Verifier(password)
704
+ };
705
+ }
588
706
  const SALT_LENGTH = 16;
589
707
  const DEFAULT_PASSWORD_LENGTH = 32;
590
708
  const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -903,7 +1021,7 @@ async function unwrapDek(wrapped, privateKey) {
903
1021
  }
904
1022
  //#endregion
905
1023
  //#region package.json
906
- var version = "0.12.0";
1024
+ var version = "0.13.0";
907
1025
  const PROJECT_FILE = "seekrit.json";
908
1026
  function globalConfigPath() {
909
1027
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
@@ -1444,7 +1562,7 @@ async function resolveGroup(ctx, opts) {
1444
1562
  * control plane only ever stores ciphertext.
1445
1563
  */
1446
1564
  /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1447
- function parseTtlSeconds$2(input) {
1565
+ function parseTtlSeconds$3(input) {
1448
1566
  const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1449
1567
  if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1450
1568
  return Number(m[1]) * ({
@@ -1455,7 +1573,7 @@ function parseTtlSeconds$2(input) {
1455
1573
  }[m[2] || "s"] ?? 1);
1456
1574
  }
1457
1575
  /** A fresh, valid MySQL user name: `tmp_` + lowercase alphanumerics. */
1458
- function generateUserName(prefix = "tmp") {
1576
+ function generateUserName$1(prefix = "tmp") {
1459
1577
  const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
1460
1578
  let out = "";
1461
1579
  const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
@@ -1465,7 +1583,7 @@ function generateUserName(prefix = "tmp") {
1465
1583
  function registerMysqlCommands(program) {
1466
1584
  const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
1467
1585
  const target = mysql.command("target").description("manage provisioning targets");
1468
- 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$2, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$2, []).action(async (options) => {
1586
+ 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) => {
1469
1587
  const ctx = buildContext();
1470
1588
  const org = await resolveOrg(ctx, options.org);
1471
1589
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -1530,8 +1648,8 @@ function registerMysqlCommands(program) {
1530
1648
  const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
1531
1649
  if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1532
1650
  if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
1533
- const userName = options.user ?? generateUserName();
1534
- const ttlSeconds = parseTtlSeconds$2(options.ttl);
1651
+ const userName = options.user ?? generateUserName$1();
1652
+ const ttlSeconds = parseTtlSeconds$3(options.ttl);
1535
1653
  const { password, verifier } = await generateMysqlCredential();
1536
1654
  const { connection } = await ctx.client.mintLease(org.id, {
1537
1655
  provider: "mysql",
@@ -1566,7 +1684,7 @@ function registerMysqlCommands(program) {
1566
1684
  });
1567
1685
  }
1568
1686
  /** Collect a repeatable option into an array. */
1569
- function collect$2(value, acc) {
1687
+ function collect$3(value, acc) {
1570
1688
  acc.push(value);
1571
1689
  return acc;
1572
1690
  }
@@ -1582,7 +1700,7 @@ function collect$2(value, acc) {
1582
1700
  * ciphertext.
1583
1701
  */
1584
1702
  /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1585
- function parseTtlSeconds$1(input) {
1703
+ function parseTtlSeconds$2(input) {
1586
1704
  const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1587
1705
  if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1588
1706
  return Number(m[1]) * ({
@@ -1603,7 +1721,7 @@ function generateRoleName(prefix = "tmp") {
1603
1721
  function registerPgCommands(program) {
1604
1722
  const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
1605
1723
  const target = pg.command("target").description("manage provisioning targets");
1606
- 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$1, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$1, []).action(async (options) => {
1724
+ 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) => {
1607
1725
  const ctx = buildContext();
1608
1726
  const org = await resolveOrg(ctx, options.org);
1609
1727
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -1685,7 +1803,7 @@ function registerPgCommands(program) {
1685
1803
  if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1686
1804
  if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
1687
1805
  const roleName = options.role ?? generateRoleName();
1688
- const ttlSeconds = parseTtlSeconds$1(options.ttl);
1806
+ const ttlSeconds = parseTtlSeconds$2(options.ttl);
1689
1807
  const { password, verifier } = await generatePostgresCredential();
1690
1808
  const { connection } = await ctx.client.mintLease(org.id, {
1691
1809
  provider: "postgres",
@@ -1717,6 +1835,145 @@ function registerPgCommands(program) {
1717
1835
  });
1718
1836
  }
1719
1837
  /** Collect a repeatable option into an array. */
1838
+ function collect$2(value, acc) {
1839
+ acc.push(value);
1840
+ return acc;
1841
+ }
1842
+ //#endregion
1843
+ //#region src/redis.ts
1844
+ /**
1845
+ * `seekrit redis` — temporary Redis (6+) credentials (Vault-style dynamic
1846
+ * secrets).
1847
+ *
1848
+ * Zero-knowledge: minting generates the password and its SHA-256 digest on THIS
1849
+ * machine and sends only the digest; the plaintext password never reaches the
1850
+ * API or gets stored. Registering a target wraps the admin connection string to
1851
+ * the broker's public key locally, so the control plane only ever stores
1852
+ * ciphertext.
1853
+ */
1854
+ /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1855
+ function parseTtlSeconds$1(input) {
1856
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1857
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1858
+ return Number(m[1]) * ({
1859
+ s: 1,
1860
+ m: 60,
1861
+ h: 3600,
1862
+ d: 86400
1863
+ }[m[2] || "s"] ?? 1);
1864
+ }
1865
+ /** A fresh, valid Redis ACL user name: `tmp_` + lowercase alphanumerics. */
1866
+ function generateUserName(prefix = "tmp") {
1867
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
1868
+ let out = "";
1869
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
1870
+ for (const b of bytes) out += alphabet[b % 36];
1871
+ return `${prefix}_${out}`;
1872
+ }
1873
+ function registerRedisCommands(program) {
1874
+ const redis = program.command("redis").description("temporary Redis credentials (short-lived, zero-knowledge)");
1875
+ const target = redis.command("target").description("manage provisioning targets");
1876
+ target.command("add").description("register a Redis server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-cache").option("--org <slug>").requiredOption("--host <host>", "redis host").option("--port <port>", "redis port", "6379").option("--db <index>", "logical database index (the /<n> in the URL)").option("--access <level>", "readonly | readwrite | custom", "readonly").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 redis:// (or rediss://) connection string (or set SEEKRIT_REDIS_ADMIN_URL); wrapped locally").option("--create-statement <cmd>", "custom SETUSER template (repeatable)", collect$1, []).option("--revoke-statement <cmd>", "custom DELUSER template (repeatable)", collect$1, []).action(async (options) => {
1877
+ const ctx = buildContext();
1878
+ const org = await resolveOrg(ctx, options.org);
1879
+ const executor = options.executor === "remote" ? "remote" : "in_do";
1880
+ if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
1881
+ if (![
1882
+ "readonly",
1883
+ "readwrite",
1884
+ "custom"
1885
+ ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
1886
+ const accessLevel = options.access;
1887
+ const adminSecret = resolveLeaseAdminSecret({
1888
+ executor,
1889
+ hmacKey: options.hmacKey,
1890
+ adminUrl: options.adminUrl,
1891
+ adminUrlEnv: "SEEKRIT_REDIS_ADMIN_URL"
1892
+ });
1893
+ const config = {
1894
+ provider: "redis",
1895
+ executor,
1896
+ accessLevel,
1897
+ connection: {
1898
+ host: options.host,
1899
+ port: Number.parseInt(options.port, 10),
1900
+ ...options.db !== void 0 ? { db: Number.parseInt(options.db, 10) } : {}
1901
+ },
1902
+ ...accessLevel === "custom" ? {
1903
+ ...options.createStatement.length ? { createStatements: options.createStatement } : {},
1904
+ ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
1905
+ } : {},
1906
+ ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
1907
+ };
1908
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
1909
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
1910
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
1911
+ name: options.name,
1912
+ config,
1913
+ wrappedAdminSecret
1914
+ });
1915
+ console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
1916
+ });
1917
+ target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
1918
+ const ctx = buildContext();
1919
+ const org = await resolveOrg(ctx, options.org);
1920
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1921
+ for (const t of targets) {
1922
+ if (t.provider !== "redis") continue;
1923
+ const cfg = t.config;
1924
+ const db = cfg.connection.db ?? 0;
1925
+ console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${db}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
1926
+ }
1927
+ });
1928
+ target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
1929
+ const ctx = buildContext();
1930
+ const org = await resolveOrg(ctx, options.org);
1931
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
1932
+ console.error(`removed ${targetId}`);
1933
+ });
1934
+ redis.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--user <name>", "ACL user name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
1935
+ const ctx = buildContext();
1936
+ const org = await resolveOrg(ctx, options.org);
1937
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1938
+ const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
1939
+ if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1940
+ if (target.provider !== "redis") fail(`target "${targetRef}" is not a Redis target`);
1941
+ const userName = options.user ?? generateUserName();
1942
+ const ttlSeconds = parseTtlSeconds$1(options.ttl);
1943
+ const { password, verifier } = await generateRedisCredential();
1944
+ const { connection } = await ctx.client.mintLease(org.id, {
1945
+ provider: "redis",
1946
+ targetId: target.id,
1947
+ roleName: userName,
1948
+ verifier,
1949
+ ttlSeconds
1950
+ });
1951
+ const url = `redis://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
1952
+ console.error(`leased ${userName} on ${connection.host}:${connection.port} — expires ${connection.expiresAt}`);
1953
+ if (options.json) console.log(JSON.stringify({
1954
+ ...connection,
1955
+ password,
1956
+ url
1957
+ }, null, 2));
1958
+ else console.log(url);
1959
+ });
1960
+ redis.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
1961
+ const ctx = buildContext();
1962
+ const org = await resolveOrg(ctx, options.org);
1963
+ const { leases } = await ctx.client.listLeases(org.id);
1964
+ for (const l of leases) {
1965
+ if (l.provider !== "redis") continue;
1966
+ console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
1967
+ }
1968
+ });
1969
+ redis.command("revoke <leaseId>").description("revoke a lease now (deletes the ACL user immediately)").option("--org <slug>").action(async (leaseId, options) => {
1970
+ const ctx = buildContext();
1971
+ const org = await resolveOrg(ctx, options.org);
1972
+ await ctx.client.revokeLease(org.id, leaseId);
1973
+ console.error(`revoked ${leaseId}`);
1974
+ });
1975
+ }
1976
+ /** Collect a repeatable option into an array. */
1720
1977
  function collect$1(value, acc) {
1721
1978
  acc.push(value);
1722
1979
  return acc;
@@ -2336,6 +2593,7 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
2336
2593
  });
2337
2594
  registerPgCommands(program);
2338
2595
  registerMysqlCommands(program);
2596
+ registerRedisCommands(program);
2339
2597
  registerProvisionerCommands(program);
2340
2598
  registerSshCommands(program);
2341
2599
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -24,8 +24,8 @@
24
24
  "@types/node": "^26.1.0",
25
25
  "tsdown": "^0.22.3",
26
26
  "@seekrit/api-client": "0.0.1",
27
- "@seekrit/crypto": "0.0.1",
28
- "@seekrit/core": "0.0.1"
27
+ "@seekrit/core": "0.0.1",
28
+ "@seekrit/crypto": "0.0.1"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsdown",