@seekrit/cli 0.10.0 → 0.12.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 (3) hide show
  1. package/README.md +24 -2
  2. package/dist/index.js +1473 -1425
  3. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,1543 +1,1576 @@
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
- //#region ../../packages/crypto/src/encoding.ts
11
- const CHUNK = 32768;
12
- /** Base64url (no padding) — portable across browsers, Workers, and Node. */
13
- function toBase64Url(bytes) {
14
- let binary = "";
15
- for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
16
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
17
- }
18
- function fromBase64Url(text) {
19
- const base64 = text.replaceAll("-", "+").replaceAll("_", "/");
20
- const binary = atob(base64);
21
- const bytes = new Uint8Array(binary.length);
22
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
23
- return bytes;
24
- }
25
- /**
26
- * Standard base64 (with `+`, `/`, and `=` padding). Most seekrit blobs use
27
- * base64url, but some external wire formats mandate standard base64 — notably
28
- * PostgreSQL SCRAM-SHA-256 verifier strings (see scram.ts).
29
- */
30
- function toBase64(bytes) {
31
- let binary = "";
32
- for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
33
- return btoa(binary);
34
- }
35
- function utf8Encode(text) {
36
- return new TextEncoder().encode(text);
37
- }
38
- function utf8Decode(bytes) {
39
- return new TextDecoder().decode(bytes);
40
- }
41
- //#endregion
42
- //#region ../../packages/crypto/src/errors.ts
43
- var SeekritCryptoError = class extends Error {
44
- code;
45
- constructor(code, message) {
46
- super(message);
47
- this.name = "SeekritCryptoError";
48
- this.code = code;
49
- }
50
- };
51
- /**
52
- * Split a versioned blob like `sc1.<b64>.<b64>` and verify the prefix.
53
- * AES-GCM auth failure downstream surfaces as DECRYPT_FAILED — that is also
54
- * the "wrong passphrase" signal for passphrase-encrypted blobs.
55
- */
56
- function splitBlob(blob, prefix, segments) {
57
- const parts = blob.split(".");
58
- if (parts[0] !== prefix) throw new SeekritCryptoError("UNSUPPORTED_VERSION", `expected a "${prefix}" blob, got "${parts[0] ?? ""}"`);
59
- if (parts.length !== segments + 1 || parts.some((p) => p.length === 0)) throw new SeekritCryptoError("MALFORMED_BLOB", `malformed "${prefix}" blob`);
60
- return parts.slice(1);
61
- }
62
- //#endregion
63
- //#region ../../packages/crypto/src/aes.ts
64
- const SECRET_PREFIX = "sc1";
65
- const IV_LENGTH = 12;
66
- /** Generate a fresh 256-bit data encryption key for an environment. */
67
- function generateDek() {
68
- return crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
69
- }
70
- async function importDek(dek, usage) {
71
- return crypto.subtle.importKey("raw", dek, { name: "AES-GCM" }, false, [usage]);
72
- }
10
+ z.enum([
11
+ "postgres",
12
+ "mysql",
13
+ "ssh"
14
+ ]);
15
+ const executorModeSchema = z.enum(["in_do", "remote"]);
73
16
  /**
74
- * Encrypt a secret value with the environment DEK.
75
- *
76
- * @param aad Authenticated context binding the ciphertext to its location
77
- * (e.g. `environmentId/SECRET_NAME`) so blobs cannot be swapped between
78
- * secrets or environments without detection.
17
+ * A Postgres role name we are willing to create. Deliberately strict — this
18
+ * value is interpolated into a SQL template, so it must be a bare identifier
19
+ * with no way to break out of quoting (no quotes, whitespace, or semicolons).
79
20
  */
80
- async function encryptSecret(dek, plaintext, aad) {
81
- const key = await importDek(dek, "encrypt");
82
- const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
83
- const ciphertext = await crypto.subtle.encrypt({
84
- name: "AES-GCM",
85
- iv,
86
- additionalData: utf8Encode(aad)
87
- }, key, utf8Encode(plaintext));
88
- return `${SECRET_PREFIX}.${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
89
- }
90
- async function decryptSecret(dek, blob, aad) {
91
- const [ivB64, ctB64] = splitBlob(blob, SECRET_PREFIX, 2);
92
- const key = await importDek(dek, "decrypt");
93
- try {
94
- const plaintext = await crypto.subtle.decrypt({
95
- name: "AES-GCM",
96
- iv: fromBase64Url(ivB64),
97
- additionalData: utf8Encode(aad)
98
- }, key, fromBase64Url(ctB64));
99
- return utf8Decode(new Uint8Array(plaintext));
100
- } catch {
101
- throw new SeekritCryptoError("DECRYPT_FAILED", "secret decryption failed: wrong key, tampered data, or mismatched context");
102
- }
103
- }
104
- /** AAD binding a secret ciphertext to its environment + name. */
105
- function secretAad(environmentId, secretName) {
106
- return `${environmentId}/${secretName}`;
107
- }
108
- //#endregion
109
- //#region ../../packages/crypto/src/keys.ts
110
- async function generateKeyPair() {
111
- const pair = await crypto.subtle.generateKey({
112
- name: "ECDH",
113
- namedCurve: "P-256"
114
- }, true, ["deriveBits"]);
115
- const [publicJwk, privateJwk] = await Promise.all([crypto.subtle.exportKey("jwk", pair.publicKey), crypto.subtle.exportKey("jwk", pair.privateKey)]);
116
- return {
117
- publicKeyJwk: JSON.stringify(publicJwk),
118
- privateKeyJwk: JSON.stringify(privateJwk)
119
- };
120
- }
121
- async function importPublicKey(publicKeyJwk) {
122
- return crypto.subtle.importKey("jwk", JSON.parse(publicKeyJwk), {
123
- name: "ECDH",
124
- namedCurve: "P-256"
125
- }, true, []);
126
- }
127
- async function importPrivateKey(privateKeyJwk) {
128
- return crypto.subtle.importKey("jwk", JSON.parse(privateKeyJwk), {
129
- name: "ECDH",
130
- namedCurve: "P-256"
131
- }, true, ["deriveBits"]);
132
- }
133
- async function exportPrivateKeyPkcs8(key) {
134
- return new Uint8Array(await crypto.subtle.exportKey("pkcs8", key));
135
- }
136
- async function importPrivateKeyPkcs8(pkcs8) {
137
- return crypto.subtle.importKey("pkcs8", pkcs8, {
138
- name: "ECDH",
139
- namedCurve: "P-256"
140
- }, true, ["deriveBits"]);
141
- }
142
- //#endregion
143
- //#region ../../packages/crypto/src/mysql.ts
21
+ 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");
144
22
  /**
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.
23
+ * A SCRAM-SHA-256 verifier string as produced by @seekrit/crypto. Validated so
24
+ * it, too, is safe to interpolate into a quoted SQL literal (the alphabet is
25
+ * base64 + the fixed structural characters, none of which is a single quote).
170
26
  */
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
- }
27
+ 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");
192
28
  /**
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>'`.
29
+ * An SSH login principal (a Unix-style username the certificate authorizes).
30
+ * Bounded and restricted to a safe charset principals are SSH-wire-encoded,
31
+ * not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
196
32
  */
197
- async function mysqlNativePasswordVerifier(password) {
198
- return `*${toUpperHex(await sha1(await sha1(utf8Encode(password))))}`;
199
- }
33
+ const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
34
+ /** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
35
+ const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
36
+ /** An SSH certificate extension name, e.g. `permit-pty`. */
37
+ const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
200
38
  /**
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.
39
+ * A MySQL/MariaDB user name we are willing to create. Interpolated into a
40
+ * quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
41
+ * alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
203
42
  */
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
212
- //#region ../../packages/crypto/src/passphrase.ts
43
+ const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
213
44
  /**
214
- * User private keys are stored server-side encrypted under a key derived from
215
- * the user's passphrase, so any browser or CLI session can fetch and unlock
216
- * them without the server ever seeing the passphrase or plaintext key.
217
- *
218
- * KDF is PBKDF2-HMAC-SHA256 (WebCrypto-native everywhere). The blob embeds
219
- * its own salt + iteration count for future agility; bumping ITERATIONS only
220
- * affects newly written blobs. TODO: revisit Argon2id via WASM later.
221
- *
222
- * Blob format: `pk1.<iterations>.<salt>.<iv>.<ciphertext>`
45
+ * A `mysql_native_password` authentication string `*` followed by 40 upper
46
+ * hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
47
+ * @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
48
+ * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
49
+ * alphabet contains no single quote, so it is safe in a quoted SQL literal.
223
50
  */
224
- const PK_PREFIX = "pk1";
225
- /** OWASP-recommended minimum for PBKDF2-HMAC-SHA256. */
226
- const PBKDF2_ITERATIONS = 6e5;
227
- async function deriveKek(passphrase, salt, iterations, usage) {
228
- const material = await crypto.subtle.importKey("raw", utf8Encode(passphrase), "PBKDF2", false, ["deriveKey"]);
229
- return crypto.subtle.deriveKey({
230
- name: "PBKDF2",
231
- hash: "SHA-256",
232
- salt,
233
- iterations
234
- }, material, {
235
- name: "AES-GCM",
236
- length: 256
237
- }, false, [usage]);
238
- }
239
- async function encryptPrivateKey(passphrase, privateKeyJwk) {
240
- const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
241
- const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
242
- const kek = await deriveKek(passphrase, salt, PBKDF2_ITERATIONS, "encrypt");
243
- const ciphertext = await crypto.subtle.encrypt({
244
- name: "AES-GCM",
245
- iv
246
- }, kek, utf8Encode(privateKeyJwk));
247
- return [
248
- PK_PREFIX,
249
- String(PBKDF2_ITERATIONS),
250
- toBase64Url(salt),
251
- toBase64Url(iv),
252
- toBase64Url(new Uint8Array(ciphertext))
253
- ].join(".");
254
- }
255
- /** Wrong passphrase surfaces as SeekritCryptoError with code DECRYPT_FAILED. */
256
- async function decryptPrivateKey(passphrase, blob) {
257
- const [iterStr, saltB64, ivB64, ctB64] = splitBlob(blob, PK_PREFIX, 4);
258
- const iterations = Number.parseInt(iterStr, 10);
259
- if (!Number.isFinite(iterations) || iterations < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid PBKDF2 iteration count");
260
- const kek = await deriveKek(passphrase, fromBase64Url(saltB64), iterations, "decrypt");
261
- try {
262
- const plaintext = await crypto.subtle.decrypt({
263
- name: "AES-GCM",
264
- iv: fromBase64Url(ivB64)
265
- }, kek, fromBase64Url(ctB64));
266
- return utf8Decode(new Uint8Array(plaintext));
267
- } catch {
268
- throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
269
- }
270
- }
271
- const SALT_LENGTH = 16;
272
- const DEFAULT_PASSWORD_LENGTH = 32;
273
- const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
274
- async function hmacSha256(key, message) {
275
- const k = await crypto.subtle.importKey("raw", key, {
276
- name: "HMAC",
277
- hash: "SHA-256"
278
- }, false, ["sign"]);
279
- return new Uint8Array(await crypto.subtle.sign("HMAC", k, message));
280
- }
281
- async function sha256(data) {
282
- return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
283
- }
284
- async function saltPassword(password, salt, iterations) {
285
- const material = await crypto.subtle.importKey("raw", utf8Encode(password), "PBKDF2", false, ["deriveBits"]);
286
- const bits = await crypto.subtle.deriveBits({
287
- name: "PBKDF2",
288
- hash: "SHA-256",
289
- salt,
290
- iterations
291
- }, material, 256);
292
- return new Uint8Array(bits);
293
- }
294
- function randomPassword(length) {
295
- let out = "";
296
- while (out.length < length) {
297
- const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
298
- for (const byte of bytes) {
299
- if (byte < 248) out += PASSWORD_ALPHABET[byte % 62];
300
- if (out.length === length) break;
301
- }
302
- }
303
- return out;
304
- }
51
+ const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
52
+ const postgresAccessLevelSchema = z.enum([
53
+ "readonly",
54
+ "readwrite",
55
+ "custom"
56
+ ]);
57
+ /** The group role each preset's leased credentials inherit. */
58
+ const POSTGRES_GROUP_ROLES = {
59
+ readonly: "seekrit_readonly",
60
+ readwrite: "seekrit_readwrite"
61
+ };
62
+ const mysqlAccessLevelSchema = z.enum([
63
+ "readonly",
64
+ "readwrite",
65
+ "custom"
66
+ ]);
67
+ const connectionSchema = z.object({
68
+ host: z.string().min(1),
69
+ port: z.number().int().min(1).max(65535),
70
+ database: z.string().min(1)
71
+ });
72
+ /** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
73
+ const statementSchema = z.string().min(1).max(4e3);
74
+ /** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
75
+ const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
76
+ const postgresTargetConfigSchema = z.object({
77
+ provider: z.literal("postgres"),
78
+ executor: executorModeSchema,
79
+ accessLevel: postgresAccessLevelSchema.optional(),
80
+ schema: identifierSchema.optional(),
81
+ connection: connectionSchema,
82
+ provisionerUrl: z.url().optional(),
83
+ createStatements: z.array(statementSchema).max(16).optional(),
84
+ revokeStatements: z.array(statementSchema).max(16).optional()
85
+ });
86
+ /** A MySQL account host part (`'name'@'<host>'`) no quotes/whitespace. */
87
+ const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
88
+ const mysqlTargetConfigSchema = z.object({
89
+ provider: z.literal("mysql"),
90
+ executor: executorModeSchema,
91
+ accessLevel: mysqlAccessLevelSchema.optional(),
92
+ connection: connectionSchema,
93
+ userHost: mysqlHostSchema.optional(),
94
+ provisionerUrl: z.url().optional(),
95
+ createStatements: z.array(statementSchema).max(16).optional(),
96
+ revokeStatements: z.array(statementSchema).max(16).optional()
97
+ });
98
+ const sshTargetConfigSchema = z.object({
99
+ provider: z.literal("ssh"),
100
+ executor: z.literal("in_do"),
101
+ caPublicKey: sshPublicKeySchema,
102
+ allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
103
+ extensions: z.array(sshExtensionSchema).max(16).optional(),
104
+ maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
105
+ connection: z.object({
106
+ host: z.string().min(1).optional(),
107
+ user: sshPrincipalSchema.optional()
108
+ }).optional()
109
+ });
110
+ const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
111
+ postgresTargetConfigSchema,
112
+ mysqlTargetConfigSchema,
113
+ sshTargetConfigSchema
114
+ ]);
115
+ z.object({
116
+ name: z.string().trim().min(1).max(128),
117
+ config: leaseTargetConfigSchema,
118
+ /**
119
+ * The admin/provisioning credential (e.g. a Postgres connection string),
120
+ * encrypted client-side to the broker's public key (a `wd1.` wrap). The
121
+ * control plane stores only this ciphertext — it never sees the plaintext.
122
+ */
123
+ wrappedAdminSecret: z.string().min(1)
124
+ });
125
+ /** Requested lease lifetime, shared by all providers. */
126
+ const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
305
127
  /**
306
- * Compute the `SCRAM-SHA-256$<i>:<salt>$<StoredKey>:<ServerKey>` verifier for a
307
- * known password. Pass the result straight to `CREATE ROLE PASSWORD`.
128
+ * Client API: mint a Postgres lease. The client generates the password and
129
+ * its SCRAM verifier locally and sends only the verifier the plaintext
130
+ * password never leaves the requesting machine.
308
131
  */
