@seekrit/cli 0.7.0 → 0.9.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { Command } from "commander";
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { homedir } from "node:os";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir, tmpdir } from "node:os";
6
6
  import { dirname, join, parse } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { Writable } from "node:stream";
@@ -140,6 +140,75 @@ async function importPrivateKeyPkcs8(pkcs8) {
140
140
  }, true, ["deriveBits"]);
141
141
  }
142
142
  //#endregion
143
+ //#region ../../packages/crypto/src/mysql.ts
144
+ /**
145
+ * Client-side construction of a MySQL/MariaDB `mysql_native_password`
146
+ * authentication string, for minting *temporary MySQL login credentials*
147
+ * without the password plaintext ever reaching seekrit's control plane OR
148
+ * MySQL itself.
149
+ *
150
+ * The trick mirrors the Postgres SCRAM one (scram.ts): the stored auth string
151
+ * is `*<UPPER(HEX(SHA1(SHA1(password))))>`, and
152
+ * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'` stores that
153
+ * string verbatim — MySQL does NOT re-hash it. So the flow is:
154
+ *
155
+ * 1. the machine that will connect generates a random password locally,
156
+ * 2. computes this hash locally,
157
+ * 3. sends only the hash to the broker → `CREATE USER … AS '<hash>'`,
158
+ * 4. connects directly to MySQL with the plaintext it never shared.
159
+ *
160
+ * Zero-knowledge at both layers: the control plane relays only the hash, and
161
+ * the hash is NOT sufficient to authenticate. `mysql_native_password` login is
162
+ * a challenge-response — the server proves knowledge of `SHA1(SHA1(password))`
163
+ * against a fresh scramble, and verifying a client requires `SHA1(password)`
164
+ * (the preimage of the first inner hash), which the stored double-SHA1 does not
165
+ * reveal. A dump of `mysql.user` / the query log therefore cannot log in.
166
+ *
167
+ * SHA-1 is available via WebCrypto (`crypto.subtle.digest("SHA-1", …)`) in the
168
+ * browser, the CLI, the MCP server, and Workers — so this runs everywhere the
169
+ * SCRAM helper does, with no hand-rolled hash primitive.
170
+ */
171
+ const DEFAULT_PASSWORD_LENGTH$1 = 32;
172
+ const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
173
+ async function sha1(data) {
174
+ return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
175
+ }
176
+ function toUpperHex(bytes) {
177
+ let hex = "";
178
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
179
+ return hex.toUpperCase();
180
+ }
181
+ function randomPassword$1(length) {
182
+ let out = "";
183
+ while (out.length < length) {
184
+ const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
185
+ for (const byte of bytes) {
186
+ if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
187
+ if (out.length === length) break;
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ /**
193
+ * Compute the `mysql_native_password` auth string `*<UPPER(HEX(SHA1(SHA1(pw))))>`
194
+ * for a known password. Pass the result straight to
195
+ * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`.
196
+ */
197
+ async function mysqlNativePasswordVerifier(password) {
198
+ return `*${toUpperHex(await sha1(await sha1(utf8Encode(password))))}`;
199
+ }
200
+ /**
201
+ * Mint a fresh random password and its `mysql_native_password` hash in one step
202
+ * — the client-side half of a Vault-style dynamic MySQL credential.
203
+ */
204
+ async function generateMysqlCredential(options = {}) {
205
+ const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
206
+ return {
207
+ password,
208
+ verifier: await mysqlNativePasswordVerifier(password)
209
+ };
210
+ }
211
+ //#endregion
143
212
  //#region ../../packages/crypto/src/passphrase.ts
144
213
  /**
145
214
  * User private keys are stored server-side encrypted under a key derived from
@@ -259,6 +328,138 @@ async function generatePostgresCredential(options = {}) {
259
328
  };
260
329
  }
261
330
  //#endregion
331
+ //#region ../../packages/crypto/src/ssh.ts
332
+ /**
333
+ * Client-side SSH certificate authority for minting *temporary SSH access*
334
+ * without any private key ever reaching seekrit's control plane.
335
+ *
336
+ * The model (Vault-style, tier-1 verifier injection — see the `ZkTier` doc in
337
+ * @seekrit/core leases.ts):
338
+ *
339
+ * 1. the machine that will connect generates an ephemeral Ed25519 keypair
340
+ * locally ({@link generateSshKeyPair}),
341
+ * 2. it sends only the *public* key to the broker,
342
+ * 3. the broker signs a short-lived OpenSSH user *certificate* over that
343
+ * public key ({@link signSshUserCertificate}) using the CA private key,
344
+ * 4. the consumer connects with `ssh -i <key> -o CertificateFile=<cert>`; the
345
+ * host trusts the CA (`TrustedUserCAKeys`) and never needs the key on disk.
346
+ *
347
+ * Zero-knowledge end to end: the user private key exists only on the consumer,
348
+ * and a certificate is a public artifact (it authorizes but cannot authenticate
349
+ * without the matching private key). The only secret seekrit stores is the CA
350
+ * private key — wrapped to the broker DO's public key, decrypted transiently in
351
+ * the DO to sign (the same in-DO trade the Postgres admin credential makes).
352
+ *
353
+ * Everything here is WebCrypto Ed25519 + hand-rolled SSH wire encoding, so it
354
+ * runs unchanged in the browser, the CLI, and Workers. No Node-specific crypto.
355
+ */
356
+ const ED25519 = { name: "Ed25519" };
357
+ /**
358
+ * Serializer for the SSH binary wire types. `string` is a uint32 length prefix
359
+ * followed by that many bytes (a length-delimited byte blob, not text); ints
360
+ * are big-endian. This is the encoding used by public keys, certificates, and
361
+ * the OpenSSH private key container alike.
362
+ */
363
+ var SshWriter = class {
364
+ chunks = [];
365
+ len = 0;
366
+ bytes(b) {
367
+ this.chunks.push(b);
368
+ this.len += b.length;
369
+ return this;
370
+ }
371
+ byte(n) {
372
+ return this.bytes(new Uint8Array([n & 255]));
373
+ }
374
+ uint32(n) {
375
+ const b = /* @__PURE__ */ new Uint8Array(4);
376
+ new DataView(b.buffer).setUint32(0, n >>> 0, false);
377
+ return this.bytes(b);
378
+ }
379
+ uint64(n) {
380
+ const b = /* @__PURE__ */ new Uint8Array(8);
381
+ new DataView(b.buffer).setBigUint64(0, BigInt(n), false);
382
+ return this.bytes(b);
383
+ }
384
+ string(s) {
385
+ const b = typeof s === "string" ? utf8Encode(s) : s;
386
+ return this.uint32(b.length).bytes(b);
387
+ }
388
+ build() {
389
+ const out = new Uint8Array(this.len);
390
+ let o = 0;
391
+ for (const c of this.chunks) {
392
+ out.set(c, o);
393
+ o += c.length;
394
+ }
395
+ return out;
396
+ }
397
+ };
398
+ function concat(...arrays) {
399
+ const total = arrays.reduce((n, a) => n + a.length, 0);
400
+ const out = new Uint8Array(total);
401
+ let o = 0;
402
+ for (const a of arrays) {
403
+ out.set(a, o);
404
+ o += a.length;
405
+ }
406
+ return out;
407
+ }
408
+ /** The `ssh-ed25519` public-key blob: string "ssh-ed25519" ‖ string <32 bytes>. */
409
+ function ed25519PublicKeyBlob(pub) {
410
+ return new SshWriter().string("ssh-ed25519").string(pub).build();
411
+ }
412
+ function encodeSshPublicKey(pub, comment) {
413
+ const b64 = toBase64(ed25519PublicKeyBlob(pub));
414
+ return comment ? `ssh-ed25519 ${b64} ${comment}` : `ssh-ed25519 ${b64}`;
415
+ }
416
+ /**
417
+ * Generate an ephemeral client keypair. The private key is serialized in the
418
+ * `openssh-key-v1` format so `ssh -i` accepts it directly; the public key is
419
+ * what gets certified. Nothing here is ever sent to the control plane except
420
+ * the public key.
421
+ */
422
+ async function generateSshKeyPair(comment = "seekrit") {
423
+ const pair = await crypto.subtle.generateKey(ED25519, true, ["sign", "verify"]);
424
+ const pub = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
425
+ const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey));
426
+ const seed = pkcs8.subarray(pkcs8.length - 32);
427
+ return {
428
+ publicKeyOpenssh: encodeSshPublicKey(pub, comment),
429
+ privateKeyOpenssh: encodeOpensshPrivateKey(seed, pub, comment)
430
+ };
431
+ }
432
+ /**
433
+ * Generate a certificate-authority keypair. The private half is exported as a
434
+ * JWK (so the broker can re-import it to sign); the public half is printed for
435
+ * admins to install on their hosts.
436
+ */
437
+ async function generateSshCaKeyPair(comment = "seekrit-ca") {
438
+ const pair = await crypto.subtle.generateKey(ED25519, true, ["sign", "verify"]);
439
+ const pub = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
440
+ const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
441
+ return {
442
+ privateKeyJwk: JSON.stringify(jwk),
443
+ publicKeyOpenssh: encodeSshPublicKey(pub, comment)
444
+ };
445
+ }
446
+ /**
447
+ * Serialize an Ed25519 keypair as an unencrypted `openssh-key-v1` private key.
448
+ * Format (PROTOCOL.key): magic ‖ ciphername "none" ‖ kdfname "none" ‖ empty
449
+ * kdfoptions ‖ nkeys=1 ‖ public-key blob ‖ private section (wrapped as a
450
+ * string). The private section is two equal check-ints, then the key, then the
451
+ * comment, padded with 1,2,3,… to the "none" block size (8).
452
+ */
453
+ function encodeOpensshPrivateKey(seed, pub, comment) {
454
+ const check = new DataView(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(4)).buffer).getUint32(0, false);
455
+ const privSection = new SshWriter().uint32(check).uint32(check).string("ssh-ed25519").string(pub).string(concat(seed, pub)).string(comment).build();
456
+ const padLen = (8 - privSection.length % 8) % 8;
457
+ const pad = new Uint8Array(padLen);
458
+ for (let i = 0; i < padLen; i++) pad[i] = i + 1;
459
+ const b64 = toBase64(new SshWriter().bytes(utf8Encode("openssh-key-v1")).byte(0).string("none").string("none").string(/* @__PURE__ */ new Uint8Array(0)).uint32(1).string(ed25519PublicKeyBlob(pub)).string(concat(privSection, pad)).build());
460
+ return `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64.match(/.{1,70}/g)?.join("\n") ?? b64}\n-----END OPENSSH PRIVATE KEY-----\n`;
461
+ }
462
+ //#endregion
262
463
  //#region ../../packages/crypto/src/token.ts
