@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.
- package/dist/index.js +274 -16
- 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$
|
|
489
|
-
const PASSWORD_ALPHABET$
|
|
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$
|
|
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$
|
|
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$
|
|
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.
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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.
|
|
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/
|
|
28
|
-
"@seekrit/
|
|
27
|
+
"@seekrit/core": "0.0.1",
|
|
28
|
+
"@seekrit/crypto": "0.0.1"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsdown",
|