309
- async function scramSha256Verifier(password, options = {}) {
310
- const salt = options.salt ?? crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
311
- const iterations = options.iterations ?? 4096;
312
- const saltedPassword = await saltPassword(password, salt, iterations);
313
- const storedKey = await sha256(await hmacSha256(saltedPassword, utf8Encode("Client Key")));
314
- const serverKey = await hmacSha256(saltedPassword, utf8Encode("Server Key"));
315
- return `SCRAM-SHA-256$${iterations}:${toBase64(salt)}$${toBase64(storedKey)}:${toBase64(serverKey)}`;
316
- }
132
+ const mintPostgresLeaseSchema = z.object({
133
+ provider: z.literal("postgres"),
134
+ targetId: z.string().min(1),
135
+ roleName: postgresRoleNameSchema,
136
+ verifier: scramVerifierSchema,
137
+ ttlSeconds: ttlSecondsSchema
138
+ });
317
139
  /**
318
- * Mint a fresh random password and its SCRAM verifier in one step — the
319
- * client-side half of a Vault-style dynamic Postgres credential.
140
+ * Client API: mint a MySQL/MariaDB lease. The client generates the password
141
+ * and its `mysql_native_password` hash locally and sends only the hash — the
142
+ * plaintext password never leaves the requesting machine.
320
143
  */
321
- async function generatePostgresCredential(options = {}) {
322
- const password = randomPassword(options.length ?? DEFAULT_PASSWORD_LENGTH);
323
- const iterations = options.iterations ?? 4096;
324
- return {
325
- password,
326
- verifier: await scramSha256Verifier(password, { iterations }),
327
- iterations
328
- };
329
- }
144
+ const mintMysqlLeaseSchema = z.object({
145
+ provider: z.literal("mysql"),
146
+ targetId: z.string().min(1),
147
+ roleName: mysqlUserNameSchema,
148
+ verifier: mysqlNativeVerifierSchema,
149
+ ttlSeconds: ttlSecondsSchema
150
+ });
151
+ /**
152
+ * Client → API: mint an SSH lease. The client generates an ephemeral keypair
153
+ * locally and sends only the public key; the signed certificate comes back in
154
+ * the response. The private key never leaves the requesting machine.
155
+ */
156
+ const mintSshLeaseSchema = z.object({
157
+ provider: z.literal("ssh"),
158
+ targetId: z.string().min(1),
159
+ publicKey: sshPublicKeySchema,
160
+ principals: z.array(sshPrincipalSchema).min(1).max(32),
161
+ ttlSeconds: ttlSecondsSchema
162
+ });
163
+ z.discriminatedUnion("provider", [
164
+ mintPostgresLeaseSchema,
165
+ mintMysqlLeaseSchema,
166
+ mintSshLeaseSchema
167
+ ]);
330
168
  //#endregion
331
- //#region ../../packages/crypto/src/ssh.ts
169
+ //#region ../../packages/core/src/providers/postgres.ts
332
170
  /**
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).
171
+ * The one-time setup SQL an admin runs to create the shared group role that a
172
+ * read-only / read-write target's leased credentials inherit. Idempotent (safe
173
+ * to re-run). Returns null for custom targets (the admin owns their own SQL).
352
174
  *
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.
175
+ * Identifiers are interpolated from admin-supplied config (database/schema) and
176
+ * fixed group-role constants this SQL is displayed for the admin to run in
177
+ * their own database, not executed by seekrit.
355
178
  */
356
- const ED25519 = { name: "Ed25519" };
179
+ function postgresGroupBootstrapSql(config) {
180
+ if (config.accessLevel !== "readonly" && config.accessLevel !== "readwrite") return null;
181
+ const group = POSTGRES_GROUP_ROLES[config.accessLevel];
182
+ const schema = config.schema ?? "public";
183
+ const db = config.connection.database;
184
+ const privileges = config.accessLevel === "readonly" ? "SELECT" : "SELECT, INSERT, UPDATE, DELETE";
185
+ const lines = [
186
+ `-- Run once as an admin on "${db}". Temporary ${config.accessLevel} credentials inherit this role.`,
187
+ "DO $$ BEGIN",
188
+ ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${group}') THEN`,
189
+ ` CREATE ROLE ${group} NOLOGIN;`,
190
+ " END IF;",
191
+ "END $$;",
192
+ `GRANT CONNECT ON DATABASE "${db}" TO ${group};`,
193
+ `GRANT USAGE ON SCHEMA "${schema}" TO ${group};`,
194
+ `GRANT ${privileges} ON ALL TABLES IN SCHEMA "${schema}" TO ${group};`,
195
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT ${privileges} ON TABLES TO ${group};`
196
+ ];
197
+ 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};`);
198
+ return lines.join("\n");
199
+ }
200
+ //#endregion
201
+ //#region ../../packages/core/src/providers/ssh.ts
357
202
  /**
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.
203
+ * The one-time host setup an admin runs so a target's certificates are accepted.
204
+ * Analogue of `postgresGroupBootstrapSql` displayed for the admin to run, not
205
+ * executed by seekrit. Embeds the CA public key so it's copy-paste runnable.
421
206
  */
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
- };
207
+ function sshHostSetupInstructions(config) {
208
+ const principals = config.allowedPrincipals?.length ? config.allowedPrincipals : ["<login-user>"];
209
+ return [
210
+ "# Run once on each target host so it trusts seekrit-issued certificates.",
211
+ "# 1. Install the CA public key and trust it for user authentication:",
212
+ `echo '${config.caPublicKey}' | sudo tee /etc/ssh/seekrit_ca.pub`,
213
+ "sudo sh -c 'echo \"TrustedUserCAKeys /etc/ssh/seekrit_ca.pub\" >> /etc/ssh/sshd_config'",
214
+ "# 2. (optional) Restrict which cert principals may log in as which users via",
215
+ "# AuthorizedPrincipalsFile, e.g. /etc/ssh/auth_principals/<user> listing:",
216
+ ...principals.map((p) => `# ${p}`),
217
+ "# 3. Reload sshd:",
218
+ "sudo systemctl reload sshd"
219
+ ].join("\n");
431
220
  }
221
+ //#endregion
222
+ //#region ../../packages/core/src/types.ts
432
223
  /**
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.
224
+ * Transactional notification emails seekrit can send. Each id is one
225
+ * user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
226
+ * audit-grade metadata never secret material — and every one is opt-out
227
+ * (defaults on). Kept as a const array so the API, api-client, and dashboard
228
+ * share a single source of truth (mirrors `AUDIT_ACTIONS`).
436
229
  */
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
- };
230
+ const NOTIFICATION_TYPES = [
231
+ "token_created",
232
+ "token_revoked",
233
+ "env_access_granted",
234
+ "env_access_revoked",
235
+ "resolve_denied",
236
+ "org_welcome",
237
+ "token_expiring",
238
+ "lease_expired"
239
+ ];
240
+ //#endregion
241
+ //#region ../../packages/core/src/schemas.ts
242
+ /** URL-safe identifier segment: `my-app`, `production`, … */
243
+ const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
244
+ const nameSchema = z.string().trim().min(1).max(128);
245
+ /** Env-var style secret name: FOO, DATABASE_URL, apiKey2 … */
246
+ const secretNameSchema = z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
247
+ z.enum([
248
+ "owner",
249
+ "admin",
250
+ "member"
251
+ ]);
252
+ const principalTypeSchema = z.enum(["user", "service_token"]);
253
+ /** Org-level capability a service token can hold (never `owner`). */
254
+ const serviceTokenRoleSchema = z.enum(["admin", "member"]);
255
+ z.object({
256
+ name: nameSchema,
257
+ slug: slugSchema
258
+ });
259
+ z.object({
260
+ name: nameSchema,
261
+ slug: slugSchema
262
+ });
263
+ z.object({
264
+ name: nameSchema,
265
+ slug: slugSchema
266
+ });
267
+ z.object({
268
+ groupId: z.string().min(1),
269
+ /** Precedence among an env's groups (higher wins). Appended if omitted. */
270
+ position: z.number().int().min(0).optional()
271
+ });
272
+ z.object({
273
+ name: nameSchema,
274
+ slug: slugSchema,
275
+ /** Environment DEK wrapped to the creator's public key — created client-side. */
276
+ wrappedDek: z.string().min(1)
277
+ });
278
+ z.object({
279
+ /** Opaque versioned ciphertext blob from @seekrit/crypto. */
280
+ ciphertext: z.string().min(1).max(65536) });
281
+ z.object({
282
+ publicKeyJwk: z.string().min(1),
283
+ /**
284
+ * Private key encrypted with a passphrase-derived KEK; opaque to the
285
+ * server. Self-contained blob (embeds KDF salt + iterations).
286
+ */
287
+ encryptedPrivateKey: z.string().min(1)
288
+ });
289
+ z.object({
290
+ principalType: principalTypeSchema,
291
+ principalId: z.string().min(1),
292
+ wrappedDek: z.string().min(1)
293
+ });
294
+ z.object({
295
+ name: nameSchema,
296
+ tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
297
+ /** SHA-256 hash (base64url) of the full token string. */
298
+ tokenHash: z.string().min(1),
299
+ publicKeyJwk: z.string().min(1),
300
+ /**
301
+ * Org-level capability. Defaults to `member` (a runtime credential); pass
302
+ * `admin` to mint a headless provisioning token. Only an admin caller may
303
+ * create an `admin` token, so capability cannot escalate itself.
304
+ */
305
+ role: serviceTokenRoleSchema.default("member"),
306
+ /**
307
+ * The application environment this token is bound to (org + app + env).
308
+ * Optional so org-admin tokens can exist, but required for runtime tokens
309
+ * that resolve secrets via `GET /v1/resolve`.
310
+ */
311
+ environmentId: z.string().min(1).nullish(),
312
+ expiresAt: z.iso.datetime().nullish()
313
+ });
314
+ z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
315
+ z.object({
316
+ endpoint: z.url().max(2048),
317
+ headers: z.record(z.string().min(1).max(256), z.string().max(4096)).optional(),
318
+ enabled: z.boolean().default(true)
319
+ });
320
+ z.object({
321
+ cursor: z.string().optional(),
322
+ limit: z.coerce.number().int().min(1).max(200).default(50),
323
+ action: z.string().optional(),
324
+ resourceType: z.string().optional()
325
+ });
326
+ //#endregion
327
+ //#region ../../packages/crypto/src/encoding.ts
328
+ const CHUNK = 32768;
329
+ /** Base64url (no padding) — portable across browsers, Workers, and Node. */
330
+ function toBase64Url(bytes) {
331
+ let binary = "";
332
+ for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
333
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
334
+ }
335
+ function fromBase64Url(text) {
336
+ const base64 = text.replaceAll("-", "+").replaceAll("_", "/");
337
+ const binary = atob(base64);
338
+ const bytes = new Uint8Array(binary.length);
339
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
340
+ return bytes;
445
341
  }