263
464
  /**
264
465
  * Service tokens (CI, docker builds, agent proxies, k8s, …) are self-contained
@@ -385,7 +586,7 @@ async function unwrapDek(wrapped, privateKey) {
385
586
  }
386
587
  //#endregion
387
588
  //#region package.json
388
- var version = "0.7.0";
589
+ var version = "0.9.0";
389
590
  const PROJECT_FILE = "seekrit.json";
390
591
  function globalConfigPath() {
391
592
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
@@ -474,6 +675,12 @@ var SeekritClient = class {
474
675
  setMyKeys(input) {
475
676
  return this.request("PUT", "/v1/me/keys", input);
476
677
  }
678
+ getMyNotificationPrefs() {
679
+ return this.request("GET", "/v1/me/notifications");
680
+ }
681
+ setMyNotificationPrefs(input) {
682
+ return this.request("PUT", "/v1/me/notifications", input);
683
+ }
477
684
  listOrgs() {
478
685
  return this.request("GET", "/v1/orgs");
479
686
  }
@@ -592,7 +799,6 @@ var SeekritClient = class {
592
799
  listLeases(orgId) {
593
800
  return this.request("GET", `/v1/orgs/${orgId}/leases`);
594
801
  }
595
- /** Mint a temporary credential. Send only the client-computed verifier. */
596
802
  mintLease(orgId, input) {
597
803
  return this.request("POST", `/v1/orgs/${orgId}/leases`, input);
598
804
  }
@@ -733,7 +939,234 @@ function formatSecrets(values, format) {
733
939
  case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
734
940
  }
735
941
  }
