@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.
- package/README.md +24 -2
- package/dist/index.js +1473 -1425
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
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
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
*
|
|
202
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
*
|
|
307
|
-
*
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
*
|
|
319
|
-
*
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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/
|
|
169
|
+
//#region ../../packages/core/src/providers/postgres.ts
|
|
332
170
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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
|
-
*
|
|
354
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
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
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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/
|
|
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
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
491
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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("
|
|
418
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "secret decryption failed: wrong key, tampered data, or mismatched context");
|
|
513
419
|
}
|
|
514
420
|
}
|
|
515
|
-
|
|
516
|
-
|
|
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/
|
|
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
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
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
|
-
*
|
|
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
|
|
529
|
-
const
|
|
530
|
-
async function
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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: "
|
|
547
|
+
name: "PBKDF2",
|
|
539
548
|
hash: "SHA-256",
|
|
540
549
|
salt,
|
|
541
|
-
|
|
542
|
-
},
|
|
550
|
+
iterations
|
|
551
|
+
}, material, {
|
|
543
552
|
name: "AES-GCM",
|
|
544
553
|
length: 256
|
|
545
554
|
}, false, [usage]);
|
|
546
555
|
}
|
|
547
|
-
|
|
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
|
-
},
|
|
561
|
-
const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
|
563
|
+
}, kek, utf8Encode(privateKeyJwk));
|
|
562
564
|
return [
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
/**
|
|
571
|
-
async function
|
|
572
|
-
const [
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
579
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
579
580
|
name: "AES-GCM",
|
|
580
581
|
iv: fromBase64Url(ivB64)
|
|
581
|
-
},
|
|
582
|
-
return new Uint8Array(
|
|
582
|
+
}, kek, fromBase64Url(ctB64));
|
|
583
|
+
return utf8Decode(new Uint8Array(plaintext));
|
|
583
584
|
} catch {
|
|
584
|
-
throw new SeekritCryptoError("DECRYPT_FAILED", "
|
|
585
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
|
|
585
586
|
}
|
|
586
587
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
595
|
-
|
|
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
|
|
600
|
-
const
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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/
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
721
|
-
return this.
|
|
688
|
+
byte(n) {
|
|
689
|
+
return this.bytes(new Uint8Array([n & 255]));
|
|
722
690
|
}
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
727
|
-
|
|
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
|
-
|
|
730
|
-
|
|
701
|
+
string(s) {
|
|
702
|
+
const b = typeof s === "string" ? utf8Encode(s) : s;
|
|
703
|
+
return this.uint32(b.length).bytes(b);
|
|
731
704
|
}
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
848
|
-
function
|
|
849
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
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
|
|
886
|
-
const
|
|
887
|
-
const
|
|
888
|
-
const
|
|
889
|
-
const
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
auth
|
|
905
|
-
}),
|
|
906
|
-
auth
|
|
745
|
+
publicKeyOpenssh: encodeSshPublicKey(pub, comment),
|
|
746
|
+
privateKeyOpenssh: encodeOpensshPrivateKey(seed, pub, comment)
|
|
907
747
|
};
|
|
908
748
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
915
|
-
|
|
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
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
return
|
|
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
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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/
|
|
836
|
+
//#region ../../packages/crypto/src/wrap.ts
|
|
957
837
|
/**
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
961
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
/**
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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/
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
/**
|
|
1221
|
-
function
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
1226
|
-
|
|
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
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
const
|
|
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
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1263
|
-
*
|
|
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
|
-
|
|
1267
|
-
const
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
"
|
|
1281
|
-
|
|
1282
|
-
const
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
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
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
*
|
|
1368
|
-
*
|
|
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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
});
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
|
1435
|
+
//#region src/mysql.ts
|
|
1385
1436
|
/**
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
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
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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);
|