446
342
  /**
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).
343
+ * Standard base64 (with `+`, `/`, and `=` padding). Most seekrit blobs use
344
+ * base64url, but some external wire formats mandate standard base64 notably
345
+ * PostgreSQL SCRAM-SHA-256 verifier strings (see scram.ts).
452
346
  */
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`;
347
+ function toBase64(bytes) {
348
+ let binary = "";
349
+ for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
350
+ return btoa(binary);
351
+ }
352
+ function utf8Encode(text) {
353
+ return new TextEncoder().encode(text);
354
+ }
355
+ function utf8Decode(bytes) {
356
+ return new TextDecoder().decode(bytes);
461
357
  }
462
358
  //#endregion
463
- //#region ../../packages/crypto/src/token.ts
359
+ //#region ../../packages/crypto/src/errors.ts
360
+ var SeekritCryptoError = class extends Error {
361
+ code;
362
+ constructor(code, message) {
363
+ super(message);
364
+ this.name = "SeekritCryptoError";
365
+ this.code = code;
366
+ }
367
+ };
464
368
  /**
465
- * Service tokens (CI, docker builds, agent proxies, k8s, …) are self-contained
466
- * principals: the token string itself carries the private key, so the server
467
- * never holds it. The server stores only the SHA-256 hash of the full token
468
- * (for authentication) and the public key (for wrapping DEK grants).
469
- *
470
- * Format: `skt_<token id>_<private key pkcs8, base64url>`
369
+ * Split a versioned blob like `sc1.<b64>.<b64>` and verify the prefix.
370
+ * AES-GCM auth failure downstream surfaces as DECRYPT_FAILED that is also
371
+ * the "wrong passphrase" signal for passphrase-encrypted blobs.
471
372
  */
472
- const TOKEN_PREFIX = "skt";
473
- const TOKEN_ID_LENGTH = 22;
474
- const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
475
- function randomTokenId() {
476
- let out = "";
477
- while (out.length < TOKEN_ID_LENGTH) {
478
- const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
479
- for (const byte of bytes) {
480
- if (byte < 248) out += ID_ALPHABET[byte % 62];
481
- if (out.length === TOKEN_ID_LENGTH) break;
482
- }
483
- }
484
- return `${TOKEN_PREFIX}_${out}`;
373
+ function splitBlob(blob, prefix, segments) {
374
+ const parts = blob.split(".");
375
+ if (parts[0] !== prefix) throw new SeekritCryptoError("UNSUPPORTED_VERSION", `expected a "${prefix}" blob, got "${parts[0] ?? ""}"`);
376
+ if (parts.length !== segments + 1 || parts.some((p) => p.length === 0)) throw new SeekritCryptoError("MALFORMED_BLOB", `malformed "${prefix}" blob`);
377
+ return parts.slice(1);
485
378
  }
486
- async function hashToken(token) {
487
- const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
488
- return toBase64Url(new Uint8Array(digest));
379
+ //#endregion
380
+ //#region ../../packages/crypto/src/aes.ts
381
+ const SECRET_PREFIX = "sc1";
382
+ const IV_LENGTH = 12;
383
+ /** Generate a fresh 256-bit data encryption key for an environment. */
384
+ function generateDek() {
385
+ return crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
489
386
  }
490
- async function createServiceToken() {
491
- const { publicKeyJwk, privateKeyJwk } = await generateKeyPair();
492
- const pkcs8 = await exportPrivateKeyPkcs8(await importPrivateKey(privateKeyJwk));
493
- const tokenId = randomTokenId();
494
- const token = `${tokenId}_${toBase64Url(pkcs8)}`;
495
- return {
496
- token,
497
- tokenId,
498
- tokenHash: await hashToken(token),
499
- publicKeyJwk
500
- };
387
+ async function importDek(dek, usage) {
388
+ return crypto.subtle.importKey("raw", dek, { name: "AES-GCM" }, false, [usage]);
501
389
  }
502
- async function parseServiceToken(token) {
503
- const match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(token);
504
- if (!match) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit service token");
505
- const [, tokenId, keyB64] = match;
390
+ /**
391
+ * Encrypt a secret value with the environment DEK.
392
+ *
393
+ * @param aad Authenticated context binding the ciphertext to its location
394
+ * (e.g. `environmentId/SECRET_NAME`) so blobs cannot be swapped between
395
+ * secrets or environments without detection.
396
+ */
397
+ async function encryptSecret(dek, plaintext, aad) {
398
+ const key = await importDek(dek, "encrypt");
399
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
400
+ const ciphertext = await crypto.subtle.encrypt({
401
+ name: "AES-GCM",
402
+ iv,
403
+ additionalData: utf8Encode(aad)
404
+ }, key, utf8Encode(plaintext));
405
+ return `${SECRET_PREFIX}.${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
406
+ }
407
+ async function decryptSecret(dek, blob, aad) {
408
+ const [ivB64, ctB64] = splitBlob(blob, SECRET_PREFIX, 2);
409
+ const key = await importDek(dek, "decrypt");
506
410
  try {
507
- return {
508
- tokenId,
509
- privateKey: await importPrivateKeyPkcs8(fromBase64Url(keyB64))
510
- };
411
+ const plaintext = await crypto.subtle.decrypt({
412
+ name: "AES-GCM",
413
+ iv: fromBase64Url(ivB64),
414
+ additionalData: utf8Encode(aad)
415
+ }, key, fromBase64Url(ctB64));
416
+ return utf8Decode(new Uint8Array(plaintext));
511
417
  } catch {
512
- throw new SeekritCryptoError("MALFORMED_TOKEN", "service token private key is corrupted");
418
+ throw new SeekritCryptoError("DECRYPT_FAILED", "secret decryption failed: wrong key, tampered data, or mismatched context");
513
419
  }
514
420
  }