736
- z.enum(["postgres"]);
942
+ //#endregion
943
+ //#region src/target.ts
944
+ /** Resolve the target org from a flag, the committed config, or a lone org. */
945
+ async function resolveOrg(ctx, orgSlug) {
946
+ const wanted = orgSlug ?? findProjectConfig()?.org;
947
+ const { orgs } = await ctx.client.listOrgs();
948
+ if (wanted) {
949
+ const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
950
+ if (!org) fail(`no accessible org "${wanted}"`);
951
+ return {
952
+ id: org.id,
953
+ slug: org.slug
954
+ };
955
+ }
956
+ const only = orgs[0];
957
+ if (orgs.length === 1 && only) return {
958
+ id: only.id,
959
+ slug: only.slug
960
+ };
961
+ fail("specify --org (or run `seekrit init`)");
962
+ }
963
+ /**
964
+ * Resolve an environment to operate on — an application env (`--app --env`,
965
+ * or the config's app + `--env`) or a group env (`--group --env`).
966
+ */
967
+ async function resolveEnvTarget(ctx, opts) {
968
+ const org = await resolveOrg(ctx, opts.org);
969
+ if (!opts.env) fail("specify --env");
970
+ if (opts.group) {
971
+ const { groups } = await ctx.client.listGroups(org.id);
972
+ const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
973
+ if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
974
+ const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
975
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
976
+ if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
977
+ return {
978
+ orgId: org.id,
979
+ envId: env.id,
980
+ label: `${group.slug}@${env.slug}`
981
+ };
982
+ }
983
+ const appSlug = opts.app ?? findProjectConfig()?.app;
984
+ if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
985
+ const { apps } = await ctx.client.listApps(org.id);
986
+ const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
987
+ if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
988
+ const { environments } = await ctx.client.listEnvs(org.id, app.id);
989
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
990
+ if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
991
+ return {
992
+ orgId: org.id,
993
+ envId: env.id,
994
+ label: `${app.slug}/${env.slug}`
995
+ };
996
+ }
997
+ /** Resolve an application environment, keeping ids + slugs (for token binding). */
998
+ async function resolveAppEnv(ctx, opts) {
999
+ const org = await resolveOrg(ctx, opts.org);
1000
+ const appSlug = opts.app ?? findProjectConfig()?.app;
1001
+ if (!appSlug) fail("specify --app (or run `seekrit init`)");
1002
+ if (!opts.env) fail("specify --env");
1003
+ const { apps } = await ctx.client.listApps(org.id);
1004
+ const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1005
+ if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1006
+ const { environments } = await ctx.client.listEnvs(org.id, app.id);
1007
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1008
+ if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1009
+ return {
1010
+ orgId: org.id,
1011
+ appId: app.id,
1012
+ appSlug: app.slug,
1013
+ envId: env.id,
1014
+ envSlug: env.slug
1015
+ };
1016
+ }
1017
+ /** Resolve a group by slug within the target org. */
1018
+ async function resolveGroup(ctx, opts) {
1019
+ const org = await resolveOrg(ctx, opts.org);
1020
+ if (!opts.group) fail("specify --group");
1021
+ const { groups } = await ctx.client.listGroups(org.id);
1022
+ const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1023
+ if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1024
+ return {
1025
+ orgId: org.id,
1026
+ id: group.id,
1027
+ slug: group.slug
1028
+ };
1029
+ }
1030
+ //#endregion
1031
+ //#region src/mysql.ts
1032
+ /**
1033
+ * `seekrit mysql` — temporary MySQL/MariaDB credentials (Vault-style dynamic
1034
+ * secrets).
1035
+ *
1036
+ * Zero-knowledge: minting generates the password and its
1037
+ * `mysql_native_password` hash on THIS machine and sends only the hash; the
1038
+ * plaintext password never reaches the API or gets stored. Registering a target
1039
+ * wraps the admin connection string to the broker's public key locally, so the
1040
+ * control plane only ever stores ciphertext.
1041
+ */
1042
+ /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1043
+ function parseTtlSeconds$2(input) {
1044
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1045
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1046
+ return Number(m[1]) * ({
1047
+ s: 1,
1048
+ m: 60,
1049
+ h: 3600,
1050
+ d: 86400
1051
+ }[m[2] || "s"] ?? 1);
1052
+ }
1053
+ /** A fresh, valid MySQL user name: `tmp_` + lowercase alphanumerics. */
1054
+ function generateUserName(prefix = "tmp") {
1055
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
1056
+ let out = "";
1057
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
1058
+ for (const b of bytes) out += alphabet[b % 36];
1059
+ return `${prefix}_${out}`;
1060
+ }
1061
+ function registerMysqlCommands(program) {
1062
+ const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
1063
+ const target = mysql.command("target").description("manage provisioning targets");
1064
+ 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("--admin-url <url>", "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) => {
1065
+ const ctx = buildContext();
1066
+ const org = await resolveOrg(ctx, options.org);
1067
+ const executor = options.executor === "remote" ? "remote" : "in_do";
1068
+ if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
1069
+ if (![
1070
+ "readonly",
1071
+ "readwrite",
1072
+ "custom"
1073
+ ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
1074
+ const accessLevel = options.access;
1075
+ const adminSecret = options.adminUrl ?? process.env.SEEKRIT_MYSQL_ADMIN_URL;
1076
+ if (!adminSecret) fail(executor === "remote" ? "provide the shared HMAC key via --admin-url or SEEKRIT_MYSQL_ADMIN_URL" : "provide the admin connection string via --admin-url or SEEKRIT_MYSQL_ADMIN_URL");
1077
+ const config = {
1078
+ provider: "mysql",
1079
+ executor,
1080
+ accessLevel,
1081
+ connection: {
1082
+ host: options.host,
1083
+ port: Number.parseInt(options.port, 10),
1084
+ database: options.database
1085
+ },
1086
+ userHost: options.userHost,
1087
+ ...accessLevel === "custom" ? {
1088
+ ...options.createStatement.length ? { createStatements: options.createStatement } : {},
1089
+ ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
1090
+ } : {},
1091
+ ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
1092
+ };
1093
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
1094
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
1095
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
1096
+ name: options.name,
1097
+ config,
1098
+ wrappedAdminSecret
1099
+ });
1100
+ console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
1101
+ });
1102
+ target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
1103
+ const ctx = buildContext();
1104
+ const org = await resolveOrg(ctx, options.org);
1105
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1106
+ for (const t of targets) {
1107
+ if (t.provider !== "mysql") continue;
1108
+ const cfg = t.config;
1109
+ console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
1110
+ }
1111
+ });
1112
+ target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
1113
+ const ctx = buildContext();
1114
+ const org = await resolveOrg(ctx, options.org);
1115
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
1116
+ console.error(`removed ${targetId}`);
1117
+ });
1118
+ mysql.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--user <name>", "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) => {
1119
+ const ctx = buildContext();
1120
+ const org = await resolveOrg(ctx, options.org);
1121
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1122
+ const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
1123
+ if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1124
+ if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
1125
+ const userName = options.user ?? generateUserName();
1126
+ const ttlSeconds = parseTtlSeconds$2(options.ttl);
1127
+ const { password, verifier } = await generateMysqlCredential();
1128
+ const { connection } = await ctx.client.mintLease(org.id, {
1129
+ provider: "mysql",
1130
+ targetId: target.id,
1131
+ roleName: userName,
1132
+ verifier,
1133
+ ttlSeconds
1134
+ });
1135
+ const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
1136
+ console.error(`leased ${userName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
1137
+ if (options.json) console.log(JSON.stringify({
1138
+ ...connection,
1139
+ password,
1140
+ url
1141
+ }, null, 2));
1142
+ else console.log(url);
1143
+ });
1144
+ mysql.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
1145
+ const ctx = buildContext();
1146
+ const org = await resolveOrg(ctx, options.org);
1147
+ const { leases } = await ctx.client.listLeases(org.id);
1148
+ for (const l of leases) {
1149
+ if (l.provider !== "mysql") continue;
1150
+ console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
1151
+ }
1152
+ });
1153
+ mysql.command("revoke <leaseId>").description("revoke a lease now (drops the user immediately)").option("--org <slug>").action(async (leaseId, options) => {
1154
+ const ctx = buildContext();
1155
+ const org = await resolveOrg(ctx, options.org);
1156
+ await ctx.client.revokeLease(org.id, leaseId);
1157
+ console.error(`revoked ${leaseId}`);
1158
+ });
1159
+ }
1160
+ /** Collect a repeatable option into an array. */
1161
+ function collect$2(value, acc) {
1162
+ acc.push(value);
1163
+ return acc;
1164
+ }
1165
+ z.enum([
1166
+ "postgres",
1167
+ "mysql",
1168
+ "ssh"
1169
+ ]);
737
1170
  const executorModeSchema = z.enum(["in_do", "remote"]);
738
1171
  /**
739
1172
  * A Postgres role name we are willing to create. Deliberately strict — this
@@ -747,6 +1180,30 @@ const postgresRoleNameSchema = z.string().regex(/^[a-z_][a-z0-9_]{2,62}$/, "must
747
1180
  * base64 + the fixed structural characters, none of which is a single quote).
748
1181
  */
749
1182
  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");
1183
+ /**
1184
+ * An SSH login principal (a Unix-style username the certificate authorizes).
1185
+ * Bounded and restricted to a safe charset — principals are SSH-wire-encoded,
1186
+ * not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
1187
+ */
1188
+ const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
1189
+ /** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
1190
+ const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
1191
+ /** An SSH certificate extension name, e.g. `permit-pty`. */
1192
+ const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
1193
+ /**
1194
+ * A MySQL/MariaDB user name we are willing to create. Interpolated into a
1195
+ * quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
1196
+ * alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
1197
+ */
1198
+ const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
1199
+ /**
1200
+ * A `mysql_native_password` authentication string — `*` followed by 40 upper
1201
+ * hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
1202
+ * @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
1203
+ * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
1204
+ * alphabet contains no single quote, so it is safe in a quoted SQL literal.
1205
+ */
1206
+ const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
750
1207
  const postgresAccessLevelSchema = z.enum([
751
1208
  "readonly",
752
1209
  "readwrite",
@@ -757,6 +1214,11 @@ const POSTGRES_GROUP_ROLES = {
757
1214
  readonly: "seekrit_readonly",
758
1215
  readwrite: "seekrit_readwrite"
759
1216
  };
1217
+ const mysqlAccessLevelSchema = z.enum([
1218
+ "readonly",
1219
+ "readwrite",
1220
+ "custom"
1221
+ ]);
760
1222
  const connectionSchema = z.object({
761
1223
  host: z.string().min(1),
762
1224
  port: z.number().int().min(1).max(65535),
@@ -766,7 +1228,7 @@ const connectionSchema = z.object({
766
1228
  const statementSchema = z.string().min(1).max(4e3);
767
1229
  /** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
768
1230
  const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
769
- const leaseTargetConfigSchema = z.object({
1231
+ const postgresTargetConfigSchema = z.object({
770
1232
  provider: z.literal("postgres"),
771
1233
  executor: executorModeSchema,
772
1234
  accessLevel: postgresAccessLevelSchema.optional(),
@@ -776,6 +1238,35 @@ const leaseTargetConfigSchema = z.object({
776
1238
  createStatements: z.array(statementSchema).max(16).optional(),
777
1239
  revokeStatements: z.array(statementSchema).max(16).optional()
778
1240
  });
1241
+ /** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
1242
+ const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
1243
+ const mysqlTargetConfigSchema = z.object({
1244
+ provider: z.literal("mysql"),
1245
+ executor: executorModeSchema,
1246
+ accessLevel: mysqlAccessLevelSchema.optional(),
1247
+ connection: connectionSchema,
1248
+ userHost: mysqlHostSchema.optional(),
1249
+ provisionerUrl: z.url().optional(),
1250
+ createStatements: z.array(statementSchema).max(16).optional(),
1251
+ revokeStatements: z.array(statementSchema).max(16).optional()
1252
+ });
1253
+ const sshTargetConfigSchema = z.object({
1254
+ provider: z.literal("ssh"),
1255
+ executor: z.literal("in_do"),
1256
+ caPublicKey: sshPublicKeySchema,
1257
+ allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
1258
+ extensions: z.array(sshExtensionSchema).max(16).optional(),
1259
+ maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
1260
+ connection: z.object({
1261
+ host: z.string().min(1).optional(),
1262
+ user: sshPrincipalSchema.optional()
1263
+ }).optional()
1264
+ });
1265
+ const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
1266
+ postgresTargetConfigSchema,
1267
+ mysqlTargetConfigSchema,
1268
+ sshTargetConfigSchema
1269
+ ]);
779
1270
  z.object({
780
1271
  name: z.string().trim().min(1).max(128),
781
1272
  config: leaseTargetConfigSchema,
@@ -786,12 +1277,49 @@ z.object({
786
1277
  */
787
1278
  wrappedAdminSecret: z.string().min(1)
788
1279
  });
789
- z.object({
1280
+ /** Requested lease lifetime, shared by all providers. */
1281
+ const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
1282
+ /**
1283
+ * Client → API: mint a Postgres lease. The client generates the password and
1284
+ * its SCRAM verifier locally and sends only the verifier — the plaintext
1285
+ * password never leaves the requesting machine.
1286
+ */
1287
+ const mintPostgresLeaseSchema = z.object({
1288
+ provider: z.literal("postgres"),
790
1289
  targetId: z.string().min(1),
791
1290
  roleName: postgresRoleNameSchema,
792
1291
  verifier: scramVerifierSchema,
793
- ttlSeconds: z.number().int().min(60).max(3600 * 24 * 7)
1292
+ ttlSeconds: ttlSecondsSchema
1293
+ });
1294
+ /**
1295
+ * Client → API: mint a MySQL/MariaDB lease. The client generates the password
1296
+ * and its `mysql_native_password` hash locally and sends only the hash — the
1297
+ * plaintext password never leaves the requesting machine.
1298
+ */
1299
+ const mintMysqlLeaseSchema = z.object({
1300
+ provider: z.literal("mysql"),
1301
+ targetId: z.string().min(1),
1302
+ roleName: mysqlUserNameSchema,
1303
+ verifier: mysqlNativeVerifierSchema,
1304
+ ttlSeconds: ttlSecondsSchema
794
1305
  });
1306
+ /**
1307
+ * Client → API: mint an SSH lease. The client generates an ephemeral keypair
1308
+ * locally and sends only the public key; the signed certificate comes back in
1309
+ * the response. The private key never leaves the requesting machine.
1310
+ */
1311
+ const mintSshLeaseSchema = z.object({
1312
+ provider: z.literal("ssh"),
1313
+ targetId: z.string().min(1),
1314
+ publicKey: sshPublicKeySchema,
1315
+ principals: z.array(sshPrincipalSchema).min(1).max(32),
1316
+ ttlSeconds: ttlSecondsSchema
1317
+ });
1318
+ z.discriminatedUnion("provider", [
1319
+ mintPostgresLeaseSchema,
1320
+ mintMysqlLeaseSchema,
1321
+ mintSshLeaseSchema
1322
+ ]);
795
1323
  //#endregion
796
1324
  //#region ../../packages/core/src/providers/postgres.ts
797
1325
  /**
@@ -825,6 +1353,46 @@ function postgresGroupBootstrapSql(config) {
825
1353
  return lines.join("\n");
826
1354
  }
827
1355
  //#endregion
1356
+ //#region ../../packages/core/src/providers/ssh.ts
1357
+ /**
1358
+ * The one-time host setup an admin runs so a target's certificates are accepted.
1359
+ * Analogue of `postgresGroupBootstrapSql` — displayed for the admin to run, not
1360
+ * executed by seekrit. Embeds the CA public key so it's copy-paste runnable.
1361
+ */
1362
+ function sshHostSetupInstructions(config) {
1363
+ const principals = config.allowedPrincipals?.length ? config.allowedPrincipals : ["<login-user>"];
1364
+ return [
1365
+ "# Run once on each target host so it trusts seekrit-issued certificates.",
1366
+ "# 1. Install the CA public key and trust it for user authentication:",
1367
+ `echo '${config.caPublicKey}' | sudo tee /etc/ssh/seekrit_ca.pub`,
1368
+ "sudo sh -c 'echo \"TrustedUserCAKeys /etc/ssh/seekrit_ca.pub\" >> /etc/ssh/sshd_config'",
1369
+ "# 2. (optional) Restrict which cert principals may log in as which users via",
1370
+ "# AuthorizedPrincipalsFile, e.g. /etc/ssh/auth_principals/<user> listing:",
1371
+ ...principals.map((p) => `# ${p}`),
1372
+ "# 3. Reload sshd:",
1373
+ "sudo systemctl reload sshd"
1374
+ ].join("\n");
1375
+ }
1376
+ //#endregion
1377
+ //#region ../../packages/core/src/types.ts
1378
+ /**
1379
+ * Transactional notification emails seekrit can send. Each id is one
1380
+ * user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
1381
+ * audit-grade metadata — never secret material — and every one is opt-out
1382
+ * (defaults on). Kept as a const array so the API, api-client, and dashboard
1383
+ * share a single source of truth (mirrors `AUDIT_ACTIONS`).
1384
+ */
1385
+ const NOTIFICATION_TYPES = [
1386
+ "token_created",
1387
+ "token_revoked",
1388
+ "env_access_granted",
1389
+ "env_access_revoked",
1390
+ "resolve_denied",
1391
+ "org_welcome",
1392
+ "token_expiring",
1393
+ "lease_expired"
1394
+ ];
1395
+ //#endregion
828
1396
  //#region ../../packages/core/src/schemas.ts
