@seekrit/cli 0.11.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 +665 -359
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,12 +1,380 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { Command } from "commander";
4
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { z } from "zod";
5
+ import { Command } from "commander";
5
6
  import { homedir, tmpdir } from "node:os";
6
7
  import { dirname, join, parse } from "node:path";
7
8
  import { createInterface } from "node:readline";
8
9
  import { Writable } from "node:stream";
9
- import { z } from "zod";
10
+ z.enum([
11
+ "postgres",
12
+ "mysql",
13
+ "ssh",
14
+ "redis"
15
+ ]);
16
+ const executorModeSchema = z.enum(["in_do", "remote"]);
17
+ /**
18
+ * A Postgres role name we are willing to create. Deliberately strict — this
19
+ * value is interpolated into a SQL template, so it must be a bare identifier
20
+ * with no way to break out of quoting (no quotes, whitespace, or semicolons).
21
+ */
22
+ const postgresRoleNameSchema = z.string().regex(/^[a-z_][a-z0-9_]{2,62}$/, "must be 3–63 chars, lowercase letters/digits/underscore, starting with a letter or underscore");
23
+ /**
24
+ * A SCRAM-SHA-256 verifier string as produced by @seekrit/crypto. Validated so
25
+ * it, too, is safe to interpolate into a quoted SQL literal (the alphabet is
26
+ * base64 + the fixed structural characters, none of which is a single quote).
27
+ */
28
+ const scramVerifierSchema = z.string().regex(/^SCRAM-SHA-256\$\d{3,}:[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/, "must be a SCRAM-SHA-256 verifier");
29
+ /**
30
+ * An SSH login principal (a Unix-style username the certificate authorizes).
31
+ * Bounded and restricted to a safe charset — principals are SSH-wire-encoded,
32
+ * not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
33
+ */
34
+ const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
35
+ /** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
36
+ const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
37
+ /** An SSH certificate extension name, e.g. `permit-pty`. */
38
+ const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
39
+ /**
40
+ * A MySQL/MariaDB user name we are willing to create. Interpolated into a
41
+ * quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
42
+ * alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
43
+ */
44
+ const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
45
+ /**
46
+ * A `mysql_native_password` authentication string — `*` followed by 40 upper
47
+ * hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
48
+ * @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
49
+ * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
50
+ * alphabet contains no single quote, so it is safe in a quoted SQL literal.
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)");
69
+ const postgresAccessLevelSchema = z.enum([
70
+ "readonly",
71
+ "readwrite",
72
+ "custom"
73
+ ]);
74
+ /** The group role each preset's leased credentials inherit. */
75
+ const POSTGRES_GROUP_ROLES = {
76
+ readonly: "seekrit_readonly",
77
+ readwrite: "seekrit_readwrite"
78
+ };
79
+ const mysqlAccessLevelSchema = z.enum([
80
+ "readonly",
81
+ "readwrite",
82
+ "custom"
83
+ ]);
84
+ const redisAccessLevelSchema = z.enum([
85
+ "readonly",
86
+ "readwrite",
87
+ "custom"
88
+ ]);
89
+ const connectionSchema = z.object({
90
+ host: z.string().min(1),
91
+ port: z.number().int().min(1).max(65535),
92
+ database: z.string().min(1)
93
+ });
94
+ /** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
95
+ const statementSchema = z.string().min(1).max(4e3);
96
+ /** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
97
+ const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
98
+ const postgresTargetConfigSchema = z.object({
99
+ provider: z.literal("postgres"),
100
+ executor: executorModeSchema,
101
+ accessLevel: postgresAccessLevelSchema.optional(),
102
+ schema: identifierSchema.optional(),
103
+ connection: connectionSchema,
104
+ provisionerUrl: z.url().optional(),
105
+ createStatements: z.array(statementSchema).max(16).optional(),
106
+ revokeStatements: z.array(statementSchema).max(16).optional()
107
+ });
108
+ /** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
109
+ const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
110
+ const mysqlTargetConfigSchema = z.object({
111
+ provider: z.literal("mysql"),
112
+ executor: executorModeSchema,
113
+ accessLevel: mysqlAccessLevelSchema.optional(),
114
+ connection: connectionSchema,
115
+ userHost: mysqlHostSchema.optional(),
116
+ provisionerUrl: z.url().optional(),
117
+ createStatements: z.array(statementSchema).max(16).optional(),
118
+ revokeStatements: z.array(statementSchema).max(16).optional()
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
+ });
135
+ const sshTargetConfigSchema = z.object({
136
+ provider: z.literal("ssh"),
137
+ executor: z.literal("in_do"),
138
+ caPublicKey: sshPublicKeySchema,
139
+ allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
140
+ extensions: z.array(sshExtensionSchema).max(16).optional(),
141
+ maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
142
+ connection: z.object({
143
+ host: z.string().min(1).optional(),
144
+ user: sshPrincipalSchema.optional()
145
+ }).optional()
146
+ });
147
+ const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
148
+ postgresTargetConfigSchema,
149
+ mysqlTargetConfigSchema,
150
+ redisTargetConfigSchema,
151
+ sshTargetConfigSchema
152
+ ]);
153
+ z.object({
154
+ name: z.string().trim().min(1).max(128),
155
+ config: leaseTargetConfigSchema,
156
+ /**
157
+ * The admin/provisioning credential (e.g. a Postgres connection string),
158
+ * encrypted client-side to the broker's public key (a `wd1.` wrap). The
159
+ * control plane stores only this ciphertext — it never sees the plaintext.
160
+ */
161
+ wrappedAdminSecret: z.string().min(1)
162
+ });
163
+ /** Requested lease lifetime, shared by all providers. */
164
+ const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
165
+ /**
166
+ * Client → API: mint a Postgres lease. The client generates the password and
167
+ * its SCRAM verifier locally and sends only the verifier — the plaintext
168
+ * password never leaves the requesting machine.
169
+ */
170
+ const mintPostgresLeaseSchema = z.object({
171
+ provider: z.literal("postgres"),
172
+ targetId: z.string().min(1),
173
+ roleName: postgresRoleNameSchema,
174
+ verifier: scramVerifierSchema,
175
+ ttlSeconds: ttlSecondsSchema
176
+ });
177
+ /**
178
+ * Client → API: mint a MySQL/MariaDB lease. The client generates the password
179
+ * and its `mysql_native_password` hash locally and sends only the hash — the
180
+ * plaintext password never leaves the requesting machine.
181
+ */
182
+ const mintMysqlLeaseSchema = z.object({
183
+ provider: z.literal("mysql"),
184
+ targetId: z.string().min(1),
185
+ roleName: mysqlUserNameSchema,
186
+ verifier: mysqlNativeVerifierSchema,
187
+ ttlSeconds: ttlSecondsSchema
188
+ });
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
+ /**
202
+ * Client → API: mint an SSH lease. The client generates an ephemeral keypair
203
+ * locally and sends only the public key; the signed certificate comes back in
204
+ * the response. The private key never leaves the requesting machine.
205
+ */
206
+ const mintSshLeaseSchema = z.object({
207
+ provider: z.literal("ssh"),
208
+ targetId: z.string().min(1),
209
+ publicKey: sshPublicKeySchema,
210
+ principals: z.array(sshPrincipalSchema).min(1).max(32),
211
+ ttlSeconds: ttlSecondsSchema
212
+ });
213
+ z.discriminatedUnion("provider", [
214
+ mintPostgresLeaseSchema,
215
+ mintMysqlLeaseSchema,
216
+ mintRedisLeaseSchema,
217
+ mintSshLeaseSchema
218
+ ]);
219
+ //#endregion
220
+ //#region ../../packages/core/src/providers/postgres.ts
221
+ /**
222
+ * The one-time setup SQL an admin runs to create the shared group role that a
223
+ * read-only / read-write target's leased credentials inherit. Idempotent (safe
224
+ * to re-run). Returns null for custom targets (the admin owns their own SQL).
225
+ *
226
+ * Identifiers are interpolated from admin-supplied config (database/schema) and
227
+ * fixed group-role constants — this SQL is displayed for the admin to run in
228
+ * their own database, not executed by seekrit.
229
+ */
230
+ function postgresGroupBootstrapSql(config) {
231
+ if (config.accessLevel !== "readonly" && config.accessLevel !== "readwrite") return null;
232
+ const group = POSTGRES_GROUP_ROLES[config.accessLevel];
233
+ const schema = config.schema ?? "public";
234
+ const db = config.connection.database;
235
+ const privileges = config.accessLevel === "readonly" ? "SELECT" : "SELECT, INSERT, UPDATE, DELETE";
236
+ const lines = [
237
+ `-- Run once as an admin on "${db}". Temporary ${config.accessLevel} credentials inherit this role.`,
238
+ "DO $$ BEGIN",
239
+ ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${group}') THEN`,
240
+ ` CREATE ROLE ${group} NOLOGIN;`,
241
+ " END IF;",
242
+ "END $$;",
243
+ `GRANT CONNECT ON DATABASE "${db}" TO ${group};`,
244
+ `GRANT USAGE ON SCHEMA "${schema}" TO ${group};`,
245
+ `GRANT ${privileges} ON ALL TABLES IN SCHEMA "${schema}" TO ${group};`,
246
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT ${privileges} ON TABLES TO ${group};`
247
+ ];
248
+ if (config.accessLevel === "readwrite") lines.push(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "${schema}" TO ${group};`, `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT USAGE, SELECT ON SEQUENCES TO ${group};`);
249
+ return lines.join("\n");
250
+ }
251
+ //#endregion
252
+ //#region ../../packages/core/src/providers/ssh.ts
253
+ /**
254
+ * The one-time host setup an admin runs so a target's certificates are accepted.
255
+ * Analogue of `postgresGroupBootstrapSql` — displayed for the admin to run, not
256
+ * executed by seekrit. Embeds the CA public key so it's copy-paste runnable.
257
+ */
258
+ function sshHostSetupInstructions(config) {
259
+ const principals = config.allowedPrincipals?.length ? config.allowedPrincipals : ["<login-user>"];
260
+ return [
261
+ "# Run once on each target host so it trusts seekrit-issued certificates.",
262
+ "# 1. Install the CA public key and trust it for user authentication:",
263
+ `echo '${config.caPublicKey}' | sudo tee /etc/ssh/seekrit_ca.pub`,
264
+ "sudo sh -c 'echo \"TrustedUserCAKeys /etc/ssh/seekrit_ca.pub\" >> /etc/ssh/sshd_config'",
265
+ "# 2. (optional) Restrict which cert principals may log in as which users via",
266
+ "# AuthorizedPrincipalsFile, e.g. /etc/ssh/auth_principals/<user> listing:",
267
+ ...principals.map((p) => `# ${p}`),
268
+ "# 3. Reload sshd:",
269
+ "sudo systemctl reload sshd"
270
+ ].join("\n");
271
+ }
272
+ //#endregion
273
+ //#region ../../packages/core/src/types.ts
274
+ /**
275
+ * Transactional notification emails seekrit can send. Each id is one
276
+ * user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
277
+ * audit-grade metadata — never secret material — and every one is opt-out
278
+ * (defaults on). Kept as a const array so the API, api-client, and dashboard
279
+ * share a single source of truth (mirrors `AUDIT_ACTIONS`).
280
+ */
281
+ const NOTIFICATION_TYPES = [
282
+ "token_created",
283
+ "token_revoked",
284
+ "env_access_granted",
285
+ "env_access_revoked",
286
+ "resolve_denied",
287
+ "org_welcome",
288
+ "token_expiring",
289
+ "lease_expired"
290
+ ];
291
+ //#endregion
292
+ //#region ../../packages/core/src/schemas.ts
293
+ /** URL-safe identifier segment: `my-app`, `production`, … */
294
+ const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
295
+ const nameSchema = z.string().trim().min(1).max(128);
296
+ /** Env-var style secret name: FOO, DATABASE_URL, apiKey2 … */
297
+ const secretNameSchema = z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
298
+ z.enum([
299
+ "owner",
300
+ "admin",
301
+ "member"
302
+ ]);
303
+ const principalTypeSchema = z.enum(["user", "service_token"]);
304
+ /** Org-level capability a service token can hold (never `owner`). */
305
+ const serviceTokenRoleSchema = z.enum(["admin", "member"]);
306
+ z.object({
307
+ name: nameSchema,
308
+ slug: slugSchema
309
+ });
310
+ z.object({
311
+ name: nameSchema,
312
+ slug: slugSchema
313
+ });
314
+ z.object({
315
+ name: nameSchema,
316
+ slug: slugSchema
317
+ });
318
+ z.object({
319
+ groupId: z.string().min(1),
320
+ /** Precedence among an env's groups (higher wins). Appended if omitted. */
321
+ position: z.number().int().min(0).optional()
322
+ });
323
+ z.object({
324
+ name: nameSchema,
325
+ slug: slugSchema,
326
+ /** Environment DEK wrapped to the creator's public key — created client-side. */
327
+ wrappedDek: z.string().min(1)
328
+ });
329
+ z.object({
330
+ /** Opaque versioned ciphertext blob from @seekrit/crypto. */
331
+ ciphertext: z.string().min(1).max(65536) });
332
+ z.object({
333
+ publicKeyJwk: z.string().min(1),
334
+ /**
335
+ * Private key encrypted with a passphrase-derived KEK; opaque to the
336
+ * server. Self-contained blob (embeds KDF salt + iterations).
337
+ */
338
+ encryptedPrivateKey: z.string().min(1)
339
+ });
340
+ z.object({
341
+ principalType: principalTypeSchema,
342
+ principalId: z.string().min(1),
343
+ wrappedDek: z.string().min(1)
344
+ });
345
+ z.object({
346
+ name: nameSchema,
347
+ tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
348
+ /** SHA-256 hash (base64url) of the full token string. */
349
+ tokenHash: z.string().min(1),
350
+ publicKeyJwk: z.string().min(1),
351
+ /**
352
+ * Org-level capability. Defaults to `member` (a runtime credential); pass
353
+ * `admin` to mint a headless provisioning token. Only an admin caller may
354
+ * create an `admin` token, so capability cannot escalate itself.
355
+ */
356
+ role: serviceTokenRoleSchema.default("member"),
357
+ /**
358
+ * The application environment this token is bound to (org + app + env).
359
+ * Optional so org-admin tokens can exist, but required for runtime tokens
360
+ * that resolve secrets via `GET /v1/resolve`.
361
+ */
362
+ environmentId: z.string().min(1).nullish(),
363
+ expiresAt: z.iso.datetime().nullish()
364
+ });
365
+ z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
366
+ z.object({
367
+ endpoint: z.url().max(2048),
368
+ headers: z.record(z.string().min(1).max(256), z.string().max(4096)).optional(),
369
+ enabled: z.boolean().default(true)
370
+ });
371
+ z.object({
372
+ cursor: z.string().optional(),
373
+ limit: z.coerce.number().int().min(1).max(200).default(50),
374
+ action: z.string().optional(),
375
+ resourceType: z.string().optional()
376
+ });
377
+ //#endregion
10
378
  //#region ../../packages/crypto/src/encoding.ts