515
- function isServiceToken(value) {
516
- return value.startsWith(`${TOKEN_PREFIX}_`);
421
+ /** AAD binding a secret ciphertext to its environment + name. */
422
+ function secretAad(environmentId, secretName) {
423
+ return `${environmentId}/${secretName}`;
517
424
  }
518
425
  //#endregion
519
- //#region ../../packages/crypto/src/wrap.ts
426
+ //#region ../../packages/crypto/src/keys.ts
427
+ async function generateKeyPair() {
428
+ const pair = await crypto.subtle.generateKey({
429
+ name: "ECDH",
430
+ namedCurve: "P-256"
431
+ }, true, ["deriveBits"]);
432
+ const [publicJwk, privateJwk] = await Promise.all([crypto.subtle.exportKey("jwk", pair.publicKey), crypto.subtle.exportKey("jwk", pair.privateKey)]);
433
+ return {
434
+ publicKeyJwk: JSON.stringify(publicJwk),
435
+ privateKeyJwk: JSON.stringify(privateJwk)
436
+ };
437
+ }
438
+ async function importPublicKey(publicKeyJwk) {
439
+ return crypto.subtle.importKey("jwk", JSON.parse(publicKeyJwk), {
440
+ name: "ECDH",
441
+ namedCurve: "P-256"
442
+ }, true, []);
443
+ }
444
+ async function importPrivateKey(privateKeyJwk) {
445
+ return crypto.subtle.importKey("jwk", JSON.parse(privateKeyJwk), {
446
+ name: "ECDH",
447
+ namedCurve: "P-256"
448
+ }, true, ["deriveBits"]);
449
+ }
450
+ async function exportPrivateKeyPkcs8(key) {
451
+ return new Uint8Array(await crypto.subtle.exportKey("pkcs8", key));
452
+ }
453
+ async function importPrivateKeyPkcs8(pkcs8) {
454
+ return crypto.subtle.importKey("pkcs8", pkcs8, {
455
+ name: "ECDH",
456
+ namedCurve: "P-256"
457
+ }, true, ["deriveBits"]);
458
+ }
459
+ //#endregion
460
+ //#region ../../packages/crypto/src/mysql.ts
520
461
  /**
521
- * ECIES-style key wrapping: an ephemeral P-256 keypair performs ECDH against
522
- * the recipient's public key; the shared secret is run through HKDF-SHA256 to
523
- * derive a one-time AES-256-GCM wrapping key. Only the holder of the
524
- * recipient private key can unwrap.
462
+ * Client-side construction of a MySQL/MariaDB `mysql_native_password`
463
+ * authentication string, for minting *temporary MySQL login credentials*
464
+ * without the password plaintext ever reaching seekrit's control plane OR
465
+ * MySQL itself.
525
466
  *
526
- * Blob format: `wd1.<ephemeral pub (raw)>.<hkdf salt>.<iv>.<ciphertext>`
467
+ * The trick mirrors the Postgres SCRAM one (scram.ts): the stored auth string
468
+ * is `*<UPPER(HEX(SHA1(SHA1(password))))>`, and
469
+ * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'` stores that
470
+ * string verbatim — MySQL does NOT re-hash it. So the flow is:
471
+ *
472
+ * 1. the machine that will connect generates a random password locally,
473
+ * 2. computes this hash locally,
474
+ * 3. sends only the hash to the broker → `CREATE USER … AS '<hash>'`,
475
+ * 4. connects directly to MySQL with the plaintext it never shared.
476
+ *
477
+ * Zero-knowledge at both layers: the control plane relays only the hash, and
478
+ * the hash is NOT sufficient to authenticate. `mysql_native_password` login is
479
+ * a challenge-response — the server proves knowledge of `SHA1(SHA1(password))`
480
+ * against a fresh scramble, and verifying a client requires `SHA1(password)`
481
+ * (the preimage of the first inner hash), which the stored double-SHA1 does not
482
+ * reveal. A dump of `mysql.user` / the query log therefore cannot log in.
483
+ *
484
+ * SHA-1 is available via WebCrypto (`crypto.subtle.digest("SHA-1", …)`) in the
485
+ * browser, the CLI, the MCP server, and Workers — so this runs everywhere the
486
+ * SCRAM helper does, with no hand-rolled hash primitive.
527
487
  */
528
- const WRAP_PREFIX = "wd1";
529
- const HKDF_INFO = "seekrit/wrap-dek/v1";
530
- async function deriveWrappingKey(ownPrivateKey, peerPublicKey, salt, usage) {
531
- const ecdh = {
532
- name: "ECDH",
533
- public: peerPublicKey
488
+ const DEFAULT_PASSWORD_LENGTH$1 = 32;
489
+ const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
490
+ async function sha1(data) {
491
+ return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
492
+ }
493
+ function toUpperHex(bytes) {
494
+ let hex = "";
495
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
496
+ return hex.toUpperCase();
497
+ }
498
+ function randomPassword$1(length) {
499
+ let out = "";
500
+ while (out.length < length) {
501
+ const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
502
+ for (const byte of bytes) {
503
+ if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
504
+ if (out.length === length) break;
505
+ }
506
+ }
507
+ return out;
508
+ }
509
+ /**
510
+ * Compute the `mysql_native_password` auth string `*<UPPER(HEX(SHA1(SHA1(pw))))>`
511
+ * for a known password. Pass the result straight to
512
+ * `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`.
513
+ */
514
+ async function mysqlNativePasswordVerifier(password) {
515
+ return `*${toUpperHex(await sha1(await sha1(utf8Encode(password))))}`;
516
+ }
517
+ /**
518
+ * Mint a fresh random password and its `mysql_native_password` hash in one step
519
+ * — the client-side half of a Vault-style dynamic MySQL credential.
520
+ */
521
+ async function generateMysqlCredential(options = {}) {
522
+ const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
523
+ return {
524
+ password,
525
+ verifier: await mysqlNativePasswordVerifier(password)
534
526
  };
535
- const sharedBits = await crypto.subtle.deriveBits(ecdh, ownPrivateKey, 256);
536
- const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
527
+ }
528
+ //#endregion
529
+ //#region ../../packages/crypto/src/passphrase.ts
530
+ /**
531
+ * User private keys are stored server-side encrypted under a key derived from
532
+ * the user's passphrase, so any browser or CLI session can fetch and unlock
533
+ * them without the server ever seeing the passphrase or plaintext key.
534
+ *
535
+ * KDF is PBKDF2-HMAC-SHA256 (WebCrypto-native everywhere). The blob embeds
536
+ * its own salt + iteration count for future agility; bumping ITERATIONS only
537
+ * affects newly written blobs. TODO: revisit Argon2id via WASM later.
538
+ *
539
+ * Blob format: `pk1.<iterations>.<salt>.<iv>.<ciphertext>`
540
+ */
541
+ const PK_PREFIX = "pk1";
542
+ /** OWASP-recommended minimum for PBKDF2-HMAC-SHA256. */
543
+ const PBKDF2_ITERATIONS = 6e5;
544
+ async function deriveKek(passphrase, salt, iterations, usage) {
545
+ const material = await crypto.subtle.importKey("raw", utf8Encode(passphrase), "PBKDF2", false, ["deriveKey"]);
537
546
  return crypto.subtle.deriveKey({
538
- name: "HKDF",
547
+ name: "PBKDF2",
539
548
  hash: "SHA-256",
540
549
  salt,
541
- info: utf8Encode(HKDF_INFO)
542
- }, hkdfKey, {
550
+ iterations
551
+ }, material, {
543
552
  name: "AES-GCM",
544
553
  length: 256
545
554
  }, false, [usage]);
546
555
  }
547
- /** Wrap an environment DEK to a principal's public key. */
548
- async function wrapDek(dek, recipientPublicKeyJwk) {
549
- const recipientKey = await importPublicKey(recipientPublicKeyJwk);
550
- const ephemeral = await crypto.subtle.generateKey({
551
- name: "ECDH",
552
- namedCurve: "P-256"
553
- }, true, ["deriveBits"]);
556
+ async function encryptPrivateKey(passphrase, privateKeyJwk) {
554
557
  const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
555
- const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, recipientKey, salt, "encrypt");
556
558
  const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
559
+ const kek = await deriveKek(passphrase, salt, PBKDF2_ITERATIONS, "encrypt");
557
560
  const ciphertext = await crypto.subtle.encrypt({
558
561
  name: "AES-GCM",
559
562
  iv
560
- }, wrappingKey, dek);
561
- const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
563
+ }, kek, utf8Encode(privateKeyJwk));
562
564
  return [
563
- WRAP_PREFIX,
564
- toBase64Url(ephemeralRaw),
565
+ PK_PREFIX,
566
+ String(PBKDF2_ITERATIONS),
565
567
  toBase64Url(salt),
566
568
  toBase64Url(iv),
567
569
  toBase64Url(new Uint8Array(ciphertext))
568
570
  ].join(".");
569
571
  }
570
- /** Unwrap an environment DEK with the principal's private ECDH key. */
571
- async function unwrapDek(wrapped, privateKey) {
572
- const [ephB64, saltB64, ivB64, ctB64] = splitBlob(wrapped, WRAP_PREFIX, 4);
573
- const wrappingKey = await deriveWrappingKey(privateKey, await crypto.subtle.importKey("raw", fromBase64Url(ephB64), {
574
- name: "ECDH",
575
- namedCurve: "P-256"
576
- }, false, []), fromBase64Url(saltB64), "decrypt");
572
+ /** Wrong passphrase surfaces as SeekritCryptoError with code DECRYPT_FAILED. */
573
+ async function decryptPrivateKey(passphrase, blob) {
574
+ const [iterStr, saltB64, ivB64, ctB64] = splitBlob(blob, PK_PREFIX, 4);
575
+ const iterations = Number.parseInt(iterStr, 10);
576
+ if (!Number.isFinite(iterations) || iterations < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid PBKDF2 iteration count");
577
+ const kek = await deriveKek(passphrase, fromBase64Url(saltB64), iterations, "decrypt");
577
578
  try {
578
- const dek = await crypto.subtle.decrypt({
579
+ const plaintext = await crypto.subtle.decrypt({
579
580
  name: "AES-GCM",
580
581
  iv: fromBase64Url(ivB64)
581
- }, wrappingKey, fromBase64Url(ctB64));
582
- return new Uint8Array(dek);
582
+ }, kek, fromBase64Url(ctB64));
583
+ return utf8Decode(new Uint8Array(plaintext));
583
584
  } catch {
584
- throw new SeekritCryptoError("DECRYPT_FAILED", "DEK unwrap failed: wrong private key or tampered grant");
585
+ throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
585
586
  }
586
587
  }
587
- //#endregion
588
- //#region package.json
589
- var version = "0.10.0";
590
- const PROJECT_FILE = "seekrit.json";
591
- function globalConfigPath() {
592
- return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
588
+ const SALT_LENGTH = 16;
589
+ const DEFAULT_PASSWORD_LENGTH = 32;
590
+ const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
591
+ async function hmacSha256(key, message) {
592
+ const k = await crypto.subtle.importKey("raw", key, {
593
+ name: "HMAC",
594
+ hash: "SHA-256"
595
+ }, false, ["sign"]);
596
+ return new Uint8Array(await crypto.subtle.sign("HMAC", k, message));
593
597
  }
594
- function readGlobalConfig() {
595
- const path = globalConfigPath();
596
- if (!existsSync(path)) return {};
597
- return JSON.parse(readFileSync(path, "utf8"));
598
+ async function sha256(data) {
599
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
598
600
  }
599
- function writeGlobalConfig(update) {
600
- const path = globalConfigPath();
601
- const merged = {
602
- ...readGlobalConfig(),
603
- ...update
604
- };
605
- mkdirSync(dirname(path), { recursive: true });
606
- writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
601
+ async function saltPassword(password, salt, iterations) {
602
+ const material = await crypto.subtle.importKey("raw", utf8Encode(password), "PBKDF2", false, ["deriveBits"]);
603
+ const bits = await crypto.subtle.deriveBits({
604
+ name: "PBKDF2",
605
+ hash: "SHA-256",
606
+ salt,
607
+ iterations
608
+ }, material, 256);
609
+ return new Uint8Array(bits);
607
610
  }
608
- /** Walk up from cwd looking for seekrit.json. */
609
- function findProjectConfig(startDir = process.cwd()) {
610
- let dir = startDir;
611
- const { root } = parse(dir);
612
- while (true) {
613
- const candidate = join(dir, PROJECT_FILE);
614
- if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8"));
615
- if (dir === root) return null;
616
- dir = dirname(dir);
611
+ function randomPassword(length) {
612
+ let out = "";
613
+ while (out.length < length) {
614
+ const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
615
+ for (const byte of bytes) {
616
+ if (byte < 248) out += PASSWORD_ALPHABET[byte % 62];
617
+ if (out.length === length) break;
618
+ }
617
619
  }
620
+ return out;
618
621
  }
619
- function writeProjectConfig(config, dir = process.cwd()) {
620
- const path = join(dir, PROJECT_FILE);
621
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
622
- return path;
622
+ /**
623
+ * Compute the `SCRAM-SHA-256$<i>:<salt>$<StoredKey>:<ServerKey>` verifier for a
624
+ * known password. Pass the result straight to `CREATE ROLE … PASSWORD`.
625
+ */
626
+ async function scramSha256Verifier(password, options = {}) {
627
+ const salt = options.salt ?? crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
628
+ const iterations = options.iterations ?? 4096;
629
+ const saltedPassword = await saltPassword(password, salt, iterations);
630
+ const storedKey = await sha256(await hmacSha256(saltedPassword, utf8Encode("Client Key")));
631
+ const serverKey = await hmacSha256(saltedPassword, utf8Encode("Server Key"));
632
+ return `SCRAM-SHA-256$${iterations}:${toBase64(salt)}$${toBase64(storedKey)}:${toBase64(serverKey)}`;
633
+ }
634
+ /**
635
+ * Mint a fresh random password and its SCRAM verifier in one step — the
636
+ * client-side half of a Vault-style dynamic Postgres credential.
637
+ */
638
+ async function generatePostgresCredential(options = {}) {
639
+ const password = randomPassword(options.length ?? DEFAULT_PASSWORD_LENGTH);
640
+ const iterations = options.iterations ?? 4096;
641
+ return {
642
+ password,
643
+ verifier: await scramSha256Verifier(password, { iterations }),
644
+ iterations
645
+ };
623
646
  }
624
647
  //#endregion
625
- //#region ../../packages/api-client/src/index.ts
626
- var SeekritApiError = class extends Error {
627
- status;
628
- code;
629
- constructor(status, code, message) {
630
- super(message);
631
- this.name = "SeekritApiError";
632
- this.status = status;
633
- this.code = code;
634
- }
635
- };
636
- var SeekritClient = class {
637
- baseUrl;
638
- auth;
639
- fetchImpl;
640
- constructor(options) {
641
- this.baseUrl = options.baseUrl.replace(/\/$/, "");
642
- this.auth = options.auth;
643
- this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
644
- }
645
- async request(method, path, body) {
646
- const headers = { accept: "application/json" };
647
- if (this.auth.type === "bearer") headers.authorization = `Bearer ${this.auth.token}`;
648
- else if (this.auth.type === "dynamic") {
649
- const token = await this.auth.getToken();
650
- if (!token) throw new SeekritApiError(401, "unauthorized", "session expired");
651
- headers.authorization = `Bearer ${token}`;
652
- } else headers["x-seekrit-dev-user"] = this.auth.email;
653
- if (body !== void 0) headers["content-type"] = "application/json";
654
- const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
655
- method,
656
- headers,
657
- body: body === void 0 ? void 0 : JSON.stringify(body)
658
- });
659
- if (!res.ok) {
660
- const fallback = { error: {
661
- code: "internal",
662
- message: `HTTP ${res.status}`
663
- } };
664
- const payload = await res.json().catch(() => fallback);
665
- throw new SeekritApiError(res.status, payload.error?.code ?? "internal", payload.error?.message ?? `HTTP ${res.status}`);
666
- }
667
- return await res.json();
668
- }
669
- me() {
670
- return this.request("GET", "/v1/me");
671
- }
672
- getMyKeys() {
673
- return this.request("GET", "/v1/me/keys");
674
- }
675
- setMyKeys(input) {
676
- return this.request("PUT", "/v1/me/keys", input);
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
- }
684
- listOrgs() {
685
- return this.request("GET", "/v1/orgs");
686
- }
687
- createOrg(input) {
688
- return this.request("POST", "/v1/orgs", input);
689
- }
690
- getOrg(orgId) {
691
- return this.request("GET", `/v1/orgs/${orgId}`);
692
- }
693
- listMembers(orgId) {
694
- return this.request("GET", `/v1/orgs/${orgId}/members`);
695
- }
696
- listApps(orgId) {
697
- return this.request("GET", `/v1/orgs/${orgId}/apps`);
698
- }
699
- createApp(orgId, input) {
700
- return this.request("POST", `/v1/orgs/${orgId}/apps`, input);
701
- }
702
- getApp(orgId, appId) {
703
- return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}`);
704
- }
705
- deleteApp(orgId, appId) {
706
- return this.request("DELETE", `/v1/orgs/${orgId}/apps/${appId}`);
707
- }
708
- listEnvs(orgId, appId) {
709
- return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/envs`);
710
- }
711
- createEnv(orgId, appId, input) {
712
- return this.request("POST", `/v1/orgs/${orgId}/apps/${appId}/envs`, input);
713
- }
714
- getEnv(orgId, envId) {
715
- return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}`);
716
- }
717
- deleteEnv(orgId, envId) {
718
- return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
648
+ //#region ../../packages/crypto/src/ssh.ts
649
+ /**
650
+ * Client-side SSH certificate authority for minting *temporary SSH access*
651
+ * without any private key ever reaching seekrit's control plane.
652
+ *
653
+ * The model (Vault-style, tier-1 verifier injection — see the `ZkTier` doc in
654
+ * @seekrit/core leases.ts):
655
+ *
656
+ * 1. the machine that will connect generates an ephemeral Ed25519 keypair
657
+ * locally ({@link generateSshKeyPair}),
658
+ * 2. it sends only the *public* key to the broker,
659
+ * 3. the broker signs a short-lived OpenSSH user *certificate* over that
660
+ * public key ({@link signSshUserCertificate}) using the CA private key,
661
+ * 4. the consumer connects with `ssh -i <key> -o CertificateFile=<cert>`; the
662
+ * host trusts the CA (`TrustedUserCAKeys`) and never needs the key on disk.
663
+ *
664
+ * Zero-knowledge end to end: the user private key exists only on the consumer,
665
+ * and a certificate is a public artifact (it authorizes but cannot authenticate
666
+ * without the matching private key). The only secret seekrit stores is the CA
667
+ * private key — wrapped to the broker DO's public key, decrypted transiently in
668
+ * the DO to sign (the same in-DO trade the Postgres admin credential makes).
669
+ *
670
+ * Everything here is WebCrypto Ed25519 + hand-rolled SSH wire encoding, so it
671
+ * runs unchanged in the browser, the CLI, and Workers. No Node-specific crypto.
672
+ */
673
+ const ED25519 = { name: "Ed25519" };
674
+ /**
675
+ * Serializer for the SSH binary wire types. `string` is a uint32 length prefix
676
+ * followed by that many bytes (a length-delimited byte blob, not text); ints
677
+ * are big-endian. This is the encoding used by public keys, certificates, and
678
+ * the OpenSSH private key container alike.
679
+ */
680
+ var SshWriter = class {
681
+ chunks = [];
682
+ len = 0;
683
+ bytes(b) {
684
+ this.chunks.push(b);
685
+ this.len += b.length;
686
+ return this;
719
687
  }
720
- listGroups(orgId) {
721
- return this.request("GET", `/v1/orgs/${orgId}/groups`);
688
+ byte(n) {
689
+ return this.bytes(new Uint8Array([n & 255]));
722
690
  }
723
- createGroup(orgId, input) {
724
- return this.request("POST", `/v1/orgs/${orgId}/groups`, input);
691
+ uint32(n) {
692
+ const b = /* @__PURE__ */ new Uint8Array(4);
693
+ new DataView(b.buffer).setUint32(0, n >>> 0, false);
694
+ return this.bytes(b);
725
695
  }
726
- getGroup(orgId, groupId) {
727
- return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}`);
696
+ uint64(n) {
697
+ const b = /* @__PURE__ */ new Uint8Array(8);
698
+ new DataView(b.buffer).setBigUint64(0, BigInt(n), false);
699
+ return this.bytes(b);
728
700
  }
729
- deleteGroup(orgId, groupId) {
730
- return this.request("DELETE", `/v1/orgs/${orgId}/groups/${groupId}`);
701
+ string(s) {
702
+ const b = typeof s === "string" ? utf8Encode(s) : s;
703
+ return this.uint32(b.length).bytes(b);
731
704
  }
732
- listGroupEnvs(orgId, groupId) {
733
- return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}/envs`);
705
+ build() {
706
+ const out = new Uint8Array(this.len);
707
+ let o = 0;
708
+ for (const c of this.chunks) {
709
+ out.set(c, o);
710
+ o += c.length;
711
+ }
712
+ return out;
734
713
  }
735
- createGroupEnv(orgId, groupId, input) {
736
- return this.request("POST", `/v1/orgs/${orgId}/groups/${groupId}/envs`, input);
714
+ };
715
+ function concat(...arrays) {
716
+ const total = arrays.reduce((n, a) => n + a.length, 0);
717
+ const out = new Uint8Array(total);
718
+ let o = 0;
719
+ for (const a of arrays) {
720
+ out.set(a, o);
721
+ o += a.length;
737
722
  }
738
- listEnvGroups(orgId, envId) {
739
- return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/groups`);
740
- }
741
- linkEnvGroup(orgId, envId, input) {
742
- return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/groups`, input);
743
- }
744
- unlinkEnvGroup(orgId, envId, groupId) {
745
- return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/groups/${groupId}`);
746
- }
747
- /** Ordered ciphertext layers + wrapped DEKs for the calling principal. */
748
- resolve(query = {}) {
749
- const params = new URLSearchParams();
750
- if (query.env) params.set("env", query.env);
751
- for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
752
- const qs = params.size > 0 ? `?${params}` : "";
753
- return this.request("GET", `/v1/resolve${qs}`);
754
- }
755
- listSecrets(orgId, envId) {
756
- return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets`);
757
- }
758
- setSecret(orgId, envId, name, ciphertext) {
759
- return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext });
760
- }
761
- deleteSecret(orgId, envId, name) {
762
- return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
763
- }
764
- /** The calling principal's wrapped DEK for this environment. */
765
- getMyEnvKey(orgId, envId) {
766
- return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key`);
767
- }
768
- listEnvKeys(orgId, envId) {
769
- return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/keys`);
770
- }
771
- grantEnvKey(orgId, envId, input) {
772
- return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/keys`, input);
773
- }
774
- revokeEnvKey(orgId, envId, grantId) {
775
- return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/keys/${grantId}`);
776
- }
777
- listTokens(orgId) {
778
- return this.request("GET", `/v1/orgs/${orgId}/tokens`);
779
- }
780
- createToken(orgId, input) {
781
- return this.request("POST", `/v1/orgs/${orgId}/tokens`, input);
782
- }
783
- revokeToken(orgId, tokenId) {
784
- return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
785
- }
786
- /** The broker's public key — wrap the admin credential to it before registering a target. */
787
- getLeaseBrokerKey(orgId) {
788
- return this.request("GET", `/v1/orgs/${orgId}/leases/broker-key`);
789
- }
790
- listLeaseTargets(orgId) {
791
- return this.request("GET", `/v1/orgs/${orgId}/leases/targets`);
792
- }
793
- registerLeaseTarget(orgId, input) {
794
- return this.request("POST", `/v1/orgs/${orgId}/leases/targets`, input);
795
- }
796
- deleteLeaseTarget(orgId, targetId) {
797
- return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
798
- }
799
- listLeases(orgId) {
800
- return this.request("GET", `/v1/orgs/${orgId}/leases`);
801
- }
802
- mintLease(orgId, input) {
803
- return this.request("POST", `/v1/orgs/${orgId}/leases`, input);
804
- }
805
- revokeLease(orgId, leaseId) {
806
- return this.request("DELETE", `/v1/orgs/${orgId}/leases/${leaseId}`);
807
- }
808
- listAudit(orgId, query = {}) {
809
- const params = new URLSearchParams();
810
- if (query.cursor) params.set("cursor", query.cursor);
811
- if (query.limit) params.set("limit", String(query.limit));
812
- if (query.action) params.set("action", query.action);
813
- if (query.resourceType) params.set("resourceType", query.resourceType);
814
- const qs = params.size > 0 ? `?${params}` : "";
815
- return this.request("GET", `/v1/orgs/${orgId}/audit${qs}`);
816
- }
817
- getLogSink(orgId) {
818
- return this.request("GET", `/v1/orgs/${orgId}/log-sink`);
819
- }
820
- setLogSink(orgId, input) {
821
- return this.request("PUT", `/v1/orgs/${orgId}/log-sink`, input);
822
- }
823
- deleteLogSink(orgId) {
824
- return this.request("DELETE", `/v1/orgs/${orgId}/log-sink`);
825
- }
826
- /** Send a synthetic record to the configured endpoint to verify connectivity. */
827
- testLogSink(orgId) {
828
- return this.request("POST", `/v1/orgs/${orgId}/log-sink/test`);
829
- }
830
- };
831
- //#endregion
832
- //#region src/io.ts
833
- let failThrows = false;
834
- /**
835
- * In `seekrit mcp` the process is a long-lived stdio server, so a `fail()`
836
- * must surface as a catchable error (→ a tool error result) rather than
837
- * exiting and killing every other tool. Toggled on once at MCP startup.
838
- */
839
- function setFailThrows(value) {
840
- failThrows = value;
841
- }
842
- function fail(message) {
843
- if (failThrows) throw new Error(message);
844
- console.error(`error: ${message}`);
845
- process.exit(1);
723
+ return out;
846
724
  }
847
- /** Prompt without echoing input (for passphrases). */
848
- function promptHidden(question) {
849
- const muted = new Writable({ write(_chunk, _encoding, callback) {
850
- callback();
851
- } });
852
- process.stderr.write(question);
853
- const rl = createInterface({
854
- input: process.stdin,
855
- output: muted,
856
- terminal: true
857
- });
858
- return new Promise((resolve) => {
859
- rl.question("", (answer) => {
860
- rl.close();
861
- process.stderr.write("\n");
862
- resolve(answer);
863
- });
864
- });
725
+ /** The `ssh-ed25519` public-key blob: string "ssh-ed25519" ‖ string <32 bytes>. */
726
+ function ed25519PublicKeyBlob(pub) {
727
+ return new SshWriter().string("ssh-ed25519").string(pub).build();
865
728
  }
866
- /** Read all of stdin (for `seekrit secrets set NAME -` piping). */
867
- async function readStdin() {
868
- const chunks = [];
869
- for await (const chunk of process.stdin) chunks.push(chunk);
870
- return Buffer.concat(chunks).toString("utf8");
729
+ function encodeSshPublicKey(pub, comment) {
730
+ const b64 = toBase64(ed25519PublicKeyBlob(pub));
731
+ return comment ? `ssh-ed25519 ${b64} ${comment}` : `ssh-ed25519 ${b64}`;
871
732
  }
872
- //#endregion
873
- //#region src/context.ts
874
733
  /**
875
- * Build the client context from configured credentials, or return null when
876
- * none are set. `seekrit run` uses this to degrade to a plain launcher instead
877
- * of exiting; every other command goes through `buildContext`, which fails.
878
- *
879
- * `dotenvVars` supplies `SEEKRIT_*` values read from a `.env` file. They sit
880
- * below the live `process.env` but above the saved config, so a project-local
881
- * `.env` can carry the token / API URL — matching `seekrit-run`'s
882
- * `flag > env > .env` credential resolution. Empty for every command but
883
- * `seekrit run`, which loads `.env` before authenticating.
734
+ * Generate an ephemeral client keypair. The private key is serialized in the
735
+ * `openssh-key-v1` format so `ssh -i` accepts it directly; the public key is
736
+ * what gets certified. Nothing here is ever sent to the control plane except
737
+ * the public key.
884
738
  */
885
- function tryBuildContext(dotenvVars = {}) {
886
- const config = readGlobalConfig();
887
- const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
888
- const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
889
- const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
890
- const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
891
- let auth;
892
- if (token) auth = {
893
- type: "bearer",
894
- token
895
- };
896
- else if (devUser) auth = {
897
- type: "dev",
898
- email: devUser
899
- };
900
- else return null;
739
+ async function generateSshKeyPair(comment = "seekrit") {
740
+ const pair = await crypto.subtle.generateKey(ED25519, true, ["sign", "verify"]);
741
+ const pub = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
742
+ const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey));
743
+ const seed = pkcs8.subarray(pkcs8.length - 32);
901
744
  return {
902
- client: new SeekritClient({
903
- baseUrl: apiUrl,
904
- auth
905
- }),
906
- auth
745
+ publicKeyOpenssh: encodeSshPublicKey(pub, comment),
746
+ privateKeyOpenssh: encodeOpensshPrivateKey(seed, pub, comment)
907
747
  };
908
748
  }
909
- function buildContext() {
910
- const ctx = tryBuildContext();
911
- if (!ctx) fail("no credentials found run `seekrit login --token skt_…`, or set SEEKRIT_TOKEN / SEEKRIT_DEV_USER");
912
- return ctx;
749
+ /**
750
+ * Generate a certificate-authority keypair. The private half is exported as a
751
+ * JWK (so the broker can re-import it to sign); the public half is printed for
752
+ * admins to install on their hosts.
753
+ */
754
+ async function generateSshCaKeyPair(comment = "seekrit-ca") {
755
+ const pair = await crypto.subtle.generateKey(ED25519, true, ["sign", "verify"]);
756
+ const pub = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
757
+ const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
758
+ return {
759
+ privateKeyJwk: JSON.stringify(jwk),
760
+ publicKeyOpenssh: encodeSshPublicKey(pub, comment)
761
+ };
913
762
  }
914
- function isTokenAuth(ctx) {
915
- return ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token);
763
+ /**
764
+ * Serialize an Ed25519 keypair as an unencrypted `openssh-key-v1` private key.
765
+ * Format (PROTOCOL.key): magic ‖ ciphername "none" ‖ kdfname "none" ‖ empty
766
+ * kdfoptions ‖ nkeys=1 ‖ public-key blob ‖ private section (wrapped as a
767
+ * string). The private section is two equal check-ints, then the key, then the
768
+ * comment, padded with 1,2,3,… to the "none" block size (8).
769
+ */
770
+ function encodeOpensshPrivateKey(seed, pub, comment) {
771
+ const check = new DataView(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(4)).buffer).getUint32(0, false);
772
+ const privSection = new SshWriter().uint32(check).uint32(check).string("ssh-ed25519").string(pub).string(concat(seed, pub)).string(comment).build();
773
+ const padLen = (8 - privSection.length % 8) % 8;
774
+ const pad = new Uint8Array(padLen);
775
+ for (let i = 0; i < padLen; i++) pad[i] = i + 1;
776
+ 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());
777
+ return `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64.match(/.{1,70}/g)?.join("\n") ?? b64}\n-----END OPENSSH PRIVATE KEY-----\n`;
916
778
  }
779
+ //#endregion
780
+ //#region ../../packages/crypto/src/token.ts
917
781
  /**
918
- * Recover the calling principal's private key:
919
- * - service tokens carry their private key in the token string;
920
- * - users fetch their passphrase-encrypted key from the API and unlock it.
782
+ * Service tokens (CI, docker builds, agent proxies, k8s, …) are self-contained
783
+ * principals: the token string itself carries the private key, so the server
784
+ * never holds it. The server stores only the SHA-256 hash of the full token
785
+ * (for authentication) and the public key (for wrapping DEK grants).
786
+ *
787
+ * Format: `skt_<token id>_<private key pkcs8, base64url>`
921
788
  */
922
- async function getPrivateKey(ctx) {
923
- if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
924
- const { privateKey } = await parseServiceToken(ctx.auth.token);
925
- return privateKey;
789
+ const TOKEN_PREFIX = "skt";
790
+ const TOKEN_ID_LENGTH = 22;
791
+ const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
792
+ function randomTokenId() {
793
+ let out = "";
794
+ while (out.length < TOKEN_ID_LENGTH) {
795
+ const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
796
+ for (const byte of bytes) {
797
+ if (byte < 248) out += ID_ALPHABET[byte % 62];
798
+ if (out.length === TOKEN_ID_LENGTH) break;
799
+ }
926
800
  }
927
- const { encryptedPrivateKey } = await ctx.client.getMyKeys();
928
- return importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey));
929
- }
930
- /** Recover one environment's DEK for the current principal. */
931
- async function getDek(ctx, orgId, envId) {
932
- const [{ wrappedDek }, privateKey] = await Promise.all([ctx.client.getMyEnvKey(orgId, envId), getPrivateKey(ctx)]);
933
- return unwrapDek(wrappedDek, privateKey);
801
+ return `${TOKEN_PREFIX}_${out}`;
934
802
  }
935
- //#endregion
936
- //#region src/format.ts
937
- function needsQuoting(value) {
938
- return /[\s"'`$\\#]/.test(value) || value === "";
803
+ async function hashToken(token) {
804
+ const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
805
+ return toBase64Url(new Uint8Array(digest));
939
806
  }
940
- function dotenvQuote(value) {
941
- if (!needsQuoting(value)) return value;
942
- return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
943
- }
944
- function shellQuote(value) {
945
- return `'${value.replaceAll("'", `'\\''`)}'`;
807
+ async function createServiceToken() {
808
+ const { publicKeyJwk, privateKeyJwk } = await generateKeyPair();
809
+ const pkcs8 = await exportPrivateKeyPkcs8(await importPrivateKey(privateKeyJwk));
810
+ const tokenId = randomTokenId();
811
+ const token = `${tokenId}_${toBase64Url(pkcs8)}`;
812
+ return {
813
+ token,
814
+ tokenId,
815
+ tokenHash: await hashToken(token),
816
+ publicKeyJwk
817
+ };
946
818
  }
947
- function formatSecrets(values, format) {
948
- const names = Object.keys(values).sort();
949
- switch (format) {
950
- case "json": return JSON.stringify(values, names, 2);
951
- case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
952
- case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
819
+ async function parseServiceToken(token) {
820
+ const match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(token);
821
+ if (!match) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit service token");
822
+ const [, tokenId, keyB64] = match;
823
+ try {
824
+ return {
825
+ tokenId,
826
+ privateKey: await importPrivateKeyPkcs8(fromBase64Url(keyB64))
827
+ };
828
+ } catch {
829
+ throw new SeekritCryptoError("MALFORMED_TOKEN", "service token private key is corrupted");
953
830
  }
954
831
  }
832
+ function isServiceToken(value) {
833
+ return value.startsWith(`${TOKEN_PREFIX}_`);
834
+ }
955
835
  //#endregion
956
- //#region src/provisioner.ts
836
+ //#region ../../packages/crypto/src/wrap.ts
957
837
  /**
958
- * `seekrit provisioner` helpers for the self-hosted **remote executor**
959
- * (`seekrit-provisioner`), the daemon that runs a target's provisioning SQL
960
- * inside the customer's own network so the control plane never sees the database
961
- * admin credential.
838
+ * ECIES-style key wrapping: an ephemeral P-256 keypair performs ECDH against
839
+ * the recipient's public key; the shared secret is run through HKDF-SHA256 to
840
+ * derive a one-time AES-256-GCM wrapping key. Only the holder of the
841
+ * recipient private key can unwrap.
962
842
  *
963
- * The only stateful command is `keygen`: the shared HMAC key authenticates the
964
- * signed commands the broker sends the daemon. The same base64 value is given to
965
- * BOTH `--hmac-key` when registering a remote target AND the daemon's
966
- * `SEEKRIT_PROVISIONER_HMAC_KEY`.
843
+ * Blob format: `wd1.<ephemeral pub (raw)>.<hkdf salt>.<iv>.<ciphertext>`
967
844
  */
968
- function registerProvisionerCommands(program) {
969
- program.command("provisioner").description("self-hosted remote executor (seekrit-provisioner) helpers").command("keygen").description("generate a shared HMAC key for a remote provisioning target").action(() => {
970
- const key = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
971
- console.log(toBase64(key));
972
- console.error("Give this key to BOTH `--hmac-key` when registering a remote target AND the\ndaemon's SEEKRIT_PROVISIONER_HMAC_KEY. Keep it secret; it authenticates the\nbroker's commands to your provisioner.");
973
- });
845
+ const WRAP_PREFIX = "wd1";
846
+ const HKDF_INFO = "seekrit/wrap-dek/v1";
847
+ async function deriveWrappingKey(ownPrivateKey, peerPublicKey, salt, usage) {
848
+ const ecdh = {
849
+ name: "ECDH",
850
+ public: peerPublicKey
851
+ };
852
+ const sharedBits = await crypto.subtle.deriveBits(ecdh, ownPrivateKey, 256);
853
+ const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
854
+ return crypto.subtle.deriveKey({
855
+ name: "HKDF",
856
+ hash: "SHA-256",
857
+ salt,
858
+ info: utf8Encode(HKDF_INFO)
859
+ }, hkdfKey, {
860
+ name: "AES-GCM",
861
+ length: 256
862
+ }, false, [usage]);
974
863
  }
975
- /**
976
- * Resolve the "admin secret" a lease target registration wraps to the broker.
977
- * Its meaning depends on the executor:
978
- *
979
- * - **remote** — the shared HMAC key (base64). The real database admin
980
- * credential is NOT sent to seekrit; it is configured on the daemon instead.
981
- * Preferred source is `--hmac-key` / `SEEKRIT_PROVISIONER_HMAC_KEY`, with the
982
- * older `--admin-url` / provider admin-url env kept as a fallback.
983
- * - **in_do** the database admin connection string, which the broker decrypts
984
- * transiently to run the SQL itself.
985
- *
986
- * `fail()` never returns, so the result is always a non-empty string.
987
- */
988
- function resolveLeaseAdminSecret(opts) {
989
- if (opts.executor === "remote") {
990
- const key = opts.hmacKey ?? process.env.SEEKRIT_PROVISIONER_HMAC_KEY ?? opts.adminUrl ?? process.env[opts.adminUrlEnv];
991
- if (!key) fail("the remote executor needs the shared HMAC key — pass --hmac-key or set SEEKRIT_PROVISIONER_HMAC_KEY (mint one with `seekrit provisioner keygen`)");
992
- return key.trim();
864
+ /** Wrap an environment DEK to a principal's public key. */
865
+ async function wrapDek(dek, recipientPublicKeyJwk) {
866
+ const recipientKey = await importPublicKey(recipientPublicKeyJwk);
867
+ const ephemeral = await crypto.subtle.generateKey({
868
+ name: "ECDH",
869
+ namedCurve: "P-256"
870
+ }, true, ["deriveBits"]);
871
+ const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
872
+ const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, recipientKey, salt, "encrypt");
873
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
874
+ const ciphertext = await crypto.subtle.encrypt({
875
+ name: "AES-GCM",
876
+ iv
877
+ }, wrappingKey, dek);
878
+ const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
879
+ return [
880
+ WRAP_PREFIX,
881
+ toBase64Url(ephemeralRaw),
882
+ toBase64Url(salt),
883
+ toBase64Url(iv),
884
+ toBase64Url(new Uint8Array(ciphertext))
885
+ ].join(".");
886
+ }
887
+ /** Unwrap an environment DEK with the principal's private ECDH key. */
888
+ async function unwrapDek(wrapped, privateKey) {
889
+ const [ephB64, saltB64, ivB64, ctB64] = splitBlob(wrapped, WRAP_PREFIX, 4);
890
+ const wrappingKey = await deriveWrappingKey(privateKey, await crypto.subtle.importKey("raw", fromBase64Url(ephB64), {
891
+ name: "ECDH",
892
+ namedCurve: "P-256"
893
+ }, false, []), fromBase64Url(saltB64), "decrypt");
894
+ try {
895
+ const dek = await crypto.subtle.decrypt({
896
+ name: "AES-GCM",
897
+ iv: fromBase64Url(ivB64)
898
+ }, wrappingKey, fromBase64Url(ctB64));
899
+ return new Uint8Array(dek);
900
+ } catch {
901
+ throw new SeekritCryptoError("DECRYPT_FAILED", "DEK unwrap failed: wrong private key or tampered grant");
993
902
  }
994
- const adminUrl = opts.adminUrl ?? process.env[opts.adminUrlEnv];
995
- if (!adminUrl) fail(`provide the admin connection string via --admin-url or ${opts.adminUrlEnv}`);
996
- return adminUrl;
997
903
  }
998
904
  //#endregion
999
- //#region src/target.ts
1000
- /** Resolve the target org from a flag, the committed config, or a lone org. */
1001
- async function resolveOrg(ctx, orgSlug) {
1002
- const wanted = orgSlug ?? findProjectConfig()?.org;
1003
- const { orgs } = await ctx.client.listOrgs();
1004
- if (wanted) {
1005
- const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
1006
- if (!org) fail(`no accessible org "${wanted}"`);
1007
- return {
1008
- id: org.id,
1009
- slug: org.slug
1010
- };
1011
- }
1012
- const only = orgs[0];
1013
- if (orgs.length === 1 && only) return {
1014
- id: only.id,
1015
- slug: only.slug
1016
- };
1017
- fail("specify --org (or run `seekrit init`)");
905
+ //#region package.json
906
+ var version = "0.12.0";
907
+ const PROJECT_FILE = "seekrit.json";
908
+ function globalConfigPath() {
909
+ return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
1018
910
  }
1019
- /**
1020
- * Resolve an environment to operate on — an application env (`--app --env`,
1021
- * or the config's app + `--env`) or a group env (`--group --env`).
1022
- */
1023
- async function resolveEnvTarget(ctx, opts) {
1024
- const org = await resolveOrg(ctx, opts.org);
1025
- if (!opts.env) fail("specify --env");
1026
- if (opts.group) {
1027
- const { groups } = await ctx.client.listGroups(org.id);
1028
- const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1029
- if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1030
- const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
1031
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1032
- if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
1033
- return {
1034
- orgId: org.id,
1035
- envId: env.id,
1036
- label: `${group.slug}@${env.slug}`
1037
- };
1038
- }
1039
- const appSlug = opts.app ?? findProjectConfig()?.app;
1040
- if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
1041
- const { apps } = await ctx.client.listApps(org.id);
1042
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1043
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1044
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
1045
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1046
- if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1047
- return {
1048
- orgId: org.id,
1049
- envId: env.id,
1050
- label: `${app.slug}/${env.slug}`
1051
- };
911
+ function readGlobalConfig() {
912
+ const path = globalConfigPath();
913
+ if (!existsSync(path)) return {};
914
+ return JSON.parse(readFileSync(path, "utf8"));
1052
915
  }
1053
- /** Resolve an application environment, keeping ids + slugs (for token binding). */
1054
- async function resolveAppEnv(ctx, opts) {
1055
- const org = await resolveOrg(ctx, opts.org);
1056
- const appSlug = opts.app ?? findProjectConfig()?.app;
1057
- if (!appSlug) fail("specify --app (or run `seekrit init`)");
1058
- if (!opts.env) fail("specify --env");
1059
- const { apps } = await ctx.client.listApps(org.id);
1060
- const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1061
- if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1062
- const { environments } = await ctx.client.listEnvs(org.id, app.id);
1063
- const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1064
- if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1065
- return {
1066
- orgId: org.id,
1067
- appId: app.id,
1068
- appSlug: app.slug,
1069
- envId: env.id,
1070
- envSlug: env.slug
916
+ function writeGlobalConfig(update) {
917
+ const path = globalConfigPath();
918
+ const merged = {
919
+ ...readGlobalConfig(),
920
+ ...update
1071
921
  };
922
+ mkdirSync(dirname(path), { recursive: true });
923
+ writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
1072
924
  }
1073
- /** Resolve a group by slug within the target org. */
1074
- async function resolveGroup(ctx, opts) {
1075
- const org = await resolveOrg(ctx, opts.org);
1076
- if (!opts.group) fail("specify --group");
1077
- const { groups } = await ctx.client.listGroups(org.id);
1078
- const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1079
- if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1080
- return {
1081
- orgId: org.id,
1082
- id: group.id,
1083
- slug: group.slug
1084
- };
925
+ /** Walk up from cwd looking for seekrit.json. */
926
+ function findProjectConfig(startDir = process.cwd()) {
927
+ let dir = startDir;
928
+ const { root } = parse(dir);
929
+ while (true) {
930
+ const candidate = join(dir, PROJECT_FILE);
931
+ if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8"));
932
+ if (dir === root) return null;
933
+ dir = dirname(dir);
934
+ }
935
+ }
936
+ function writeProjectConfig(config, dir = process.cwd()) {
937
+ const path = join(dir, PROJECT_FILE);
938
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
939
+ return path;
1085
940
  }
1086
941
  //#endregion
1087
- //#region src/mysql.ts
1088
- /**
1089
- * `seekrit mysql` — temporary MySQL/MariaDB credentials (Vault-style dynamic
1090
- * secrets).
1091
- *
1092
- * Zero-knowledge: minting generates the password and its
1093
- * `mysql_native_password` hash on THIS machine and sends only the hash; the
1094
- * plaintext password never reaches the API or gets stored. Registering a target
1095
- * wraps the admin connection string to the broker's public key locally, so the
1096
- * control plane only ever stores ciphertext.
1097
- */
1098
- /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1099
- function parseTtlSeconds$2(input) {
1100
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1101
- if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1102
- return Number(m[1]) * ({
1103
- s: 1,
1104
- m: 60,
1105
- h: 3600,
1106
- d: 86400
1107
- }[m[2] || "s"] ?? 1);
1108
- }
1109
- /** A fresh, valid MySQL user name: `tmp_` + lowercase alphanumerics. */
1110
- function generateUserName(prefix = "tmp") {
1111
- const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
1112
- let out = "";
1113
- const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
1114
- for (const b of bytes) out += alphabet[b % 36];
1115
- return `${prefix}_${out}`;
1116
- }
1117
- function registerMysqlCommands(program) {
1118
- const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
1119
- 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) => {
1121
- const ctx = buildContext();
1122
- const org = await resolveOrg(ctx, options.org);
1123
- const executor = options.executor === "remote" ? "remote" : "in_do";
1124
- if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
1125
- if (![
1126
- "readonly",
1127
- "readwrite",
1128
- "custom"
1129
- ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
1130
- const accessLevel = options.access;
1131
- const adminSecret = resolveLeaseAdminSecret({
1132
- executor,
1133
- hmacKey: options.hmacKey,
1134
- adminUrl: options.adminUrl,
1135
- adminUrlEnv: "SEEKRIT_MYSQL_ADMIN_URL"
1136
- });
1137
- const config = {
1138
- provider: "mysql",
1139
- executor,
1140
- accessLevel,
1141
- connection: {
1142
- host: options.host,
1143
- port: Number.parseInt(options.port, 10),
1144
- database: options.database
1145
- },
1146
- userHost: options.userHost,
1147
- ...accessLevel === "custom" ? {
1148
- ...options.createStatement.length ? { createStatements: options.createStatement } : {},
1149
- ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
1150
- } : {},
1151
- ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
1152
- };
1153
- const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
1154
- const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
1155
- const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
1156
- name: options.name,
1157
- config,
1158
- wrappedAdminSecret
942
+ //#region ../../packages/api-client/src/index.ts
943
+ var SeekritApiError = class extends Error {
944
+ status;
945
+ code;
946
+ constructor(status, code, message) {
947
+ super(message);
948
+ this.name = "SeekritApiError";
949
+ this.status = status;
950
+ this.code = code;
951
+ }
952
+ };
953
+ var SeekritClient = class {
954
+ baseUrl;
955
+ auth;
956
+ fetchImpl;
957
+ constructor(options) {
958
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
959
+ this.auth = options.auth;
960
+ this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
961
+ }
962
+ async request(method, path, body) {
963
+ const headers = { accept: "application/json" };
964
+ if (this.auth.type === "bearer") headers.authorization = `Bearer ${this.auth.token}`;
965
+ else if (this.auth.type === "dynamic") {
966
+ const token = await this.auth.getToken();
967
+ if (!token) throw new SeekritApiError(401, "unauthorized", "session expired");
968
+ headers.authorization = `Bearer ${token}`;
969
+ } else headers["x-seekrit-dev-user"] = this.auth.email;
970
+ if (body !== void 0) headers["content-type"] = "application/json";
971
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
972
+ method,
973
+ headers,
974
+ body: body === void 0 ? void 0 : JSON.stringify(body)
1159
975
  });
1160
- console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
1161
- });
1162
- target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
1163
- const ctx = buildContext();
1164
- const org = await resolveOrg(ctx, options.org);
1165
- const { targets } = await ctx.client.listLeaseTargets(org.id);
1166
- for (const t of targets) {
1167
- if (t.provider !== "mysql") continue;
1168
- const cfg = t.config;
1169
- console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
976
+ if (!res.ok) {
977
+ const fallback = { error: {
978
+ code: "internal",
979
+ message: `HTTP ${res.status}`
980
+ } };
981
+ const payload = await res.json().catch(() => fallback);
982
+ throw new SeekritApiError(res.status, payload.error?.code ?? "internal", payload.error?.message ?? `HTTP ${res.status}`);
1170
983
  }
984
+ return await res.json();
985
+ }
986
+ me() {
987
+ return this.request("GET", "/v1/me");
988
+ }
989
+ getMyKeys() {
990
+ return this.request("GET", "/v1/me/keys");
991
+ }
992
+ setMyKeys(input) {
993
+ return this.request("PUT", "/v1/me/keys", input);
994
+ }
995
+ getMyNotificationPrefs() {
996
+ return this.request("GET", "/v1/me/notifications");
997
+ }
998
+ setMyNotificationPrefs(input) {
999
+ return this.request("PUT", "/v1/me/notifications", input);
1000
+ }
1001
+ listOrgs() {
1002
+ return this.request("GET", "/v1/orgs");
1003
+ }
1004
+ createOrg(input) {
1005
+ return this.request("POST", "/v1/orgs", input);
1006
+ }
1007
+ getOrg(orgId) {
1008
+ return this.request("GET", `/v1/orgs/${orgId}`);
1009
+ }
1010
+ listMembers(orgId) {
1011
+ return this.request("GET", `/v1/orgs/${orgId}/members`);
1012
+ }
1013
+ listApps(orgId) {
1014
+ return this.request("GET", `/v1/orgs/${orgId}/apps`);
1015
+ }
1016
+ createApp(orgId, input) {
1017
+ return this.request("POST", `/v1/orgs/${orgId}/apps`, input);
1018
+ }
1019
+ getApp(orgId, appId) {
1020
+ return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}`);
1021
+ }
1022
+ deleteApp(orgId, appId) {
1023
+ return this.request("DELETE", `/v1/orgs/${orgId}/apps/${appId}`);
1024
+ }
1025
+ listEnvs(orgId, appId) {
1026
+ return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/envs`);
1027
+ }
1028
+ createEnv(orgId, appId, input) {
1029
+ return this.request("POST", `/v1/orgs/${orgId}/apps/${appId}/envs`, input);
1030
+ }
1031
+ getEnv(orgId, envId) {
1032
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}`);
1033
+ }
1034
+ deleteEnv(orgId, envId) {
1035
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
1036
+ }
1037
+ listGroups(orgId) {
1038
+ return this.request("GET", `/v1/orgs/${orgId}/groups`);
1039
+ }
1040
+ createGroup(orgId, input) {
1041
+ return this.request("POST", `/v1/orgs/${orgId}/groups`, input);
1042
+ }
1043
+ getGroup(orgId, groupId) {
1044
+ return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}`);
1045
+ }
1046
+ deleteGroup(orgId, groupId) {
1047
+ return this.request("DELETE", `/v1/orgs/${orgId}/groups/${groupId}`);
1048
+ }
1049
+ listGroupEnvs(orgId, groupId) {
1050
+ return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}/envs`);
1051
+ }
1052
+ createGroupEnv(orgId, groupId, input) {
1053
+ return this.request("POST", `/v1/orgs/${orgId}/groups/${groupId}/envs`, input);
1054
+ }
1055
+ listEnvGroups(orgId, envId) {
1056
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/groups`);
1057
+ }
1058
+ linkEnvGroup(orgId, envId, input) {
1059
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/groups`, input);
1060
+ }
1061
+ unlinkEnvGroup(orgId, envId, groupId) {
1062
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/groups/${groupId}`);
1063
+ }
1064
+ /** Ordered ciphertext layers + wrapped DEKs for the calling principal. */
1065
+ resolve(query = {}) {
1066
+ const params = new URLSearchParams();
1067
+ if (query.env) params.set("env", query.env);
1068
+ for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
1069
+ const qs = params.size > 0 ? `?${params}` : "";
1070
+ return this.request("GET", `/v1/resolve${qs}`);
1071
+ }
1072
+ listSecrets(orgId, envId) {
1073
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets`);
1074
+ }
1075
+ setSecret(orgId, envId, name, ciphertext) {
1076
+ return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext });
1077
+ }
1078
+ deleteSecret(orgId, envId, name) {
1079
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
1080
+ }
1081
+ /** The calling principal's wrapped DEK for this environment. */
1082
+ getMyEnvKey(orgId, envId) {
1083
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key`);
1084
+ }
1085
+ listEnvKeys(orgId, envId) {
1086
+ return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/keys`);
1087
+ }
1088
+ grantEnvKey(orgId, envId, input) {
1089
+ return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/keys`, input);
1090
+ }
1091
+ revokeEnvKey(orgId, envId, grantId) {
1092
+ return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/keys/${grantId}`);
1093
+ }
1094
+ listTokens(orgId) {
1095
+ return this.request("GET", `/v1/orgs/${orgId}/tokens`);
1096
+ }
1097
+ createToken(orgId, input) {
1098
+ return this.request("POST", `/v1/orgs/${orgId}/tokens`, input);
1099
+ }
1100
+ revokeToken(orgId, tokenId) {
1101
+ return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
1102
+ }
1103
+ /** The broker's public key — wrap the admin credential to it before registering a target. */
1104
+ getLeaseBrokerKey(orgId) {
1105
+ return this.request("GET", `/v1/orgs/${orgId}/leases/broker-key`);
1106
+ }
1107
+ listLeaseTargets(orgId) {
1108
+ return this.request("GET", `/v1/orgs/${orgId}/leases/targets`);
1109
+ }
1110
+ registerLeaseTarget(orgId, input) {
1111
+ return this.request("POST", `/v1/orgs/${orgId}/leases/targets`, input);
1112
+ }
1113
+ deleteLeaseTarget(orgId, targetId) {
1114
+ return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
1115
+ }
1116
+ listLeases(orgId) {
1117
+ return this.request("GET", `/v1/orgs/${orgId}/leases`);
1118
+ }
1119
+ mintLease(orgId, input) {
1120
+ return this.request("POST", `/v1/orgs/${orgId}/leases`, input);
1121
+ }
1122
+ revokeLease(orgId, leaseId) {
1123
+ return this.request("DELETE", `/v1/orgs/${orgId}/leases/${leaseId}`);
1124
+ }
1125
+ listAudit(orgId, query = {}) {
1126
+ const params = new URLSearchParams();
1127
+ if (query.cursor) params.set("cursor", query.cursor);
1128
+ if (query.limit) params.set("limit", String(query.limit));
1129
+ if (query.action) params.set("action", query.action);
1130
+ if (query.resourceType) params.set("resourceType", query.resourceType);
1131
+ const qs = params.size > 0 ? `?${params}` : "";
1132
+ return this.request("GET", `/v1/orgs/${orgId}/audit${qs}`);
1133
+ }
1134
+ getLogSink(orgId) {
1135
+ return this.request("GET", `/v1/orgs/${orgId}/log-sink`);
1136
+ }
1137
+ setLogSink(orgId, input) {
1138
+ return this.request("PUT", `/v1/orgs/${orgId}/log-sink`, input);
1139
+ }
1140
+ deleteLogSink(orgId) {
1141
+ return this.request("DELETE", `/v1/orgs/${orgId}/log-sink`);
1142
+ }
1143
+ /** Send a synthetic record to the configured endpoint to verify connectivity. */
1144
+ testLogSink(orgId) {
1145
+ return this.request("POST", `/v1/orgs/${orgId}/log-sink/test`);
1146
+ }
1147
+ };
1148
+ //#endregion
1149
+ //#region src/io.ts
1150
+ let failThrows = false;
1151
+ /**
1152
+ * In `seekrit mcp` the process is a long-lived stdio server, so a `fail()`
1153
+ * must surface as a catchable error (→ a tool error result) rather than
1154
+ * exiting and killing every other tool. Toggled on once at MCP startup.
1155
+ */
1156
+ function setFailThrows(value) {
1157
+ failThrows = value;
1158
+ }
1159
+ function fail(message) {
1160
+ if (failThrows) throw new Error(message);
1161
+ console.error(`error: ${message}`);
1162
+ process.exit(1);
1163
+ }
1164
+ /** Prompt without echoing input (for passphrases). */
1165
+ function promptHidden(question) {
1166
+ const muted = new Writable({ write(_chunk, _encoding, callback) {
1167
+ callback();
1168
+ } });
1169
+ process.stderr.write(question);
1170
+ const rl = createInterface({
1171
+ input: process.stdin,
1172
+ output: muted,
1173
+ terminal: true
1171
1174
  });
1172
- target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
1173
- const ctx = buildContext();
1174
- const org = await resolveOrg(ctx, options.org);
1175
- await ctx.client.deleteLeaseTarget(org.id, targetId);
1176
- console.error(`removed ${targetId}`);
1177
- });
1178
- 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) => {
1179
- const ctx = buildContext();
1180
- const org = await resolveOrg(ctx, options.org);
1181
- const { targets } = await ctx.client.listLeaseTargets(org.id);
1182
- const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
1183
- if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1184
- 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);
1187
- const { password, verifier } = await generateMysqlCredential();
1188
- const { connection } = await ctx.client.mintLease(org.id, {
1189
- provider: "mysql",
1190
- targetId: target.id,
1191
- roleName: userName,
1192
- verifier,
1193
- ttlSeconds
1175
+ return new Promise((resolve) => {
1176
+ rl.question("", (answer) => {
1177
+ rl.close();
1178
+ process.stderr.write("\n");
1179
+ resolve(answer);
1194
1180
  });
1195
- const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
1196
- console.error(`leased ${userName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
1197
- if (options.json) console.log(JSON.stringify({
1198
- ...connection,
1199
- password,
1200
- url
1201
- }, null, 2));
1202
- else console.log(url);
1203
- });
1204
- mysql.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
1205
- const ctx = buildContext();
1206
- const org = await resolveOrg(ctx, options.org);
1207
- const { leases } = await ctx.client.listLeases(org.id);
1208
- for (const l of leases) {
1209
- if (l.provider !== "mysql") continue;
1210
- console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
1211
- }
1212
- });
1213
- mysql.command("revoke <leaseId>").description("revoke a lease now (drops the user immediately)").option("--org <slug>").action(async (leaseId, options) => {
1214
- const ctx = buildContext();
1215
- const org = await resolveOrg(ctx, options.org);
1216
- await ctx.client.revokeLease(org.id, leaseId);
1217
- console.error(`revoked ${leaseId}`);
1218
1181
  });
1219
1182
  }
1220
- /** Collect a repeatable option into an array. */
1221
- function collect$2(value, acc) {
1222
- acc.push(value);
1223
- return acc;
1183
+ /** Read all of stdin (for `seekrit secrets set NAME -` piping). */
1184
+ async function readStdin() {
1185
+ const chunks = [];
1186
+ for await (const chunk of process.stdin) chunks.push(chunk);
1187
+ return Buffer.concat(chunks).toString("utf8");
1224
1188
  }
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");
1189
+ //#endregion
1190
+ //#region src/context.ts
1243
1191
  /**
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.
1192
+ * Build the client context from configured credentials, or return null when
1193
+ * none are set. `seekrit run` uses this to degrade to a plain launcher instead
1194
+ * of exiting; every other command goes through `buildContext`, which fails.
1195
+ *
1196
+ * `dotenvVars` supplies `SEEKRIT_*` values read from a `.env` file. They sit
1197
+ * below the live `process.env` but above the saved config, so a project-local
1198
+ * `.env` can carry the token / API URL — matching `seekrit-run`'s
1199
+ * `flag > env > .env` credential resolution. Empty for every command but
1200
+ * `seekrit run`, which loads `.env` before authenticating.
1247
1201
  */
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}$/);
1202
+ function tryBuildContext(dotenvVars = {}) {
1203
+ const config = readGlobalConfig();
1204
+ const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
1205
+ const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
1206
+ const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
1207
+ const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
1208
+ let auth;
1209
+ if (token) auth = {
1210
+ type: "bearer",
1211
+ token
1212
+ };
1213
+ else if (devUser) auth = {
1214
+ type: "dev",
1215
+ email: devUser
1216
+ };
1217
+ else return null;
1218
+ return {
1219
+ client: new SeekritClient({
1220
+ baseUrl: apiUrl,
1221
+ auth
1222
+ }),
1223
+ auth
1224
+ };
1225
+ }
1226
+ function buildContext() {
1227
+ const ctx = tryBuildContext();
1228
+ if (!ctx) fail("no credentials found — run `seekrit login --token skt_…`, or set SEEKRIT_TOKEN / SEEKRIT_DEV_USER");
1229
+ return ctx;
1230
+ }
1231
+ function isTokenAuth(ctx) {
1232
+ return ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token);
1233
+ }
1253
1234
  /**
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.
1235
+ * Recover the calling principal's private key:
1236
+ * - service tokens carry their private key in the token string;
1237
+ * - users fetch their passphrase-encrypted key from the API and unlock it.
1257
1238
  */
1258
- const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
1239
+ async function getPrivateKey(ctx) {
1240
+ if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
1241
+ const { privateKey } = await parseServiceToken(ctx.auth.token);
1242
+ return privateKey;
1243
+ }
1244
+ const { encryptedPrivateKey } = await ctx.client.getMyKeys();
1245
+ return importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey));
1246
+ }
1247
+ /** Recover one environment's DEK for the current principal. */
1248
+ async function getDek(ctx, orgId, envId) {
1249
+ const [{ wrappedDek }, privateKey] = await Promise.all([ctx.client.getMyEnvKey(orgId, envId), getPrivateKey(ctx)]);
1250
+ return unwrapDek(wrappedDek, privateKey);
1251
+ }
1252
+ //#endregion
1253
+ //#region src/dotenv.ts
1259
1254
  /**
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.
1255
+ * Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
1256
+ * prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
1257
+ * escapes; unquoted values drop trailing ` # comments`). Multiline values are
1258
+ * not supported keep those in seekrit itself.
1265
1259
  */
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);
1260
+ function parseDotenv(content) {
1261
+ const out = {};
1262
+ for (const raw of content.split(/\r?\n/)) {
1263
+ let line = raw.trim();
1264
+ if (!line || line.startsWith("#")) continue;
1265
+ if (line.startsWith("export ")) line = line.slice(7).trimStart();
1266
+ const eq = line.indexOf("=");
1267
+ if (eq === -1) continue;
1268
+ const key = line.slice(0, eq).trim();
1269
+ if (!key) continue;
1270
+ let value = line.slice(eq + 1).trim();
1271
+ const quote = value[0];
1272
+ if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
1273
+ value = value.slice(1, -1);
1274
+ if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
1275
+ } else {
1276
+ const comment = value.indexOf(" #");
1277
+ if (comment !== -1) value = value.slice(0, comment).trim();
1278
+ }
1279
+ out[key] = value;
1280
+ }
1281
+ return out;
1282
+ }
1283
+ //#endregion
1284
+ //#region src/format.ts
1285
+ function needsQuoting(value) {
1286
+ return /[\s"'`$\\#]/.test(value) || value === "";
1287
+ }
1288
+ function dotenvQuote(value) {
1289
+ if (!needsQuoting(value)) return value;
1290
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
1291
+ }
1292
+ function shellQuote(value) {
1293
+ return `'${value.replaceAll("'", `'\\''`)}'`;
1294
+ }
1295
+ function formatSecrets(values, format) {
1296
+ const names = Object.keys(values).sort();
1297
+ switch (format) {
1298
+ case "json": return JSON.stringify(values, names, 2);
1299
+ case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
1300
+ case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
1301
+ }
1302
+ }
1303
+ //#endregion
1304
+ //#region src/provisioner.ts
1342
1305
  /**
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.
1306
+ * `seekrit provisioner` helpers for the self-hosted **remote executor**
1307
+ * (`seekrit-provisioner`), the daemon that runs a target's provisioning SQL
1308
+ * inside the customer's own network so the control plane never sees the database
1309
+ * admin credential.
1310
+ *
1311
+ * The only stateful command is `keygen`: the shared HMAC key authenticates the
1312
+ * signed commands the broker sends the daemon. The same base64 value is given to
1313
+ * BOTH `--hmac-key` when registering a remote target AND the daemon's
1314
+ * `SEEKRIT_PROVISIONER_HMAC_KEY`.
1346
1315
  */
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
- });
1316
+ function registerProvisionerCommands(program) {
1317
+ program.command("provisioner").description("self-hosted remote executor (seekrit-provisioner) helpers").command("keygen").description("generate a shared HMAC key for a remote provisioning target").action(() => {
1318
+ const key = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
1319
+ console.log(toBase64(key));
1320
+ console.error("Give this key to BOTH `--hmac-key` when registering a remote target AND the\ndaemon's SEEKRIT_PROVISIONER_HMAC_KEY. Keep it secret; it authenticates the\nbroker's commands to your provisioner.");
1321
+ });
1322
+ }
1354
1323
  /**
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.
1324
+ * Resolve the "admin secret" a lease target registration wraps to the broker.
1325
+ * Its meaning depends on the executor:
1326
+ *
1327
+ * - **remote** — the shared HMAC key (base64). The real database admin
1328
+ * credential is NOT sent to seekrit; it is configured on the daemon instead.
1329
+ * Preferred source is `--hmac-key` / `SEEKRIT_PROVISIONER_HMAC_KEY`, with the
1330
+ * older `--admin-url` / provider admin-url env kept as a fallback.
1331
+ * - **in_do** — the database admin connection string, which the broker decrypts
1332
+ * transiently to run the SQL itself.
1333
+ *
1334
+ * `fail()` never returns, so the result is always a non-empty string.
1358
1335
  */
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
- });
1336
+ function resolveLeaseAdminSecret(opts) {
1337
+ if (opts.executor === "remote") {
1338
+ const key = opts.hmacKey ?? process.env.SEEKRIT_PROVISIONER_HMAC_KEY ?? opts.adminUrl ?? process.env[opts.adminUrlEnv];
1339
+ if (!key) fail("the remote executor needs the shared HMAC key — pass --hmac-key or set SEEKRIT_PROVISIONER_HMAC_KEY (mint one with `seekrit provisioner keygen`)");
1340
+ return key.trim();
1341
+ }
1342
+ const adminUrl = opts.adminUrl ?? process.env[opts.adminUrlEnv];
1343
+ if (!adminUrl) fail(`provide the admin connection string via --admin-url or ${opts.adminUrlEnv}`);
1344
+ return adminUrl;
1345
+ }
1346
+ //#endregion
1347
+ //#region src/target.ts
1348
+ /** Resolve the target org from a flag, the committed config, or a lone org. */
1349
+ async function resolveOrg(ctx, orgSlug) {
1350
+ const wanted = orgSlug ?? findProjectConfig()?.org;
1351
+ const { orgs } = await ctx.client.listOrgs();
1352
+ if (wanted) {
1353
+ const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
1354
+ if (!org) fail(`no accessible org "${wanted}"`);
1355
+ return {
1356
+ id: org.id,
1357
+ slug: org.slug
1358
+ };
1359
+ }
1360
+ const only = orgs[0];
1361
+ if (orgs.length === 1 && only) return {
1362
+ id: only.id,
1363
+ slug: only.slug
1364
+ };
1365
+ fail("specify --org (or run `seekrit init`)");
1366
+ }
1366
1367
  /**
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.
1368
+ * Resolve an environment to operate on an application env (`--app --env`,
1369
+ * or the config's app + `--env`) or a group env (`--group --env`).
1370
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
- ]);
1371
+ async function resolveEnvTarget(ctx, opts) {
1372
+ const org = await resolveOrg(ctx, opts.org);
1373
+ if (!opts.env) fail("specify --env");
1374
+ if (opts.group) {
1375
+ const { groups } = await ctx.client.listGroups(org.id);
1376
+ const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1377
+ if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1378
+ const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
1379
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1380
+ if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
1381
+ return {
1382
+ orgId: org.id,
1383
+ envId: env.id,
1384
+ label: `${group.slug}@${env.slug}`
1385
+ };
1386
+ }
1387
+ const appSlug = opts.app ?? findProjectConfig()?.app;
1388
+ if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
1389
+ const { apps } = await ctx.client.listApps(org.id);
1390
+ const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1391
+ if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1392
+ const { environments } = await ctx.client.listEnvs(org.id, app.id);
1393
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1394
+ if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1395
+ return {
1396
+ orgId: org.id,
1397
+ envId: env.id,
1398
+ label: `${app.slug}/${env.slug}`
1399
+ };
1400
+ }
1401
+ /** Resolve an application environment, keeping ids + slugs (for token binding). */
1402
+ async function resolveAppEnv(ctx, opts) {
1403
+ const org = await resolveOrg(ctx, opts.org);
1404
+ const appSlug = opts.app ?? findProjectConfig()?.app;
1405
+ if (!appSlug) fail("specify --app (or run `seekrit init`)");
1406
+ if (!opts.env) fail("specify --env");
1407
+ const { apps } = await ctx.client.listApps(org.id);
1408
+ const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
1409
+ if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
1410
+ const { environments } = await ctx.client.listEnvs(org.id, app.id);
1411
+ const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
1412
+ if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
1413
+ return {
1414
+ orgId: org.id,
1415
+ appId: app.id,
1416
+ appSlug: app.slug,
1417
+ envId: env.id,
1418
+ envSlug: env.slug
1419
+ };
1420
+ }
1421
+ /** Resolve a group by slug within the target org. */
1422
+ async function resolveGroup(ctx, opts) {
1423
+ const org = await resolveOrg(ctx, opts.org);
1424
+ if (!opts.group) fail("specify --group");
1425
+ const { groups } = await ctx.client.listGroups(org.id);
1426
+ const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
1427
+ if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
1428
+ return {
1429
+ orgId: org.id,
1430
+ id: group.id,
1431
+ slug: group.slug
1432
+ };
1433
+ }
1383
1434
  //#endregion
1384
- //#region ../../packages/core/src/providers/postgres.ts
1435
+ //#region src/mysql.ts
1385
1436
  /**
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).
1437
+ * `seekrit mysql` temporary MySQL/MariaDB credentials (Vault-style dynamic
1438
+ * secrets).
1389
1439
  *
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.
1440
+ * Zero-knowledge: minting generates the password and its
1441
+ * `mysql_native_password` hash on THIS machine and sends only the hash; the
1442
+ * plaintext password never reaches the API or gets stored. Registering a target
1443
+ * wraps the admin connection string to the broker's public key locally, so the
1444
+ * control plane only ever stores ciphertext.
1393
1445
  */
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");
1446
+ /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
1447
+ function parseTtlSeconds$2(input) {
1448
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
1449
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
1450
+ return Number(m[1]) * ({
1451
+ s: 1,
1452
+ m: 60,
1453
+ h: 3600,
1454
+ d: 86400
1455
+ }[m[2] || "s"] ?? 1);
1456
+ }
1457
+ /** A fresh, valid MySQL user name: `tmp_` + lowercase alphanumerics. */
1458
+ function generateUserName(prefix = "tmp") {
1459
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
1460
+ let out = "";
1461
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
1462
+ for (const b of bytes) out += alphabet[b % 36];
1463
+ return `${prefix}_${out}`;
1464
+ }
1465
+ function registerMysqlCommands(program) {
1466
+ const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
1467
+ const target = mysql.command("target").description("manage provisioning targets");
1468
+ target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$2, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$2, []).action(async (options) => {
1469
+ const ctx = buildContext();
1470
+ const org = await resolveOrg(ctx, options.org);
1471
+ const executor = options.executor === "remote" ? "remote" : "in_do";
1472
+ if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
1473
+ if (![
1474
+ "readonly",
1475
+ "readwrite",
1476
+ "custom"
1477
+ ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
1478
+ const accessLevel = options.access;
1479
+ const adminSecret = resolveLeaseAdminSecret({
1480
+ executor,
1481
+ hmacKey: options.hmacKey,
1482
+ adminUrl: options.adminUrl,
1483
+ adminUrlEnv: "SEEKRIT_MYSQL_ADMIN_URL"
1484
+ });
1485
+ const config = {
1486
+ provider: "mysql",
1487
+ executor,
1488
+ accessLevel,
1489
+ connection: {
1490
+ host: options.host,
1491
+ port: Number.parseInt(options.port, 10),
1492
+ database: options.database
1493
+ },
1494
+ userHost: options.userHost,
1495
+ ...accessLevel === "custom" ? {
1496
+ ...options.createStatement.length ? { createStatements: options.createStatement } : {},
1497
+ ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
1498
+ } : {},
1499
+ ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
1500
+ };
1501
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
1502
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
1503
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
1504
+ name: options.name,
1505
+ config,
1506
+ wrappedAdminSecret
1507
+ });
1508
+ console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
1509
+ });
1510
+ target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
1511
+ const ctx = buildContext();
1512
+ const org = await resolveOrg(ctx, options.org);
1513
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1514
+ for (const t of targets) {
1515
+ if (t.provider !== "mysql") continue;
1516
+ const cfg = t.config;
1517
+ console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
1518
+ }
1519
+ });
1520
+ target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
1521
+ const ctx = buildContext();
1522
+ const org = await resolveOrg(ctx, options.org);
1523
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
1524
+ console.error(`removed ${targetId}`);
1525
+ });
1526
+ 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) => {
1527
+ const ctx = buildContext();
1528
+ const org = await resolveOrg(ctx, options.org);
1529
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
1530
+ const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
1531
+ if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
1532
+ if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
1533
+ const userName = options.user ?? generateUserName();
1534
+ const ttlSeconds = parseTtlSeconds$2(options.ttl);
1535
+ const { password, verifier } = await generateMysqlCredential();
1536
+ const { connection } = await ctx.client.mintLease(org.id, {
1537
+ provider: "mysql",
1538
+ targetId: target.id,
1539
+ roleName: userName,
1540
+ verifier,
1541
+ ttlSeconds
1542
+ });
1543
+ const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
1544
+ console.error(`leased ${userName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
1545
+ if (options.json) console.log(JSON.stringify({
1546
+ ...connection,
1547
+ password,
1548
+ url
1549
+ }, null, 2));
1550
+ else console.log(url);
1551
+ });
1552
+ mysql.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
1553
+ const ctx = buildContext();
1554
+ const org = await resolveOrg(ctx, options.org);
1555
+ const { leases } = await ctx.client.listLeases(org.id);
1556
+ for (const l of leases) {
1557
+ if (l.provider !== "mysql") continue;
1558
+ console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
1559
+ }
1560
+ });
1561
+ mysql.command("revoke <leaseId>").description("revoke a lease now (drops the user immediately)").option("--org <slug>").action(async (leaseId, options) => {
1562
+ const ctx = buildContext();
1563
+ const org = await resolveOrg(ctx, options.org);
1564
+ await ctx.client.revokeLease(org.id, leaseId);
1565
+ console.error(`revoked ${leaseId}`);
1566
+ });
1414
1567
  }
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");
1568
+ /** Collect a repeatable option into an array. */
1569
+ function collect$2(value, acc) {
1570
+ acc.push(value);
1571
+ return acc;
1435
1572
  }
1436
1573
  //#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
- //#endregion
1541
1574
  //#region src/pg.ts
1542
1575
  /**
1543
1576
  * `seekrit pg` — temporary Postgres credentials (Vault-style dynamic secrets).
@@ -1689,37 +1722,6 @@ function collect$1(value, acc) {
1689
1722
  return acc;
1690
1723
  }
1691
1724
  //#endregion
1692
- //#region src/dotenv.ts
1693
- /**
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.
1698
- */
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();
1717
- }
1718
- out[key] = value;
1719
- }
1720
- return out;
1721
- }
1722
- //#endregion
1723
1725
  //#region src/secrets.ts