829
1397
  /** URL-safe identifier segment: `my-app`, `production`, … */
830
1398
  const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
@@ -897,6 +1465,7 @@ z.object({
897
1465
  environmentId: z.string().min(1).nullish(),
898
1466
  expiresAt: z.iso.datetime().nullish()
899
1467
  });
1468
+ z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
900
1469
  z.object({
901
1470
  cursor: z.string().optional(),
902
1471
  limit: z.coerce.number().int().min(1).max(200).default(50),
@@ -904,94 +1473,6 @@ z.object({
904
1473
  resourceType: z.string().optional()
905
1474
  });
906
1475
  //#endregion
907
- //#region src/target.ts
908
- /** Resolve the target org from a flag, the committed config, or a lone org. */
909
- async function resolveOrg(ctx, orgSlug) {
910
- const wanted = orgSlug ?? findProjectConfig()?.org;
911
- const { orgs } = await ctx.client.listOrgs();
912
- if (wanted) {
913
- const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
914
- if (!org) fail(`no accessible org "${wanted}"`);
915
- return {
916
- id: org.id,
917
- slug: org.slug
918
- };
919
- }
920
- const only = orgs[0];
921
- if (orgs.length === 1 && only) return {
922
- id: only.id,
923
- slug: only.slug
924
- };
925
- fail("specify --org (or run `seekrit init`)");
926
- }
927
- /**
928
- * Resolve an environment to operate on — an application env (`--app --env`,
929
- * or the config's app + `--env`) or a group env (`--group --env`).
930
- */
931
- async function resolveEnvTarget(ctx, opts) {
932
- const org = await resolveOrg(ctx, opts.org);
933
- if (!opts.env) fail("specify --env");
934
- if (opts.group) {
935
- const { groups } = await ctx.client.listGroups(org.id);
936
- const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
937
- if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
938
- const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
939
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
940
- if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
941
- return {
942
- orgId: org.id,
943
- envId: env.id,
944
- label: `${group.slug}@${env.slug}`
945
- };
946
- }
947
- const appSlug = opts.app ?? findProjectConfig()?.app;
948
- if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
949
- const { apps } = await ctx.client.listApps(org.id);
950
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
951
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
952
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
953
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
954
- if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
955
- return {
956
- orgId: org.id,
957
- envId: env.id,
958
- label: `${app.slug}/${env.slug}`
959
- };
960
- }
961
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
962
- async function resolveAppEnv(ctx, opts) {
963
- const org = await resolveOrg(ctx, opts.org);
964
- const appSlug = opts.app ?? findProjectConfig()?.app;
965
- if (!appSlug) fail("specify --app (or run `seekrit init`)");
966
- if (!opts.env) fail("specify --env");
967
- const { apps } = await ctx.client.listApps(org.id);
968
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
969
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
970
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
971
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
972
- if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
973
- return {
974
- orgId: org.id,
975
- appId: app.id,
976
- appSlug: app.slug,
977
- envId: env.id,
978
- envSlug: env.slug
979
- };
980
- }
981
- /** Resolve a group by slug within the target org. */
982
- async function resolveGroup(ctx, opts) {
983
- const org = await resolveOrg(ctx, opts.org);
984
- if (!opts.group) fail("specify --group");
985
- const { groups } = await ctx.client.listGroups(org.id);
986
- const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
987
- if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
988
- return {
989
- orgId: org.id,
990
- id: group.id,
991
- slug: group.slug
992
- };
993
- }
994
- //#endregion
995
1476
  //#region src/pg.ts
996
1477
  /**
997
1478
  * `seekrit pg` — temporary Postgres credentials (Vault-style dynamic secrets).
@@ -1003,7 +1484,7 @@ async function resolveGroup(ctx, opts) {
1003
1484
  * ciphertext.
1004
1485
  */
1005
1486
  /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1006
- function parseTtlSeconds(input) {
1487
+ function parseTtlSeconds$1(input) {
1007
1488
  const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1008
1489
  if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1009
1490
  return Number(m[1]) * ({
@@ -1024,7 +1505,7 @@ function generateRoleName(prefix = "tmp") {
1024
1505
  function registerPgCommands(program) {
1025
1506
  const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
1026
1507
  const target = pg.command("target").description("manage provisioning targets");
1027
- 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("--admin-url <url>", "admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect, []).action(async (options) => {
1508
+ 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("--admin-url <url>", "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) => {
1028
1509
  const ctx = buildContext();
1029
1510
  const org = await resolveOrg(ctx, options.org);
1030
1511
  const executor = options.executor === "remote" ? "remote" : "in_do";
@@ -1072,6 +1553,7 @@ function registerPgCommands(program) {
1072
1553
  const { targets } = await ctx.client.listLeaseTargets(org.id);
1073
1554
  for (const t of targets) {
1074
1555
  const cfg = t.config;
1556
+ if (cfg.provider !== "postgres") continue;
1075
1557
  console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
1076
1558
  }
1077
1559
  });
@@ -1081,7 +1563,9 @@ function registerPgCommands(program) {
1081
1563
  const { targets } = await ctx.client.listLeaseTargets(org.id);
1082
1564
  const t = targets.find((x) => x.id === targetId || x.name === targetId);
1083
1565
  if (!t) fail(`no target "${targetId}" in ${org.slug}`);
1084
- const bootstrap = postgresGroupBootstrapSql(t.config);
1566
+ const cfg = t.config;
1567
+ if (cfg.provider !== "postgres") fail("not a postgres target (see `seekrit ssh`)");
1568
+ const bootstrap = postgresGroupBootstrapSql(cfg);
1085
1569
  if (!bootstrap) fail("this is a custom target — it has no generated setup SQL");
1086
1570
  console.log(bootstrap);
1087
1571
  });
@@ -1097,10 +1581,12 @@ function registerPgCommands(program) {
1097
1581
  const { targets } = await ctx.client.listLeaseTargets(org.id);
1098
1582
  const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
1099
1583
  if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1584
+ if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
1100
1585
  const roleName = options.role ?? generateRoleName();
1101
- const ttlSeconds = parseTtlSeconds(options.ttl);
1586
+ const ttlSeconds = parseTtlSeconds$1(options.ttl);
1102
1587
  const { password, verifier } = await generatePostgresCredential();
1103
1588
  const { connection } = await ctx.client.mintLease(org.id, {
1589
+ provider: "postgres",
1104
1590
  targetId: target.id,
1105
1591
  roleName,
1106
1592
  verifier,
@@ -1129,7 +1615,7 @@ function registerPgCommands(program) {
1129
1615
  });
1130
1616
  }
1131
1617
  /** Collect a repeatable option into an array. */
1132
- function collect(value, acc) {
1618
+ function collect$1(value, acc) {
1133
1619
  acc.push(value);
1134
1620
  return acc;
1135
1621
  }
@@ -1234,6 +1720,147 @@ function printExplain(provenance) {
1234
1720
  for (const name of names) process.stderr.write(`${name.padEnd(width)} ${provenance[name]}\n`);
1235
1721
  }
1236
1722
  //#endregion
1723
+ //#region src/ssh.ts
1724
+ /**
1725
+ * `seekrit ssh` — temporary SSH access via short-lived certificates (Vault-style
1726
+ * dynamic secrets, the SSH sibling of `seekrit pg`).
1727
+ *
1728
+ * Zero-knowledge: minting generates an ephemeral keypair on THIS machine and
1729
+ * sends only the public key; the broker signs a certificate and returns it (a
1730
+ * public artifact). The private key never leaves this machine. Registering a
1731
+ * target generates a CA keypair locally and wraps the CA *private* key to the
1732
+ * broker's public key, so the control plane only ever stores ciphertext; the CA
1733
+ * *public* key is printed for you to install on your hosts.
1734
+ */
1735
+ /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1736
+ function parseTtlSeconds(input) {
1737
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1738
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1739
+ return Number(m[1]) * ({
1740
+ s: 1,
1741
+ m: 60,
1742
+ h: 3600,
1743
+ d: 86400
1744
+ }[m[2] || "s"] ?? 1);
1745
+ }
1746
+ function registerSshCommands(program) {
1747
+ const ssh = program.command("ssh").description("temporary SSH access (short-lived certificates, zero-knowledge)");
1748
+ const target = ssh.command("target").description("manage SSH CA targets");
1749
+ target.command("add").description("create an SSH certificate authority to issue certs from").requiredOption("--name <name>", "display name, e.g. prod-fleet").option("--org <slug>").option("--host <host>", "default host the printed ssh command connects to").option("--user <login>", "default login user (a cert principal)").option("--principal <name>", "allow-list a principal certs may request (repeatable)", collect, []).option("--extension <name>", "cert extension to grant, e.g. permit-pty (repeatable)", collect, []).option("--max-ttl <duration>", "clamp requested cert lifetime, e.g. 8h").action(async (options) => {
1750
+ const ctx = buildContext();
1751
+ const org = await resolveOrg(ctx, options.org);
1752
+ const ca = await generateSshCaKeyPair(`seekrit-ca:${options.name}`);
1753
+ const config = {
1754
+ provider: "ssh",
1755
+ executor: "in_do",
1756
+ caPublicKey: ca.publicKeyOpenssh,
1757
+ ...options.principal.length ? { allowedPrincipals: options.principal } : {},
1758
+ ...options.extension.length ? { extensions: options.extension } : {},
1759
+ ...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds(options.maxTtl) } : {},
1760
+ ...options.host || options.user ? { connection: {
1761
+ ...options.host ? { host: options.host } : {},
1762
+ ...options.user ? { user: options.user } : {}
1763
+ } } : {}
1764
+ };
1765
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
1766
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(ca.privateKeyJwk), publicKeyJwk);
1767
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
1768
+ name: options.name,
1769
+ config,
1770
+ wrappedAdminSecret
1771
+ });
1772
+ console.error(`registered SSH target ${created.name} (${created.id})`);
1773
+ console.error("\nInstall the CA on your hosts, then issue certs with `seekrit ssh lease`:\n");
1774
+ console.log(sshHostSetupInstructions(config));
1775
+ });
1776
+ target.command("list").description("list SSH CA targets").option("--org <slug>").action(async (options) => {
1777
+ const ctx = buildContext();
1778
+ const org = await resolveOrg(ctx, options.org);
1779
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1780
+ for (const t of targets) {
1781
+ const cfg = t.config;
1782
+ if (cfg.provider !== "ssh") continue;
1783
+ const where = cfg.connection?.host ?? "-";
1784
+ const principals = cfg.allowedPrincipals?.join(",") ?? "any";
1785
+ console.log(`${t.id}\t${t.name}\t${where}\tprincipals=${principals}`);
1786
+ }
1787
+ });
1788
+ target.command("setup <targetId>").description("reprint the one-time host setup for an SSH target").option("--org <slug>").action(async (targetId, options) => {
1789
+ const ctx = buildContext();
1790
+ const org = await resolveOrg(ctx, options.org);
1791
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1792
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
1793
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
1794
+ const cfg = t.config;
1795
+ if (cfg.provider !== "ssh") fail("not an ssh target (see `seekrit pg`)");
1796
+ console.log(sshHostSetupInstructions(cfg));
1797
+ });
1798
+ target.command("rm <targetId>").description("delete an SSH CA target").option("--org <slug>").action(async (targetId, options) => {
1799
+ const ctx = buildContext();
1800
+ const org = await resolveOrg(ctx, options.org);
1801
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
1802
+ console.error(`deleted ${targetId}`);
1803
+ });
1804
+ ssh.command("lease <target>").description("mint a short-lived SSH certificate; prints a ready-to-run ssh command").option("--org <slug>").option("--principal <name>", "login user to request (repeatable; default from target)", collect, []).option("--ttl <duration>", "certificate lifetime, e.g. 30m, 1h, 8h", "1h").option("--out <dir>", "directory to write the key + cert (default: a temp dir)").option("--json", "print key/cert paths and the certificate as JSON").action(async (targetRef, options) => {
1805
+ const ctx = buildContext();
1806
+ const org = await resolveOrg(ctx, options.org);
1807
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1808
+ const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
1809
+ if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
1810
+ const cfg = t.config;
1811
+ if (cfg.provider !== "ssh") fail(`"${t.name}" is not an ssh target (see \`seekrit pg\`)`);
1812
+ const principals = options.principal.length ? options.principal : cfg.connection?.user ? [cfg.connection.user] : cfg.allowedPrincipals ?? [];
1813
+ if (principals.length === 0) fail("specify at least one --principal");
1814
+ const ttlSeconds = parseTtlSeconds(options.ttl);
1815
+ const keypair = await generateSshKeyPair(`seekrit:${t.name}`);
1816
+ const { ssh: cert } = await ctx.client.mintLease(org.id, {
1817
+ provider: "ssh",
1818
+ targetId: t.id,
1819
+ publicKey: keypair.publicKeyOpenssh,
1820
+ principals,
1821
+ ttlSeconds
1822
+ });
1823
+ const dir = options.out ?? mkdtempSync(join(tmpdir(), "seekrit-ssh-"));
1824
+ if (options.out) mkdirSync(dir, { recursive: true });
1825
+ const keyPath = join(dir, "id_ed25519");
1826
+ const certPath = join(dir, "id_ed25519-cert.pub");
1827
+ writeFileSync(keyPath, keypair.privateKeyOpenssh, { mode: 384 });
1828
+ writeFileSync(certPath, `${cert.certificate}\n`, { mode: 420 });
1829
+ const loginUser = principals[0];
1830
+ const command = `ssh -i ${keyPath} -o CertificateFile=${certPath}${cert.host ? ` ${loginUser}@${cert.host}` : ""}`;
1831
+ console.error(`issued cert for ${principals.join(",")} — expires ${cert.expiresAt}`);
1832
+ if (options.json) console.log(JSON.stringify({
1833
+ keyPath,
1834
+ certPath,
1835
+ certificate: cert.certificate,
1836
+ principals,
1837
+ command,
1838
+ expiresAt: cert.expiresAt
1839
+ }, null, 2));
1840
+ else console.log(command);
1841
+ });
1842
+ ssh.command("leases").description("list SSH leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
1843
+ const ctx = buildContext();
1844
+ const org = await resolveOrg(ctx, options.org);
1845
+ const { leases } = await ctx.client.listLeases(org.id);
1846
+ for (const l of leases) {
1847
+ if (l.provider !== "ssh") continue;
1848
+ console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
1849
+ }
1850
+ });
1851
+ ssh.command("revoke <leaseId>").description("mark a lease revoked in the ledger (the cert stays valid until it expires)").option("--org <slug>").action(async (leaseId, options) => {
1852
+ const ctx = buildContext();
1853
+ const org = await resolveOrg(ctx, options.org);
1854
+ await ctx.client.revokeLease(org.id, leaseId);
1855
+ console.error(`revoked ${leaseId} (issued certs remain valid until they expire)`);
1856
+ });
1857
+ }
1858
+ /** Collect a repeatable option into an array. */
1859
+ function collect(value, acc) {
1860
+ acc.push(value);
1861
+ return acc;
1862
+ }
1863
+ //#endregion
1237
1864
  //#region src/index.ts
1238
1865
  /** Collect repeated `--with group=env` flags into a map. */
1239
1866
  function collectKv(value, acc = {}) {
@@ -1591,8 +2218,10 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
1591
2218
  console.error(`${tokenId} revoked`);
1592
2219
  });
1593
2220
  registerPgCommands(program);
2221
+ registerMysqlCommands(program);
2222
+ registerSshCommands(program);
1594
2223
  program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
1595
- const { runMcpServer } = await import("./mcp-Dw3hVhbC.js");
2224
+ const { runMcpServer } = await import("./mcp-BEcV_KpM.js");
1596
2225
  await runMcpServer();
1597
2226
  });
1598
2227
  program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
@@ -1606,4 +2235,4 @@ program.parseAsync(argv).catch((err) => {
1606
2235
  fail(err instanceof Error ? err.message : String(err));
1607
2236
  });
1608
2237
  //#endregion
1609
- export { parseServiceToken as _, resolveEnvTarget as a, getDek as c, setFailThrows as d, writeProjectConfig as f, isServiceToken as g, createServiceToken as h, resolveAppEnv as i, isTokenAuth as l, wrapDek as m, fetchDecryptedSecrets as n, resolveGroup as o, version as p, materializeEnv as r, resolveOrg as s, encryptAndSetSecret as t, tryBuildContext as u, generatePostgresCredential as v, generateDek as y };
2238
+ export { parseServiceToken as _, resolveEnvTarget as a, generateDek as b, getDek as c, setFailThrows as d, writeProjectConfig as f, isServiceToken as g, createServiceToken as h, resolveAppEnv as i, isTokenAuth as l, wrapDek as m, fetchDecryptedSecrets as n, resolveGroup as o, version as p, materializeEnv as r, resolveOrg as s, encryptAndSetSecret as t, tryBuildContext as u, generatePostgresCredential as v, generateMysqlCredential as y };
@@ -1,4 +1,4 @@
1
- import { _ as parseServiceToken, a as resolveEnvTarget, c as getDek, d as setFailThrows, f as writeProjectConfig, g as isServiceToken, h as createServiceToken, i as resolveAppEnv, l as isTokenAuth, m as wrapDek, n as fetchDecryptedSecrets, o as resolveGroup, p as version, r as materializeEnv, s as resolveOrg, t as encryptAndSetSecret, u as tryBuildContext, v as generatePostgresCredential, y as generateDek } from "./index.js";
1
+ import { _ as parseServiceToken, a as resolveEnvTarget, b as generateDek, c as getDek, d as setFailThrows, f as writeProjectConfig, g as isServiceToken, h as createServiceToken, i as resolveAppEnv, l as isTokenAuth, m as wrapDek, n as fetchDecryptedSecrets, o as resolveGroup, p as version, r as materializeEnv, s as resolveOrg, t as encryptAndSetSecret, u as tryBuildContext, v as generatePostgresCredential, y as generateMysqlCredential } from "./index.js";
2
2
  import { spawn } from "node:child_process";
3
3
  import { z } from "zod";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -560,6 +560,7 @@ async function runMcpServer() {
560
560
  const roleName = role ?? `tmp_${randomLower(12)}`;
561
561
  const { password, verifier } = await generatePostgresCredential();
562
562
  const { lease, connection } = await ctx.client.mintLease(orgRef.id, {
563
+ provider: "postgres",
563
564
  targetId: t.id,
564
565
  roleName,
565
566
  verifier,
@@ -591,6 +592,58 @@ async function runMcpServer() {
591
592
  leaseId
592
593
  };
593
594
  });
595
+ tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
596
+ const ctx = getCtx();
597
+ const orgRef = await resolveOrg(ctx, org);
598
+ return (await ctx.client.listLeaseTargets(orgRef.id)).targets.filter((t) => t.provider === "mysql");
599
+ });
600
+ tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", {
601
+ org: z.string().optional(),
602
+ target: z.string().describe("target id or name"),
603
+ user: z.string().optional().describe("user name to create (default: random tmp_ name)"),
604
+ ttlSeconds: z.number().int().min(60).max(604800).optional().describe("lifetime (default 3600)")
605
+ }, async ({ org, target, user, ttlSeconds }) => {
606
+ const ctx = getCtx();
607
+ const orgRef = await resolveOrg(ctx, org);
608
+ const { targets } = await ctx.client.listLeaseTargets(orgRef.id);
609
+ const t = targets.find((x) => x.id === target || x.name === target);
610
+ if (!t) throw new Error(`no target "${target}" in ${orgRef.slug}`);
611
+ if (t.provider !== "mysql") throw new Error(`target "${target}" is not a MySQL target`);
612
+ const userName = user ?? `tmp_${randomLower(12)}`;
613
+ const { password, verifier } = await generateMysqlCredential();
614
+ const { lease, connection } = await ctx.client.mintLease(orgRef.id, {
615
+ provider: "mysql",
616
+ targetId: t.id,
617
+ roleName: userName,
618
+ verifier,
619
+ ttlSeconds: ttlSeconds ?? 3600
620
+ });
621
+ const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
622
+ return {
623
+ leaseId: lease.id,
624
+ url,
625
+ username: userName,
626
+ expiresAt: connection.expiresAt,
627
+ note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
628
+ };
629
+ });
630
+ tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
631
+ const ctx = getCtx();
632
+ const orgRef = await resolveOrg(ctx, org);
633
+ return (await ctx.client.listLeases(orgRef.id)).leases.filter((l) => l.provider === "mysql");
634
+ });
635
+ tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", {
636
+ org: z.string().optional(),
637
+ leaseId: z.string()
638
+ }, async ({ org, leaseId }) => {
639
+ const ctx = getCtx();
640
+ const orgRef = await resolveOrg(ctx, org);
641
+ await ctx.client.revokeLease(orgRef.id, leaseId);
642
+ return {
643
+ ok: true,
644
+ leaseId
645
+ };
646
+ });
594
647
  tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", {
595
648
  org: z.string(),
596
649
  app: z.string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seekrit/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
5
5
  "type": "module",
6
6
  "publishConfig": {