11
379
  const CHUNK = 32768;
12
380
  /** Base64url (no padding) — portable across browsers, Workers, and Node. */
@@ -168,8 +536,8 @@ async function importPrivateKeyPkcs8(pkcs8) {
168
536
  * browser, the CLI, the MCP server, and Workers — so this runs everywhere the
169
537
  * SCRAM helper does, with no hand-rolled hash primitive.
170
538
  */
171
- const DEFAULT_PASSWORD_LENGTH$1 = 32;
172
- const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
539
+ const DEFAULT_PASSWORD_LENGTH$2 = 32;
540
+ const PASSWORD_ALPHABET$2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
173
541
  async function sha1(data) {
174
542
  return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
175
543
  }
@@ -178,12 +546,12 @@ function toUpperHex(bytes) {
178
546
  for (const b of bytes) hex += b.toString(16).padStart(2, "0");
179
547
  return hex.toUpperCase();
180
548
  }
181
- function randomPassword$1(length) {
549
+ function randomPassword$2(length) {
182
550
  let out = "";
183
551
  while (out.length < length) {
184
552
  const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
185
553
  for (const byte of bytes) {
186
- if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
554
+ if (byte < 248) out += PASSWORD_ALPHABET$2[byte % 62];
187
555
  if (out.length === length) break;
188
556
  }
189
557
  }
@@ -202,7 +570,7 @@ async function mysqlNativePasswordVerifier(password) {
202
570
  * — the client-side half of a Vault-style dynamic MySQL credential.
203
571
  */
204
572
  async function generateMysqlCredential(options = {}) {
205
- const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
573
+ const password = randomPassword$2(options.length ?? DEFAULT_PASSWORD_LENGTH$2);
206
574
  return {
207
575
  password,
208
576
  verifier: await mysqlNativePasswordVerifier(password)
@@ -268,6 +636,73 @@ async function decryptPrivateKey(passphrase, blob) {
268
636
  throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
269
637
  }
270
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
+ }
271
706
  const SALT_LENGTH = 16;
272
707
  const DEFAULT_PASSWORD_LENGTH = 32;
273
708
  const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -586,7 +1021,7 @@ async function unwrapDek(wrapped, privateKey) {
586
1021
  }
587
1022
  //#endregion
588
1023
  //#region package.json
589
- var version = "0.11.0";
1024
+ var version = "0.13.0";
590
1025
  const PROJECT_FILE = "seekrit.json";
591
1026
  function globalConfigPath() {
592
1027
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
@@ -933,6 +1368,37 @@ async function getDek(ctx, orgId, envId) {
933
1368
  return unwrapDek(wrappedDek, privateKey);
934
1369
  }
935
1370
  //#endregion
1371
+ //#region src/dotenv.ts
1372
+ /**
1373
+ * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
1374
+ * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
1375
+ * escapes; unquoted values drop trailing ` # comments`). Multiline values are
1376
+ * not supported — keep those in seekrit itself.
1377
+ */
1378
+ function parseDotenv(content) {
1379
+ const out = {};
1380
+ for (const raw of content.split(/\r?\n/)) {
1381
+ let line = raw.trim();
1382
+ if (!line || line.startsWith("#")) continue;
1383
+ if (line.startsWith("export ")) line = line.slice(7).trimStart();
1384
+ const eq = line.indexOf("=");
1385
+ if (eq === -1) continue;
1386
+ const key = line.slice(0, eq).trim();
1387
+ if (!key) continue;
1388
+ let value = line.slice(eq + 1).trim();
1389
+ const quote = value[0];
1390
+ if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
1391
+ value = value.slice(1, -1);
1392
+ if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
1393
+ } else {
1394
+ const comment = value.indexOf(" #");
1395
+ if (comment !== -1) value = value.slice(0, comment).trim();
1396
+ }
1397
+ out[key] = value;
1398
+ }
1399
+ return out;
1400
+ }
1401
+ //#endregion
936
1402
  //#region src/format.ts
937
1403
  function needsQuoting(value) {
938
1404
  return /[\s"'`$\\#]/.test(value) || value === "";
@@ -1096,7 +1562,7 @@ async function resolveGroup(ctx, opts) {
1096
1562
  * control plane only ever stores ciphertext.
1097
1563
  */
1098
1564
  /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1099
- function parseTtlSeconds$2(input) {
1565
+ function parseTtlSeconds$3(input) {
1100
1566
  const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1101
1567
  if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1102
1568
  return Number(m[1]) * ({
@@ -1107,7 +1573,7 @@ function parseTtlSeconds$2(input) {
1107
1573
  }[m[2] || "s"] ?? 1);
1108
1574
  }
1109
1575
  /** A fresh, valid MySQL user name: `tmp_` + lowercase alphanumerics. */
1110
- function generateUserName(prefix = "tmp") {
1576
+ function generateUserName$1(prefix = "tmp") {
1111
1577
  const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
1112
1578
  let out = "";
1113
1579
  const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
@@ -1117,7 +1583,7 @@ function generateUserName(prefix = "tmp") {
1117
1583
  function registerMysqlCommands(program) {
1118
1584
  const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
1119
1585
  const target = mysql.command("target").description("manage provisioning targets");
1120
- 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) => {
1121
1587
  const ctx = buildContext();
1122
1588
  const org = await resolveOrg(ctx, options.org);
1123
1589
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -1182,8 +1648,8 @@ function registerMysqlCommands(program) {
1182
1648
  const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
1183
1649
  if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1184
1650
  if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
1185
- const userName = options.user ?? generateUserName();
1186
- const ttlSeconds = parseTtlSeconds$2(options.ttl);
1651
+ const userName = options.user ?? generateUserName$1();
1652
+ const ttlSeconds = parseTtlSeconds$3(options.ttl);
1187
1653
  const { password, verifier } = await generateMysqlCredential();
1188
1654
  const { connection } = await ctx.client.mintLease(org.id, {
1189
1655
  provider: "mysql",
@@ -1218,325 +1684,10 @@ function registerMysqlCommands(program) {
1218
1684
  });
1219
1685
  }
1220
1686
  /** Collect a repeatable option into an array. */
1221
- function collect$2(value, acc) {
1687
+ function collect$3(value, acc) {
1222
1688
  acc.push(value);
1223
1689
  return acc;
1224
1690
  }
1225
- z.enum([
1226
- "postgres",
1227
- "mysql",
1228
- "ssh"
1229
- ]);
1230
- const executorModeSchema = z.enum(["in_do", "remote"]);
1231
- /**
1232
- * A Postgres role name we are willing to create. Deliberately strict — this
1233
- * value is interpolated into a SQL template, so it must be a bare identifier
1234
- * with no way to break out of quoting (no quotes, whitespace, or semicolons).
1235
- */
1236
- const postgresRoleNameSchema = z.string().regex(/^[a-z_][a-z0-9_]{2,62}$/, "must be 3–63 chars, lowercase letters/digits/underscore, starting with a letter or underscore");
1237
- /**
1238
- * A SCRAM-SHA-256 verifier string as produced by @seekrit/crypto. Validated so
1239
- * it, too, is safe to interpolate into a quoted SQL literal (the alphabet is
1240
- * base64 + the fixed structural characters, none of which is a single quote).
1241
- */
1242
- const scramVerifierSchema = z.string().regex(/^SCRAM-SHA-256\$\d{3,}:[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/, "must be a SCRAM-SHA-256 verifier");
1243
- /**
1244
- * An SSH login principal (a Unix-style username the certificate authorizes).
1245
- * Bounded and restricted to a safe charset — principals are SSH-wire-encoded,
1246
- * not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
1247
- */
1248
- const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
1249
- /** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
1250
- const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
1251
- /** An SSH certificate extension name, e.g. `permit-pty`. */
1252
- const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
1253
- /**
1254
- * A MySQL/MariaDB user name we are willing to create. Interpolated into a
1255
- * quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
1256
- * alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
1257
- */
1258
- const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
1259
- /**
1260
- * A `mysql_native_password` authentication string — `*` followed by 40 upper
1261
- * hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
1262
- * @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
1263
- * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
1264
- * alphabet contains no single quote, so it is safe in a quoted SQL literal.
1265
- */
1266
- const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
1267
- const postgresAccessLevelSchema = z.enum([
1268
- "readonly",
1269
- "readwrite",
1270
- "custom"
1271
- ]);
1272
- /** The group role each preset's leased credentials inherit. */
1273
- const POSTGRES_GROUP_ROLES = {
1274
- readonly: "seekrit_readonly",
1275
- readwrite: "seekrit_readwrite"
1276
- };
1277
- const mysqlAccessLevelSchema = z.enum([
1278
- "readonly",
1279
- "readwrite",
1280
- "custom"
1281
- ]);
1282
- const connectionSchema = z.object({
1283
- host: z.string().min(1),
1284
- port: z.number().int().min(1).max(65535),
1285
- database: z.string().min(1)
1286
- });
1287
- /** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
1288
- const statementSchema = z.string().min(1).max(4e3);
1289
- /** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
1290
- const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
1291
- const postgresTargetConfigSchema = z.object({
1292
- provider: z.literal("postgres"),
1293
- executor: executorModeSchema,
1294
- accessLevel: postgresAccessLevelSchema.optional(),
1295
- schema: identifierSchema.optional(),
1296
- connection: connectionSchema,
1297
- provisionerUrl: z.url().optional(),
1298
- createStatements: z.array(statementSchema).max(16).optional(),
1299
- revokeStatements: z.array(statementSchema).max(16).optional()
1300
- });
1301
- /** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
1302
- const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
1303
- const mysqlTargetConfigSchema = z.object({
1304
- provider: z.literal("mysql"),
1305
- executor: executorModeSchema,
1306
- accessLevel: mysqlAccessLevelSchema.optional(),
1307
- connection: connectionSchema,
1308
- userHost: mysqlHostSchema.optional(),
1309
- provisionerUrl: z.url().optional(),
1310
- createStatements: z.array(statementSchema).max(16).optional(),
1311
- revokeStatements: z.array(statementSchema).max(16).optional()
1312
- });
1313
- const sshTargetConfigSchema = z.object({
1314
- provider: z.literal("ssh"),
1315
- executor: z.literal("in_do"),
1316
- caPublicKey: sshPublicKeySchema,
1317
- allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
1318
- extensions: z.array(sshExtensionSchema).max(16).optional(),
1319
- maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
1320
- connection: z.object({
1321
- host: z.string().min(1).optional(),
1322
- user: sshPrincipalSchema.optional()
1323
- }).optional()
1324
- });
1325
- const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
1326
- postgresTargetConfigSchema,
1327
- mysqlTargetConfigSchema,
1328
- sshTargetConfigSchema
1329
- ]);
1330
- z.object({
1331
- name: z.string().trim().min(1).max(128),
1332
- config: leaseTargetConfigSchema,
1333
- /**
1334
- * The admin/provisioning credential (e.g. a Postgres connection string),
1335
- * encrypted client-side to the broker's public key (a `wd1.` wrap). The
1336
- * control plane stores only this ciphertext — it never sees the plaintext.
1337
- */
1338
- wrappedAdminSecret: z.string().min(1)
1339
- });
1340
- /** Requested lease lifetime, shared by all providers. */
1341
- const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
1342
- /**
1343
- * Client → API: mint a Postgres lease. The client generates the password and
1344
- * its SCRAM verifier locally and sends only the verifier — the plaintext
1345
- * password never leaves the requesting machine.
1346
- */
1347
- const mintPostgresLeaseSchema = z.object({
1348
- provider: z.literal("postgres"),
1349
- targetId: z.string().min(1),
1350
- roleName: postgresRoleNameSchema,
1351
- verifier: scramVerifierSchema,
1352
- ttlSeconds: ttlSecondsSchema
1353
- });
1354
- /**
1355
- * Client → API: mint a MySQL/MariaDB lease. The client generates the password
1356
- * and its `mysql_native_password` hash locally and sends only the hash — the
1357
- * plaintext password never leaves the requesting machine.
1358
- */
1359
- const mintMysqlLeaseSchema = z.object({
1360
- provider: z.literal("mysql"),
1361
- targetId: z.string().min(1),
1362
- roleName: mysqlUserNameSchema,
1363
- verifier: mysqlNativeVerifierSchema,
1364
- ttlSeconds: ttlSecondsSchema
1365
- });
1366
- /**
1367
- * Client → API: mint an SSH lease. The client generates an ephemeral keypair
1368
- * locally and sends only the public key; the signed certificate comes back in
1369
- * the response. The private key never leaves the requesting machine.
1370
- */
1371
- const mintSshLeaseSchema = z.object({
1372
- provider: z.literal("ssh"),
1373
- targetId: z.string().min(1),
1374
- publicKey: sshPublicKeySchema,
1375
- principals: z.array(sshPrincipalSchema).min(1).max(32),
1376
- ttlSeconds: ttlSecondsSchema
1377
- });
1378
- z.discriminatedUnion("provider", [
1379
- mintPostgresLeaseSchema,
1380
- mintMysqlLeaseSchema,
1381
- mintSshLeaseSchema
1382
- ]);
1383
- //#endregion
1384
- //#region ../../packages/core/src/providers/postgres.ts
1385
- /**
1386
- * The one-time setup SQL an admin runs to create the shared group role that a
1387
- * read-only / read-write target's leased credentials inherit. Idempotent (safe
1388
- * to re-run). Returns null for custom targets (the admin owns their own SQL).
1389
- *
1390
- * Identifiers are interpolated from admin-supplied config (database/schema) and
1391
- * fixed group-role constants — this SQL is displayed for the admin to run in
1392
- * their own database, not executed by seekrit.
1393
- */
1394
- function postgresGroupBootstrapSql(config) {
1395
- if (config.accessLevel !== "readonly" && config.accessLevel !== "readwrite") return null;
1396
- const group = POSTGRES_GROUP_ROLES[config.accessLevel];
1397
- const schema = config.schema ?? "public";
1398
- const db = config.connection.database;
1399
- const privileges = config.accessLevel === "readonly" ? "SELECT" : "SELECT, INSERT, UPDATE, DELETE";
1400
- const lines = [
1401
- `-- Run once as an admin on "${db}". Temporary ${config.accessLevel} credentials inherit this role.`,
1402
- "DO $$ BEGIN",
1403
- ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${group}') THEN`,
1404
- ` CREATE ROLE ${group} NOLOGIN;`,
1405
- " END IF;",
1406
- "END $$;",
1407
- `GRANT CONNECT ON DATABASE "${db}" TO ${group};`,
1408
- `GRANT USAGE ON SCHEMA "${schema}" TO ${group};`,
1409
- `GRANT ${privileges} ON ALL TABLES IN SCHEMA "${schema}" TO ${group};`,
1410
- `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT ${privileges} ON TABLES TO ${group};`
1411
- ];
1412
- if (config.accessLevel === "readwrite") lines.push(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "${schema}" TO ${group};`, `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT USAGE, SELECT ON SEQUENCES TO ${group};`);
1413
- return lines.join("\n");
1414
- }
1415
- //#endregion
1416
- //#region ../../packages/core/src/providers/ssh.ts
1417
- /**
1418
- * The one-time host setup an admin runs so a target's certificates are accepted.
1419
- * Analogue of `postgresGroupBootstrapSql` — displayed for the admin to run, not
1420
- * executed by seekrit. Embeds the CA public key so it's copy-paste runnable.
1421
- */
1422
- function sshHostSetupInstructions(config) {
1423
- const principals = config.allowedPrincipals?.length ? config.allowedPrincipals : ["<login-user>"];
1424
- return [
1425
- "# Run once on each target host so it trusts seekrit-issued certificates.",
1426
- "# 1. Install the CA public key and trust it for user authentication:",
1427
- `echo '${config.caPublicKey}' | sudo tee /etc/ssh/seekrit_ca.pub`,
1428
- "sudo sh -c 'echo \"TrustedUserCAKeys /etc/ssh/seekrit_ca.pub\" >> /etc/ssh/sshd_config'",
1429
- "# 2. (optional) Restrict which cert principals may log in as which users via",
1430
- "# AuthorizedPrincipalsFile, e.g. /etc/ssh/auth_principals/<user> listing:",
1431
- ...principals.map((p) => `# ${p}`),
1432
- "# 3. Reload sshd:",
1433
- "sudo systemctl reload sshd"
1434
- ].join("\n");
1435
- }
1436
- //#endregion
1437
- //#region ../../packages/core/src/types.ts
1438
- /**
1439
- * Transactional notification emails seekrit can send. Each id is one
1440
- * user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
1441
- * audit-grade metadata — never secret material — and every one is opt-out
1442
- * (defaults on). Kept as a const array so the API, api-client, and dashboard
1443
- * share a single source of truth (mirrors `AUDIT_ACTIONS`).
1444
- */
1445
- const NOTIFICATION_TYPES = [
1446
- "token_created",
1447
- "token_revoked",
1448
- "env_access_granted",
1449
- "env_access_revoked",
1450
- "resolve_denied",
1451
- "org_welcome",
1452
- "token_expiring",
1453
- "lease_expired"
1454
- ];
1455
- //#endregion
1456
- //#region ../../packages/core/src/schemas.ts
1457
- /** URL-safe identifier segment: `my-app`, `production`, … */
1458
- const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
1459
- const nameSchema = z.string().trim().min(1).max(128);
1460
- z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
1461
- z.enum([
1462
- "owner",
1463
- "admin",
1464
- "member"
1465
- ]);
1466
- const principalTypeSchema = z.enum(["user", "service_token"]);
1467
- /** Org-level capability a service token can hold (never `owner`). */
1468
- const serviceTokenRoleSchema = z.enum(["admin", "member"]);
1469
- z.object({
1470
- name: nameSchema,
1471
- slug: slugSchema
1472
- });
1473
- z.object({
1474
- name: nameSchema,
1475
- slug: slugSchema
1476
- });
1477
- z.object({
1478
- name: nameSchema,
1479
- slug: slugSchema
1480
- });
1481
- z.object({
1482
- groupId: z.string().min(1),
1483
- /** Precedence among an env's groups (higher wins). Appended if omitted. */
1484
- position: z.number().int().min(0).optional()
1485
- });
1486
- z.object({
1487
- name: nameSchema,
1488
- slug: slugSchema,
1489
- /** Environment DEK wrapped to the creator's public key — created client-side. */
1490
- wrappedDek: z.string().min(1)
1491
- });
1492
- z.object({
1493
- /** Opaque versioned ciphertext blob from @seekrit/crypto. */
1494
- ciphertext: z.string().min(1).max(65536) });
1495
- z.object({
1496
- publicKeyJwk: z.string().min(1),
1497
- /**
1498
- * Private key encrypted with a passphrase-derived KEK; opaque to the
1499
- * server. Self-contained blob (embeds KDF salt + iterations).
1500
- */
1501
- encryptedPrivateKey: z.string().min(1)
1502
- });
1503
- z.object({
1504
- principalType: principalTypeSchema,
1505
- principalId: z.string().min(1),
1506
- wrappedDek: z.string().min(1)
1507
- });
1508
- z.object({
1509
- name: nameSchema,
1510
- tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
1511
- /** SHA-256 hash (base64url) of the full token string. */
1512
- tokenHash: z.string().min(1),
1513
- publicKeyJwk: z.string().min(1),
1514
- /**
1515
- * Org-level capability. Defaults to `member` (a runtime credential); pass
1516
- * `admin` to mint a headless provisioning token. Only an admin caller may
1517
- * create an `admin` token, so capability cannot escalate itself.
1518
- */
1519
- role: serviceTokenRoleSchema.default("member"),
1520
- /**
1521
- * The application environment this token is bound to (org + app + env).
1522
- * Optional so org-admin tokens can exist, but required for runtime tokens
1523
- * that resolve secrets via `GET /v1/resolve`.
1524
- */
1525
- environmentId: z.string().min(1).nullish(),
1526
- expiresAt: z.iso.datetime().nullish()
1527
- });
1528
- z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
1529
- z.object({
1530
- endpoint: z.url().max(2048),
1531
- headers: z.record(z.string().min(1).max(256), z.string().max(4096)).optional(),
1532
- enabled: z.boolean().default(true)
1533
- });
1534
- z.object({
1535
- cursor: z.string().optional(),
1536
- limit: z.coerce.number().int().min(1).max(200).default(50),
1537
- action: z.string().optional(),
1538
- resourceType: z.string().optional()
1539
- });
1540
1691
  //#endregion
1541
1692
  //#region src/pg.ts
1542
1693
  /**
@@ -1549,7 +1700,7 @@ z.object({
1549
1700
  * ciphertext.
1550
1701
  */
1551
1702
  /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1552
- function parseTtlSeconds$1(input) {
1703
+ function parseTtlSeconds$2(input) {
1553
1704
  const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1554
1705
  if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1555
1706
  return Number(m[1]) * ({
@@ -1570,7 +1721,7 @@ function generateRoleName(prefix = "tmp") {
1570
1721
  function registerPgCommands(program) {
1571
1722
  const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
1572
1723
  const target = pg.command("target").description("manage provisioning targets");
1573
- 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) => {
1574
1725
  const ctx = buildContext();
1575
1726
  const org = await resolveOrg(ctx, options.org);
1576
1727
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -1652,7 +1803,7 @@ function registerPgCommands(program) {
1652
1803
  if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1653
1804
  if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
1654
1805
  const roleName = options.role ?? generateRoleName();
1655
- const ttlSeconds = parseTtlSeconds$1(options.ttl);
1806
+ const ttlSeconds = parseTtlSeconds$2(options.ttl);
1656
1807
  const { password, verifier } = await generatePostgresCredential();
1657
1808
  const { connection } = await ctx.client.mintLease(org.id, {
1658
1809
  provider: "postgres",
@@ -1684,40 +1835,148 @@ function registerPgCommands(program) {
1684
1835
  });
1685
1836
  }
1686
1837
  /** Collect a repeatable option into an array. */
1687
- function collect$1(value, acc) {
1838
+ function collect$2(value, acc) {
1688
1839
  acc.push(value);
1689
1840
  return acc;
1690
1841
  }
1691
1842
  //#endregion
1692
- //#region src/dotenv.ts
1843
+ //#region src/redis.ts
1693
1844
  /**
1694
- * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
1695
- * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
1696
- * escapes; unquoted values drop trailing ` # comments`). Multiline values are
1697
- * not supported keep those in seekrit itself.
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.
1698
1853
  */
1699
- function parseDotenv(content) {
1700
- const out = {};
1701
- for (const raw of content.split(/\r?\n/)) {
1702
- let line = raw.trim();
1703
- if (!line || line.startsWith("#")) continue;
1704
- if (line.startsWith("export ")) line = line.slice(7).trimStart();
1705
- const eq = line.indexOf("=");
1706
- if (eq === -1) continue;
1707
- const key = line.slice(0, eq).trim();
1708
- if (!key) continue;
1709
- let value = line.slice(eq + 1).trim();
1710
- const quote = value[0];
1711
- if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
1712
- value = value.slice(1, -1);
1713
- if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
1714
- } else {
1715
- const comment = value.indexOf(" #");
1716
- if (comment !== -1) value = value.slice(0, comment).trim();
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}`);
1717
1926
  }
1718
- out[key] = value;
1719
- }
1720
- return out;
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. */
1977
+ function collect$1(value, acc) {
1978
+ acc.push(value);
1979
+ return acc;
1721
1980
  }
1722
1981
  //#endregion
1723
1982
  //#region src/secrets.ts
@@ -1732,6 +1991,27 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
1732
1991
  await ctx.client.setSecret(orgId, envId, name, ciphertext);
1733
1992
  }
1734
1993
  /**
1994
+ * Encrypt and store many secrets into one environment. The DEK is fetched once
1995
+ * (so user auth prompts for the passphrase a single time, not per variable),
1996
+ * then each value is encrypted locally and written. Existing names are
1997
+ * overwritten; the result splits them into created vs. updated for a summary.
1998
+ * Callers validate the names first — a rejected name aborts before any write.
1999
+ */
2000
+ async function importSecrets(ctx, orgId, envId, entries) {
2001
+ const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
2002
+ const existing = new Set(secrets.map((s) => s.name));
2003
+ const result = {
2004
+ created: [],
2005
+ updated: []
2006
+ };
2007
+ for (const [name, value] of Object.entries(entries)) {
2008
+ const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
2009
+ await ctx.client.setSecret(orgId, envId, name, ciphertext);
2010
+ (existing.has(name) ? result.updated : result.created).push(name);
2011
+ }
2012
+ return result;
2013
+ }
2014
+ /**
1735
2015
  * Resolve the full, layered environment for a running app: composed group
1736
2016
  * secrets (lowest precedence) → the app env's own secrets → `.env` files.
1737
2017
  * Each layer's DEK is unwrapped once with the principal's private key and its
@@ -2111,6 +2391,31 @@ withTarget(secrets.command("set <name> [value]").description("encrypt and store
2111
2391
  await encryptAndSetSecret(ctx, orgId, envId, name, value === void 0 || value === "-" ? (await readStdin()).replace(/\n$/, "") : value);
2112
2392
  console.error(`${name} saved`);
2113
2393
  });
2394
+ withTarget(secrets.command("import [file]").description("bulk-import secrets from a .env file (default .env; '-' reads stdin)").option("--dry-run", "list the variable names that would be imported, without writing")).action(async (file, options) => {
2395
+ const source = file ?? ".env";
2396
+ let content;
2397
+ if (source === "-") content = await readStdin();
2398
+ else {
2399
+ if (!existsSync(source)) fail(`no such file: ${source}`);
2400
+ content = readFileSync(source, "utf8");
2401
+ }
2402
+ const entries = parseDotenv(content);
2403
+ const names = Object.keys(entries);
2404
+ if (names.length === 0) fail(`no variables found in ${source === "-" ? "stdin" : source}`);
2405
+ const invalid = names.filter((name) => !secretNameSchema.safeParse(name).success);
2406
+ if (invalid.length > 0) fail(`invalid secret name(s): ${invalid.join(", ")} (must match [A-Za-z_][A-Za-z0-9_]*)`);
2407
+ const ctx = buildContext();
2408
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
2409
+ if (options.dryRun) {
2410
+ const { secrets: existingRows } = await ctx.client.listSecrets(orgId, envId);
2411
+ const existing = new Set(existingRows.map((s) => s.name));
2412
+ console.error(`would import ${names.length} secret(s) into ${label}:`);
2413
+ for (const name of names.sort()) console.error(` ${name}\t${existing.has(name) ? "update" : "new"}`);
2414
+ return;
2415
+ }
2416
+ const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
2417
+ console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
2418
+ });
2114
2419
  withTarget(secrets.command("rm <name>").description("delete a secret")).action(async (name, options) => {
2115
2420
  const ctx = buildContext();
2116
2421
  const { orgId, envId } = await resolveEnvTarget(ctx, options);
@@ -2288,6 +2593,7 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
2288
2593
  });
2289
2594
  registerPgCommands(program);
2290
2595
  registerMysqlCommands(program);
2596
+ registerRedisCommands(program);
2291
2597
  registerProvisionerCommands(program);
2292
2598
  registerSshCommands(program);
2293
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.11.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": {