1724
1726
  /** Fetch + decrypt every secret in a single environment. */
1725
1727
  async function fetchDecryptedSecrets(ctx, orgId, envId) {
@@ -1732,6 +1734,27 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
1732
1734
  await ctx.client.setSecret(orgId, envId, name, ciphertext);
1733
1735
  }
1734
1736
  /**
1737
+ * Encrypt and store many secrets into one environment. The DEK is fetched once
1738
+ * (so user auth prompts for the passphrase a single time, not per variable),
1739
+ * then each value is encrypted locally and written. Existing names are
1740
+ * overwritten; the result splits them into created vs. updated for a summary.
1741
+ * Callers validate the names first — a rejected name aborts before any write.
1742
+ */
1743
+ async function importSecrets(ctx, orgId, envId, entries) {
1744
+ const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
1745
+ const existing = new Set(secrets.map((s) => s.name));
1746
+ const result = {
1747
+ created: [],
1748
+ updated: []
1749
+ };
1750
+ for (const [name, value] of Object.entries(entries)) {
1751
+ const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
1752
+ await ctx.client.setSecret(orgId, envId, name, ciphertext);
1753
+ (existing.has(name) ? result.updated : result.created).push(name);
1754
+ }
1755
+ return result;
1756
+ }
1757
+ /**
1735
1758
  * Resolve the full, layered environment for a running app: composed group
1736
1759
  * secrets (lowest precedence) → the app env's own secrets → `.env` files.
1737
1760
  * Each layer's DEK is unwrapped once with the principal's private key and its
@@ -2111,6 +2134,31 @@ withTarget(secrets.command("set <name> [value]").description("encrypt and store
2111
2134
  await encryptAndSetSecret(ctx, orgId, envId, name, value === void 0 || value === "-" ? (await readStdin()).replace(/\n$/, "") : value);
2112
2135
  console.error(`${name} saved`);
2113
2136
  });
2137
+ 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) => {
2138
+ const source = file ?? ".env";
2139
+ let content;
2140
+ if (source === "-") content = await readStdin();
2141
+ else {
2142
+ if (!existsSync(source)) fail(`no such file: ${source}`);
2143
+ content = readFileSync(source, "utf8");
2144
+ }
2145
+ const entries = parseDotenv(content);
2146
+ const names = Object.keys(entries);
2147
+ if (names.length === 0) fail(`no variables found in ${source === "-" ? "stdin" : source}`);
2148
+ const invalid = names.filter((name) => !secretNameSchema.safeParse(name).success);
2149
+ if (invalid.length > 0) fail(`invalid secret name(s): ${invalid.join(", ")} (must match [A-Za-z_][A-Za-z0-9_]*)`);
2150
+ const ctx = buildContext();
2151
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
2152
+ if (options.dryRun) {
2153
+ const { secrets: existingRows } = await ctx.client.listSecrets(orgId, envId);
2154
+ const existing = new Set(existingRows.map((s) => s.name));
2155
+ console.error(`would import ${names.length} secret(s) into ${label}:`);
2156
+ for (const name of names.sort()) console.error(` ${name}\t${existing.has(name) ? "update" : "new"}`);
2157
+ return;
2158
+ }
2159
+ const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
2160
+ console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
2161
+ });
2114
2162
  withTarget(secrets.command("rm <name>").description("delete a secret")).action(async (name, options) => {
2115
2163
  const ctx = buildContext();
2116
2164
  const { orgId, envId } = await resolveEnvTarget(ctx, options);