@seekrit/mcp 0.1.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 +67 -0
- package/dist/index.js +2230 -0
- package/package.json +32 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join, parse } from "node:path";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import { Writable } from "node:stream";
|
|
11
|
+
//#region ../../packages/crypto/src/encoding.ts
|
|
12
|
+
const CHUNK = 32768;
|
|
13
|
+
/** Base64url (no padding) — portable across browsers, Workers, and Node. */
|
|
14
|
+
function toBase64Url(bytes) {
|
|
15
|
+
let binary = "";
|
|
16
|
+
for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
17
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
18
|
+
}
|
|
19
|
+
function fromBase64Url(text) {
|
|
20
|
+
const base64 = text.replaceAll("-", "+").replaceAll("_", "/");
|
|
21
|
+
const binary = atob(base64);
|
|
22
|
+
const bytes = new Uint8Array(binary.length);
|
|
23
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
24
|
+
return bytes;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Standard base64 (with `+`, `/`, and `=` padding). Most seekrit blobs use
|
|
28
|
+
* base64url, but some external wire formats mandate standard base64 — notably
|
|
29
|
+
* PostgreSQL SCRAM-SHA-256 verifier strings (see scram.ts).
|
|
30
|
+
*/
|
|
31
|
+
function toBase64(bytes) {
|
|
32
|
+
let binary = "";
|
|
33
|
+
for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
34
|
+
return btoa(binary);
|
|
35
|
+
}
|
|
36
|
+
function utf8Encode(text) {
|
|
37
|
+
return new TextEncoder().encode(text);
|
|
38
|
+
}
|
|
39
|
+
function utf8Decode(bytes) {
|
|
40
|
+
return new TextDecoder().decode(bytes);
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region ../../packages/crypto/src/errors.ts
|
|
44
|
+
var SeekritCryptoError = class extends Error {
|
|
45
|
+
code;
|
|
46
|
+
constructor(code, message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "SeekritCryptoError";
|
|
49
|
+
this.code = code;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Split a versioned blob like `sc1.<b64>.<b64>` and verify the prefix.
|
|
54
|
+
* AES-GCM auth failure downstream surfaces as DECRYPT_FAILED — that is also
|
|
55
|
+
* the "wrong passphrase" signal for passphrase-encrypted blobs.
|
|
56
|
+
*/
|
|
57
|
+
function splitBlob(blob, prefix, segments) {
|
|
58
|
+
const parts = blob.split(".");
|
|
59
|
+
if (parts[0] !== prefix) throw new SeekritCryptoError("UNSUPPORTED_VERSION", `expected a "${prefix}" blob, got "${parts[0] ?? ""}"`);
|
|
60
|
+
if (parts.length !== segments + 1 || parts.some((p) => p.length === 0)) throw new SeekritCryptoError("MALFORMED_BLOB", `malformed "${prefix}" blob`);
|
|
61
|
+
return parts.slice(1);
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region ../../packages/crypto/src/aes.ts
|
|
65
|
+
const SECRET_PREFIX = "sc1";
|
|
66
|
+
const IV_LENGTH$1 = 12;
|
|
67
|
+
/** Generate a fresh 256-bit data encryption key for an environment. */
|
|
68
|
+
function generateDek() {
|
|
69
|
+
return crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
|
|
70
|
+
}
|
|
71
|
+
async function importDek(dek, usage) {
|
|
72
|
+
return crypto.subtle.importKey("raw", dek, { name: "AES-GCM" }, false, [usage]);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Encrypt a secret value with the environment DEK.
|
|
76
|
+
*
|
|
77
|
+
* @param aad Authenticated context binding the ciphertext to its location
|
|
78
|
+
* (e.g. `environmentId/SECRET_NAME`) so blobs cannot be swapped between
|
|
79
|
+
* secrets or environments without detection.
|
|
80
|
+
*/
|
|
81
|
+
async function encryptSecret(dek, plaintext, aad) {
|
|
82
|
+
const key = await importDek(dek, "encrypt");
|
|
83
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH$1));
|
|
84
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
85
|
+
name: "AES-GCM",
|
|
86
|
+
iv,
|
|
87
|
+
additionalData: utf8Encode(aad)
|
|
88
|
+
}, key, utf8Encode(plaintext));
|
|
89
|
+
return `${SECRET_PREFIX}.${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
|
|
90
|
+
}
|
|
91
|
+
async function decryptSecret(dek, blob, aad) {
|
|
92
|
+
const [ivB64, ctB64] = splitBlob(blob, SECRET_PREFIX, 2);
|
|
93
|
+
const key = await importDek(dek, "decrypt");
|
|
94
|
+
try {
|
|
95
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
96
|
+
name: "AES-GCM",
|
|
97
|
+
iv: fromBase64Url(ivB64),
|
|
98
|
+
additionalData: utf8Encode(aad)
|
|
99
|
+
}, key, fromBase64Url(ctB64));
|
|
100
|
+
return utf8Decode(new Uint8Array(plaintext));
|
|
101
|
+
} catch {
|
|
102
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "secret decryption failed: wrong key, tampered data, or mismatched context");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** AAD binding a secret ciphertext to its environment + name. */
|
|
106
|
+
function secretAad(environmentId, secretName) {
|
|
107
|
+
return `${environmentId}/${secretName}`;
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region ../../packages/crypto/src/keys.ts
|
|
111
|
+
async function generateKeyPair() {
|
|
112
|
+
const pair = await crypto.subtle.generateKey({
|
|
113
|
+
name: "ECDH",
|
|
114
|
+
namedCurve: "P-256"
|
|
115
|
+
}, true, ["deriveBits"]);
|
|
116
|
+
const [publicJwk, privateJwk] = await Promise.all([crypto.subtle.exportKey("jwk", pair.publicKey), crypto.subtle.exportKey("jwk", pair.privateKey)]);
|
|
117
|
+
return {
|
|
118
|
+
publicKeyJwk: JSON.stringify(publicJwk),
|
|
119
|
+
privateKeyJwk: JSON.stringify(privateJwk)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async function importPublicKey(publicKeyJwk) {
|
|
123
|
+
return crypto.subtle.importKey("jwk", JSON.parse(publicKeyJwk), {
|
|
124
|
+
name: "ECDH",
|
|
125
|
+
namedCurve: "P-256"
|
|
126
|
+
}, true, []);
|
|
127
|
+
}
|
|
128
|
+
async function importPrivateKey(privateKeyJwk) {
|
|
129
|
+
return crypto.subtle.importKey("jwk", JSON.parse(privateKeyJwk), {
|
|
130
|
+
name: "ECDH",
|
|
131
|
+
namedCurve: "P-256"
|
|
132
|
+
}, true, ["deriveBits"]);
|
|
133
|
+
}
|
|
134
|
+
async function exportPrivateKeyPkcs8(key) {
|
|
135
|
+
return new Uint8Array(await crypto.subtle.exportKey("pkcs8", key));
|
|
136
|
+
}
|
|
137
|
+
async function importPrivateKeyPkcs8(pkcs8) {
|
|
138
|
+
return crypto.subtle.importKey("pkcs8", pkcs8, {
|
|
139
|
+
name: "ECDH",
|
|
140
|
+
namedCurve: "P-256"
|
|
141
|
+
}, true, ["deriveBits"]);
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region ../../packages/crypto/src/wrap.ts
|
|
145
|
+
/**
|
|
146
|
+
* ECIES-style key wrapping: an ephemeral P-256 keypair performs ECDH against
|
|
147
|
+
* the recipient's public key; the shared secret is run through HKDF-SHA256 to
|
|
148
|
+
* derive a one-time AES-256-GCM wrapping key. Only the holder of the
|
|
149
|
+
* recipient private key can unwrap.
|
|
150
|
+
*
|
|
151
|
+
* Blob format: `wd1.<ephemeral pub (raw)>.<hkdf salt>.<iv>.<ciphertext>`
|
|
152
|
+
*/
|
|
153
|
+
const WRAP_PREFIX = "wd1";
|
|
154
|
+
const HKDF_INFO = "seekrit/wrap-dek/v1";
|
|
155
|
+
async function deriveWrappingKey(ownPrivateKey, peerPublicKey, salt, usage) {
|
|
156
|
+
const ecdh = {
|
|
157
|
+
name: "ECDH",
|
|
158
|
+
public: peerPublicKey
|
|
159
|
+
};
|
|
160
|
+
const sharedBits = await crypto.subtle.deriveBits(ecdh, ownPrivateKey, 256);
|
|
161
|
+
const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
|
|
162
|
+
return crypto.subtle.deriveKey({
|
|
163
|
+
name: "HKDF",
|
|
164
|
+
hash: "SHA-256",
|
|
165
|
+
salt,
|
|
166
|
+
info: utf8Encode(HKDF_INFO)
|
|
167
|
+
}, hkdfKey, {
|
|
168
|
+
name: "AES-GCM",
|
|
169
|
+
length: 256
|
|
170
|
+
}, false, [usage]);
|
|
171
|
+
}
|
|
172
|
+
/** Wrap an environment DEK to a principal's public key. */
|
|
173
|
+
async function wrapDek(dek, recipientPublicKeyJwk) {
|
|
174
|
+
const recipientKey = await importPublicKey(recipientPublicKeyJwk);
|
|
175
|
+
const ephemeral = await crypto.subtle.generateKey({
|
|
176
|
+
name: "ECDH",
|
|
177
|
+
namedCurve: "P-256"
|
|
178
|
+
}, true, ["deriveBits"]);
|
|
179
|
+
const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
180
|
+
const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, recipientKey, salt, "encrypt");
|
|
181
|
+
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
182
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
183
|
+
name: "AES-GCM",
|
|
184
|
+
iv
|
|
185
|
+
}, wrappingKey, dek);
|
|
186
|
+
const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
|
187
|
+
return [
|
|
188
|
+
WRAP_PREFIX,
|
|
189
|
+
toBase64Url(ephemeralRaw),
|
|
190
|
+
toBase64Url(salt),
|
|
191
|
+
toBase64Url(iv),
|
|
192
|
+
toBase64Url(new Uint8Array(ciphertext))
|
|
193
|
+
].join(".");
|
|
194
|
+
}
|
|
195
|
+
/** Unwrap an environment DEK with the principal's private ECDH key. */
|
|
196
|
+
async function unwrapDek(wrapped, privateKey) {
|
|
197
|
+
const [ephB64, saltB64, ivB64, ctB64] = splitBlob(wrapped, WRAP_PREFIX, 4);
|
|
198
|
+
const wrappingKey = await deriveWrappingKey(privateKey, await crypto.subtle.importKey("raw", fromBase64Url(ephB64), {
|
|
199
|
+
name: "ECDH",
|
|
200
|
+
namedCurve: "P-256"
|
|
201
|
+
}, false, []), fromBase64Url(saltB64), "decrypt");
|
|
202
|
+
try {
|
|
203
|
+
const dek = await crypto.subtle.decrypt({
|
|
204
|
+
name: "AES-GCM",
|
|
205
|
+
iv: fromBase64Url(ivB64)
|
|
206
|
+
}, wrappingKey, fromBase64Url(ctB64));
|
|
207
|
+
return new Uint8Array(dek);
|
|
208
|
+
} catch {
|
|
209
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "DEK unwrap failed: wrong private key or tampered grant");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region ../../packages/crypto/src/kms.ts
|
|
214
|
+
/**
|
|
215
|
+
* Client-side KMS envelope encryption. A managed `encrypt` key is a 256-bit
|
|
216
|
+
* AES-GCM key whose material reaches the client only as a `wd1.` grant (wrapped
|
|
217
|
+
* to the principal's public key). These helpers operate on that material
|
|
218
|
+
* directly — the server never sees plaintext or key material, exactly as for
|
|
219
|
+
* environment DEKs.
|
|
220
|
+
*
|
|
221
|
+
* Blob formats:
|
|
222
|
+
* `ce1.<keyId>.<version>.<iv>.<ciphertext>` — Encrypt output
|
|
223
|
+
* `dk1.<keyId>.<version>.<iv>.<ciphertext>` — GenerateDataKey wrapped key
|
|
224
|
+
*
|
|
225
|
+
* The keyId + version travel in the blob (so Decrypt can select the right key
|
|
226
|
+
* version) and are folded into the AAD (so a blob can't be replayed under a
|
|
227
|
+
* different key/version). For `ce1` the caller's optional encryption *context*
|
|
228
|
+
* is also bound — Decrypt must supply the same context, mirroring AWS KMS.
|
|
229
|
+
*/
|
|
230
|
+
const ENCRYPT_PREFIX = "ce1";
|
|
231
|
+
const DATAKEY_PREFIX = "dk1";
|
|
232
|
+
const IV_LENGTH = 12;
|
|
233
|
+
const KEY_LENGTH = 32;
|
|
234
|
+
/** Generate fresh 256-bit material for an `encrypt` KMS key. */
|
|
235
|
+
function generateEncryptKeyMaterial() {
|
|
236
|
+
return crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
|
|
237
|
+
}
|
|
238
|
+
async function importAesKey(material, usage) {
|
|
239
|
+
return crypto.subtle.importKey("raw", material, { name: "AES-GCM" }, false, [usage]);
|
|
240
|
+
}
|
|
241
|
+
function encryptAad(ref, context) {
|
|
242
|
+
return `${ref.keyId}/${ref.version}/${context}`;
|
|
243
|
+
}
|
|
244
|
+
function dataKeyAad(keyId, version) {
|
|
245
|
+
return `${keyId}/${version}`;
|
|
246
|
+
}
|
|
247
|
+
function parseVersion(versionStr) {
|
|
248
|
+
const version = Number(versionStr);
|
|
249
|
+
if (!Number.isInteger(version) || version < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid key version in KMS blob");
|
|
250
|
+
return version;
|
|
251
|
+
}
|
|
252
|
+
/** Read the key id + version a `ce1`/`dk1` blob was produced under. */
|
|
253
|
+
function kmsBlobKeyRef(blob) {
|
|
254
|
+
const prefix = blob.split(".")[0];
|
|
255
|
+
if (prefix !== ENCRYPT_PREFIX && prefix !== DATAKEY_PREFIX) throw new SeekritCryptoError("UNSUPPORTED_VERSION", `not a KMS blob: "${prefix ?? ""}"`);
|
|
256
|
+
const [keyId, versionStr] = splitBlob(blob, prefix, 4);
|
|
257
|
+
return {
|
|
258
|
+
keyId,
|
|
259
|
+
version: parseVersion(versionStr)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** Encrypt a value under a managed key. `context` (bound as AAD) defaults to empty. */
|
|
263
|
+
async function kmsEncrypt(material, ref, plaintext, context = "") {
|
|
264
|
+
const key = await importAesKey(material, "encrypt");
|
|
265
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
266
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
267
|
+
name: "AES-GCM",
|
|
268
|
+
iv,
|
|
269
|
+
additionalData: utf8Encode(encryptAad(ref, context))
|
|
270
|
+
}, key, utf8Encode(plaintext));
|
|
271
|
+
return [
|
|
272
|
+
ENCRYPT_PREFIX,
|
|
273
|
+
ref.keyId,
|
|
274
|
+
String(ref.version),
|
|
275
|
+
toBase64Url(iv),
|
|
276
|
+
toBase64Url(new Uint8Array(ciphertext))
|
|
277
|
+
].join(".");
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Decrypt a `ce1` blob. The caller supplies the material for the key version
|
|
281
|
+
* named in the blob (see `kmsBlobKeyRef`) and the same `context` used to
|
|
282
|
+
* encrypt.
|
|
283
|
+
*/
|
|
284
|
+
async function kmsDecrypt(material, blob, context = "") {
|
|
285
|
+
const [keyId, versionStr, ivB64, ctB64] = splitBlob(blob, ENCRYPT_PREFIX, 4);
|
|
286
|
+
const ref = {
|
|
287
|
+
keyId,
|
|
288
|
+
version: parseVersion(versionStr)
|
|
289
|
+
};
|
|
290
|
+
const key = await importAesKey(material, "decrypt");
|
|
291
|
+
try {
|
|
292
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
293
|
+
name: "AES-GCM",
|
|
294
|
+
iv: fromBase64Url(ivB64),
|
|
295
|
+
additionalData: utf8Encode(encryptAad(ref, context))
|
|
296
|
+
}, key, fromBase64Url(ctB64));
|
|
297
|
+
return utf8Decode(new Uint8Array(plaintext));
|
|
298
|
+
} catch {
|
|
299
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "KMS decryption failed: wrong key/version, tampered data, or mismatched context");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Generate a fresh data key wrapped under a managed key — the envelope pattern
|
|
304
|
+
* for large payloads (AWS KMS GenerateDataKey). Encrypt bulk data with
|
|
305
|
+
* `plaintext`, store `wrapped` alongside it, and recover the key later with
|
|
306
|
+
* `decryptDataKey`.
|
|
307
|
+
*/
|
|
308
|
+
async function generateDataKey(material, ref) {
|
|
309
|
+
const plaintext = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
|
|
310
|
+
const key = await importAesKey(material, "encrypt");
|
|
311
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
312
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
313
|
+
name: "AES-GCM",
|
|
314
|
+
iv,
|
|
315
|
+
additionalData: utf8Encode(dataKeyAad(ref.keyId, String(ref.version)))
|
|
316
|
+
}, key, plaintext);
|
|
317
|
+
return {
|
|
318
|
+
plaintext,
|
|
319
|
+
wrapped: [
|
|
320
|
+
DATAKEY_PREFIX,
|
|
321
|
+
ref.keyId,
|
|
322
|
+
String(ref.version),
|
|
323
|
+
toBase64Url(iv),
|
|
324
|
+
toBase64Url(new Uint8Array(ciphertext))
|
|
325
|
+
].join(".")
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region ../../packages/crypto/src/mysql.ts
|
|
330
|
+
/**
|
|
331
|
+
* Client-side construction of a MySQL/MariaDB `mysql_native_password`
|
|
332
|
+
* authentication string, for minting *temporary MySQL login credentials*
|
|
333
|
+
* without the password plaintext ever reaching seekrit's control plane OR
|
|
334
|
+
* MySQL itself.
|
|
335
|
+
*
|
|
336
|
+
* The trick mirrors the Postgres SCRAM one (scram.ts): the stored auth string
|
|
337
|
+
* is `*<UPPER(HEX(SHA1(SHA1(password))))>`, and
|
|
338
|
+
* `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'` stores that
|
|
339
|
+
* string verbatim — MySQL does NOT re-hash it. So the flow is:
|
|
340
|
+
*
|
|
341
|
+
* 1. the machine that will connect generates a random password locally,
|
|
342
|
+
* 2. computes this hash locally,
|
|
343
|
+
* 3. sends only the hash to the broker → `CREATE USER … AS '<hash>'`,
|
|
344
|
+
* 4. connects directly to MySQL with the plaintext it never shared.
|
|
345
|
+
*
|
|
346
|
+
* Zero-knowledge at both layers: the control plane relays only the hash, and
|
|
347
|
+
* the hash is NOT sufficient to authenticate. `mysql_native_password` login is
|
|
348
|
+
* a challenge-response — the server proves knowledge of `SHA1(SHA1(password))`
|
|
349
|
+
* against a fresh scramble, and verifying a client requires `SHA1(password)`
|
|
350
|
+
* (the preimage of the first inner hash), which the stored double-SHA1 does not
|
|
351
|
+
* reveal. A dump of `mysql.user` / the query log therefore cannot log in.
|
|
352
|
+
*
|
|
353
|
+
* SHA-1 is available via WebCrypto (`crypto.subtle.digest("SHA-1", …)`) in the
|
|
354
|
+
* browser, the CLI, the MCP server, and Workers — so this runs everywhere the
|
|
355
|
+
* SCRAM helper does, with no hand-rolled hash primitive.
|
|
356
|
+
*/
|
|
357
|
+
const DEFAULT_PASSWORD_LENGTH$1 = 32;
|
|
358
|
+
const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
359
|
+
async function sha1(data) {
|
|
360
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
|
|
361
|
+
}
|
|
362
|
+
function toUpperHex(bytes) {
|
|
363
|
+
let hex = "";
|
|
364
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
365
|
+
return hex.toUpperCase();
|
|
366
|
+
}
|
|
367
|
+
function randomPassword$1(length) {
|
|
368
|
+
let out = "";
|
|
369
|
+
while (out.length < length) {
|
|
370
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
371
|
+
for (const byte of bytes) {
|
|
372
|
+
if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
|
|
373
|
+
if (out.length === length) break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Compute the `mysql_native_password` auth string `*<UPPER(HEX(SHA1(SHA1(pw))))>`
|
|
380
|
+
* for a known password. Pass the result straight to
|
|
381
|
+
* `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`.
|
|
382
|
+
*/
|
|
383
|
+
async function mysqlNativePasswordVerifier(password) {
|
|
384
|
+
return `*${toUpperHex(await sha1(await sha1(utf8Encode(password))))}`;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Mint a fresh random password and its `mysql_native_password` hash in one step
|
|
388
|
+
* — the client-side half of a Vault-style dynamic MySQL credential.
|
|
389
|
+
*/
|
|
390
|
+
async function generateMysqlCredential(options = {}) {
|
|
391
|
+
const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
|
|
392
|
+
return {
|
|
393
|
+
password,
|
|
394
|
+
verifier: await mysqlNativePasswordVerifier(password)
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region ../../packages/crypto/src/passphrase.ts
|
|
399
|
+
/**
|
|
400
|
+
* User private keys are stored server-side encrypted under a key derived from
|
|
401
|
+
* the user's passphrase, so any browser or CLI session can fetch and unlock
|
|
402
|
+
* them without the server ever seeing the passphrase or plaintext key.
|
|
403
|
+
*
|
|
404
|
+
* KDF is PBKDF2-HMAC-SHA256 (WebCrypto-native everywhere). The blob embeds
|
|
405
|
+
* its own salt + iteration count for future agility; bumping ITERATIONS only
|
|
406
|
+
* affects newly written blobs. TODO: revisit Argon2id via WASM later.
|
|
407
|
+
*
|
|
408
|
+
* Blob format: `pk1.<iterations>.<salt>.<iv>.<ciphertext>`
|
|
409
|
+
*/
|
|
410
|
+
const PK_PREFIX = "pk1";
|
|
411
|
+
async function deriveKek(passphrase, salt, iterations, usage) {
|
|
412
|
+
const material = await crypto.subtle.importKey("raw", utf8Encode(passphrase), "PBKDF2", false, ["deriveKey"]);
|
|
413
|
+
return crypto.subtle.deriveKey({
|
|
414
|
+
name: "PBKDF2",
|
|
415
|
+
hash: "SHA-256",
|
|
416
|
+
salt,
|
|
417
|
+
iterations
|
|
418
|
+
}, material, {
|
|
419
|
+
name: "AES-GCM",
|
|
420
|
+
length: 256
|
|
421
|
+
}, false, [usage]);
|
|
422
|
+
}
|
|
423
|
+
/** Wrong passphrase surfaces as SeekritCryptoError with code DECRYPT_FAILED. */
|
|
424
|
+
async function decryptPrivateKey(passphrase, blob) {
|
|
425
|
+
const [iterStr, saltB64, ivB64, ctB64] = splitBlob(blob, PK_PREFIX, 4);
|
|
426
|
+
const iterations = Number.parseInt(iterStr, 10);
|
|
427
|
+
if (!Number.isFinite(iterations) || iterations < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid PBKDF2 iteration count");
|
|
428
|
+
const kek = await deriveKek(passphrase, fromBase64Url(saltB64), iterations, "decrypt");
|
|
429
|
+
try {
|
|
430
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
431
|
+
name: "AES-GCM",
|
|
432
|
+
iv: fromBase64Url(ivB64)
|
|
433
|
+
}, kek, fromBase64Url(ctB64));
|
|
434
|
+
return utf8Decode(new Uint8Array(plaintext));
|
|
435
|
+
} catch {
|
|
436
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region ../../packages/crypto/src/shamir.ts
|
|
441
|
+
/**
|
|
442
|
+
* Shamir's Secret Sharing over GF(2^8) — the same field AES uses, with the
|
|
443
|
+
* reduction polynomial x^8 + x^4 + x^3 + x + 1 (0x11b). Splits a byte string
|
|
444
|
+
* into `shares` shares such that any `threshold` of them reconstruct the secret
|
|
445
|
+
* exactly and any fewer reveal nothing about it.
|
|
446
|
+
*
|
|
447
|
+
* This is the one hand-rolled primitive behind org recovery (P0-1): the org
|
|
448
|
+
* recovery private key is split into shares, each wrapped to a designated
|
|
449
|
+
* custodian's public key, so a quorum — never seekrit, never any single
|
|
450
|
+
* custodian below the threshold — can reconstruct it. See recovery.ts for the
|
|
451
|
+
* composition with key wrapping.
|
|
452
|
+
*
|
|
453
|
+
* Each secret byte gets its own degree-(threshold-1) polynomial whose constant
|
|
454
|
+
* term is that byte; a share is that polynomial family evaluated at one distinct
|
|
455
|
+
* nonzero x-coordinate. Reconstruction is Lagrange interpolation back to x = 0.
|
|
456
|
+
*
|
|
457
|
+
* Share wire format: a Uint8Array whose first byte is the share's distinct
|
|
458
|
+
* nonzero x-coordinate and whose remaining bytes are the evaluations p_j(x) for
|
|
459
|
+
* each secret byte j. Self-describing, so combineSecret() needs no external
|
|
460
|
+
* index — the x-coordinate survives being wrapped/unwrapped/re-wrapped intact.
|
|
461
|
+
*/
|
|
462
|
+
/** Russian-peasant multiply in GF(2^8) (mod 0x11b) — used only to seed tables. */
|
|
463
|
+
function peasantMul(a, b) {
|
|
464
|
+
let product = 0;
|
|
465
|
+
let x = a;
|
|
466
|
+
let y = b;
|
|
467
|
+
for (let i = 0; i < 8; i++) {
|
|
468
|
+
if (y & 1) product ^= x;
|
|
469
|
+
const carry = x & 128;
|
|
470
|
+
x = x << 1 & 255;
|
|
471
|
+
if (carry) x ^= 27;
|
|
472
|
+
y >>= 1;
|
|
473
|
+
}
|
|
474
|
+
return product;
|
|
475
|
+
}
|
|
476
|
+
const EXP = /* @__PURE__ */ new Uint8Array(512);
|
|
477
|
+
const LOG = /* @__PURE__ */ new Uint8Array(256);
|
|
478
|
+
{
|
|
479
|
+
let a = 1;
|
|
480
|
+
for (let i = 0; i < 255; i++) {
|
|
481
|
+
EXP[i] = a;
|
|
482
|
+
LOG[a] = i;
|
|
483
|
+
a = peasantMul(a, 3);
|
|
484
|
+
}
|
|
485
|
+
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
|
486
|
+
}
|
|
487
|
+
const SALT_LENGTH = 16;
|
|
488
|
+
const DEFAULT_PASSWORD_LENGTH = 32;
|
|
489
|
+
const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
490
|
+
async function hmacSha256(key, message) {
|
|
491
|
+
const k = await crypto.subtle.importKey("raw", key, {
|
|
492
|
+
name: "HMAC",
|
|
493
|
+
hash: "SHA-256"
|
|
494
|
+
}, false, ["sign"]);
|
|
495
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", k, message));
|
|
496
|
+
}
|
|
497
|
+
async function sha256(data) {
|
|
498
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
|
|
499
|
+
}
|
|
500
|
+
async function saltPassword(password, salt, iterations) {
|
|
501
|
+
const material = await crypto.subtle.importKey("raw", utf8Encode(password), "PBKDF2", false, ["deriveBits"]);
|
|
502
|
+
const bits = await crypto.subtle.deriveBits({
|
|
503
|
+
name: "PBKDF2",
|
|
504
|
+
hash: "SHA-256",
|
|
505
|
+
salt,
|
|
506
|
+
iterations
|
|
507
|
+
}, material, 256);
|
|
508
|
+
return new Uint8Array(bits);
|
|
509
|
+
}
|
|
510
|
+
function randomPassword(length) {
|
|
511
|
+
let out = "";
|
|
512
|
+
while (out.length < length) {
|
|
513
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
514
|
+
for (const byte of bytes) {
|
|
515
|
+
if (byte < 248) out += PASSWORD_ALPHABET[byte % 62];
|
|
516
|
+
if (out.length === length) break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Compute the `SCRAM-SHA-256$<i>:<salt>$<StoredKey>:<ServerKey>` verifier for a
|
|
523
|
+
* known password. Pass the result straight to `CREATE ROLE … PASSWORD`.
|
|
524
|
+
*/
|
|
525
|
+
async function scramSha256Verifier(password, options = {}) {
|
|
526
|
+
const salt = options.salt ?? crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
527
|
+
const iterations = options.iterations ?? 4096;
|
|
528
|
+
const saltedPassword = await saltPassword(password, salt, iterations);
|
|
529
|
+
const storedKey = await sha256(await hmacSha256(saltedPassword, utf8Encode("Client Key")));
|
|
530
|
+
const serverKey = await hmacSha256(saltedPassword, utf8Encode("Server Key"));
|
|
531
|
+
return `SCRAM-SHA-256$${iterations}:${toBase64(salt)}$${toBase64(storedKey)}:${toBase64(serverKey)}`;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Mint a fresh random password and its SCRAM verifier in one step — the
|
|
535
|
+
* client-side half of a Vault-style dynamic Postgres credential.
|
|
536
|
+
*/
|
|
537
|
+
async function generatePostgresCredential(options = {}) {
|
|
538
|
+
const password = randomPassword(options.length ?? DEFAULT_PASSWORD_LENGTH);
|
|
539
|
+
const iterations = options.iterations ?? 4096;
|
|
540
|
+
return {
|
|
541
|
+
password,
|
|
542
|
+
verifier: await scramSha256Verifier(password, { iterations }),
|
|
543
|
+
iterations
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region ../../packages/crypto/src/sign.ts
|
|
548
|
+
/**
|
|
549
|
+
* Managed signing keys for the client-side KMS — ECDSA over P-256 (the curve
|
|
550
|
+
* already used for principal keypairs; universal in WebCrypto). A `sign` key's
|
|
551
|
+
* private half reaches the client only as a `wd1.` grant wrapping its PKCS8
|
|
552
|
+
* bytes; the public half is published per version so verification needs no
|
|
553
|
+
* grant. Signatures are `sg1.<keyId>.<version>.<signature>` — the keyId +
|
|
554
|
+
* version let a verifier fetch the matching version's public key.
|
|
555
|
+
*/
|
|
556
|
+
const ECDSA_PARAMS = {
|
|
557
|
+
name: "ECDSA",
|
|
558
|
+
namedCurve: "P-256"
|
|
559
|
+
};
|
|
560
|
+
const ECDSA_SIGN = {
|
|
561
|
+
name: "ECDSA",
|
|
562
|
+
hash: "SHA-256"
|
|
563
|
+
};
|
|
564
|
+
const SIGN_PREFIX = "sg1";
|
|
565
|
+
/** Generate a fresh signing keypair for a `sign` KMS key (or a new version). */
|
|
566
|
+
async function generateSigningKeyMaterial() {
|
|
567
|
+
const pair = await crypto.subtle.generateKey(ECDSA_PARAMS, true, ["sign", "verify"]);
|
|
568
|
+
const [publicJwk, pkcs8] = await Promise.all([crypto.subtle.exportKey("jwk", pair.publicKey), crypto.subtle.exportKey("pkcs8", pair.privateKey)]);
|
|
569
|
+
return {
|
|
570
|
+
publicKeyJwk: JSON.stringify(publicJwk),
|
|
571
|
+
privateKeyPkcs8: new Uint8Array(pkcs8)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/** Import the wrapped-then-unwrapped PKCS8 private key for signing. */
|
|
575
|
+
async function importSigningKey(pkcs8) {
|
|
576
|
+
return crypto.subtle.importKey("pkcs8", pkcs8, ECDSA_PARAMS, false, ["sign"]);
|
|
577
|
+
}
|
|
578
|
+
/** Import a published public key (JWK) for verification. */
|
|
579
|
+
async function importVerifyingKey(publicKeyJwk) {
|
|
580
|
+
return crypto.subtle.importKey("jwk", JSON.parse(publicKeyJwk), ECDSA_PARAMS, false, ["verify"]);
|
|
581
|
+
}
|
|
582
|
+
/** Sign a message with a managed signing key; returns an `sg1.` blob. */
|
|
583
|
+
async function signMessage(privateKey, ref, message) {
|
|
584
|
+
const data = typeof message === "string" ? utf8Encode(message) : message;
|
|
585
|
+
const sig = new Uint8Array(await crypto.subtle.sign(ECDSA_SIGN, privateKey, data));
|
|
586
|
+
return [
|
|
587
|
+
SIGN_PREFIX,
|
|
588
|
+
ref.keyId,
|
|
589
|
+
String(ref.version),
|
|
590
|
+
toBase64Url(sig)
|
|
591
|
+
].join(".");
|
|
592
|
+
}
|
|
593
|
+
/** Verify an `sg1.` signature over a message with the version's public key. */
|
|
594
|
+
async function verifyMessage(publicKey, signature, message) {
|
|
595
|
+
const [, , sigB64] = splitBlob(signature, SIGN_PREFIX, 3);
|
|
596
|
+
const data = typeof message === "string" ? utf8Encode(message) : message;
|
|
597
|
+
return crypto.subtle.verify(ECDSA_SIGN, publicKey, fromBase64Url(sigB64), data);
|
|
598
|
+
}
|
|
599
|
+
/** Read the key id + version an `sg1.` signature was produced under. */
|
|
600
|
+
function signatureKeyRef(signature) {
|
|
601
|
+
const [keyId, versionStr] = splitBlob(signature, SIGN_PREFIX, 3);
|
|
602
|
+
const version = Number(versionStr);
|
|
603
|
+
if (!Number.isInteger(version) || version < 1) throw new SeekritCryptoError("MALFORMED_BLOB", "invalid key version in signature");
|
|
604
|
+
return {
|
|
605
|
+
keyId,
|
|
606
|
+
version
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region ../../packages/crypto/src/token.ts
|
|
611
|
+
/**
|
|
612
|
+
* Service tokens (CI, docker builds, agent proxies, k8s, …) are self-contained
|
|
613
|
+
* principals: the token string itself carries the private key, so the server
|
|
614
|
+
* never holds it. The server stores only the SHA-256 hash of the full token
|
|
615
|
+
* (for authentication) and the public key (for wrapping DEK grants).
|
|
616
|
+
*
|
|
617
|
+
* Format: `skt_<token id>_<private key pkcs8, base64url>`
|
|
618
|
+
*/
|
|
619
|
+
const TOKEN_PREFIX = "skt";
|
|
620
|
+
const TOKEN_ID_LENGTH = 22;
|
|
621
|
+
const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
622
|
+
function randomTokenId() {
|
|
623
|
+
let out = "";
|
|
624
|
+
while (out.length < TOKEN_ID_LENGTH) {
|
|
625
|
+
const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_ID_LENGTH - out.length));
|
|
626
|
+
for (const byte of bytes) {
|
|
627
|
+
if (byte < 248) out += ID_ALPHABET[byte % 62];
|
|
628
|
+
if (out.length === TOKEN_ID_LENGTH) break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return `${TOKEN_PREFIX}_${out}`;
|
|
632
|
+
}
|
|
633
|
+
async function hashToken(token) {
|
|
634
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8Encode(token));
|
|
635
|
+
return toBase64Url(new Uint8Array(digest));
|
|
636
|
+
}
|
|
637
|
+
async function createServiceToken() {
|
|
638
|
+
const { publicKeyJwk, privateKeyJwk } = await generateKeyPair();
|
|
639
|
+
const pkcs8 = await exportPrivateKeyPkcs8(await importPrivateKey(privateKeyJwk));
|
|
640
|
+
const tokenId = randomTokenId();
|
|
641
|
+
const token = `${tokenId}_${toBase64Url(pkcs8)}`;
|
|
642
|
+
return {
|
|
643
|
+
token,
|
|
644
|
+
tokenId,
|
|
645
|
+
tokenHash: await hashToken(token),
|
|
646
|
+
publicKeyJwk
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
async function parseServiceToken(token) {
|
|
650
|
+
const match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(token);
|
|
651
|
+
if (!match) throw new SeekritCryptoError("MALFORMED_TOKEN", "not a valid seekrit service token");
|
|
652
|
+
const [, tokenId, keyB64] = match;
|
|
653
|
+
try {
|
|
654
|
+
return {
|
|
655
|
+
tokenId,
|
|
656
|
+
privateKey: await importPrivateKeyPkcs8(fromBase64Url(keyB64))
|
|
657
|
+
};
|
|
658
|
+
} catch {
|
|
659
|
+
throw new SeekritCryptoError("MALFORMED_TOKEN", "service token private key is corrupted");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function isServiceToken(value) {
|
|
663
|
+
return value.startsWith(`${TOKEN_PREFIX}_`);
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region ../cli/package.json
|
|
667
|
+
var version$1 = "0.18.0";
|
|
668
|
+
const PROJECT_FILE = "seekrit.json";
|
|
669
|
+
function globalConfigPath() {
|
|
670
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
671
|
+
}
|
|
672
|
+
function readGlobalConfig() {
|
|
673
|
+
const path = globalConfigPath();
|
|
674
|
+
if (!existsSync(path)) return {};
|
|
675
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
676
|
+
}
|
|
677
|
+
/** Walk up from cwd looking for seekrit.json. */
|
|
678
|
+
function findProjectConfig(startDir = process.cwd()) {
|
|
679
|
+
let dir = startDir;
|
|
680
|
+
const { root } = parse(dir);
|
|
681
|
+
while (true) {
|
|
682
|
+
const candidate = join(dir, PROJECT_FILE);
|
|
683
|
+
if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8"));
|
|
684
|
+
if (dir === root) return null;
|
|
685
|
+
dir = dirname(dir);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function writeProjectConfig(config, dir = process.cwd()) {
|
|
689
|
+
const path = join(dir, PROJECT_FILE);
|
|
690
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
691
|
+
return path;
|
|
692
|
+
}
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region ../../packages/api-client/src/index.ts
|
|
695
|
+
var SeekritApiError = class extends Error {
|
|
696
|
+
status;
|
|
697
|
+
code;
|
|
698
|
+
constructor(status, code, message) {
|
|
699
|
+
super(message);
|
|
700
|
+
this.name = "SeekritApiError";
|
|
701
|
+
this.status = status;
|
|
702
|
+
this.code = code;
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
var SeekritClient = class {
|
|
706
|
+
baseUrl;
|
|
707
|
+
auth;
|
|
708
|
+
fetchImpl;
|
|
709
|
+
constructor(options) {
|
|
710
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
711
|
+
this.auth = options.auth;
|
|
712
|
+
this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
713
|
+
}
|
|
714
|
+
async request(method, path, body) {
|
|
715
|
+
const headers = { accept: "application/json" };
|
|
716
|
+
if (this.auth.type === "bearer") headers.authorization = `Bearer ${this.auth.token}`;
|
|
717
|
+
else if (this.auth.type === "dynamic") {
|
|
718
|
+
const token = await this.auth.getToken();
|
|
719
|
+
if (!token) throw new SeekritApiError(401, "unauthorized", "session expired");
|
|
720
|
+
headers.authorization = `Bearer ${token}`;
|
|
721
|
+
} else headers["x-seekrit-dev-user"] = this.auth.email;
|
|
722
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
723
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
724
|
+
method,
|
|
725
|
+
headers,
|
|
726
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
727
|
+
});
|
|
728
|
+
if (!res.ok) {
|
|
729
|
+
const fallback = { error: {
|
|
730
|
+
code: "internal",
|
|
731
|
+
message: `HTTP ${res.status}`
|
|
732
|
+
} };
|
|
733
|
+
const payload = await res.json().catch(() => fallback);
|
|
734
|
+
throw new SeekritApiError(res.status, payload.error?.code ?? "internal", payload.error?.message ?? `HTTP ${res.status}`);
|
|
735
|
+
}
|
|
736
|
+
return await res.json();
|
|
737
|
+
}
|
|
738
|
+
me() {
|
|
739
|
+
return this.request("GET", "/v1/me");
|
|
740
|
+
}
|
|
741
|
+
getMyKeys() {
|
|
742
|
+
return this.request("GET", "/v1/me/keys");
|
|
743
|
+
}
|
|
744
|
+
setMyKeys(input) {
|
|
745
|
+
return this.request("PUT", "/v1/me/keys", input);
|
|
746
|
+
}
|
|
747
|
+
getMyNotificationPrefs() {
|
|
748
|
+
return this.request("GET", "/v1/me/notifications");
|
|
749
|
+
}
|
|
750
|
+
setMyNotificationPrefs(input) {
|
|
751
|
+
return this.request("PUT", "/v1/me/notifications", input);
|
|
752
|
+
}
|
|
753
|
+
listOrgs() {
|
|
754
|
+
return this.request("GET", "/v1/orgs");
|
|
755
|
+
}
|
|
756
|
+
createOrg(input) {
|
|
757
|
+
return this.request("POST", "/v1/orgs", input);
|
|
758
|
+
}
|
|
759
|
+
getOrg(orgId) {
|
|
760
|
+
return this.request("GET", `/v1/orgs/${orgId}`);
|
|
761
|
+
}
|
|
762
|
+
/** Rename an organization (display name only — the slug is immutable). */
|
|
763
|
+
updateOrg(orgId, input) {
|
|
764
|
+
return this.request("PATCH", `/v1/orgs/${orgId}`, input);
|
|
765
|
+
}
|
|
766
|
+
listMembers(orgId) {
|
|
767
|
+
return this.request("GET", `/v1/orgs/${orgId}/members`);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* The org-wide "require a second factor for all members" policy. `configured`
|
|
771
|
+
* is false when the identity provider isn't wired up (local dev), in which
|
|
772
|
+
* case the toggle is inert.
|
|
773
|
+
*/
|
|
774
|
+
getMfaPolicy(orgId) {
|
|
775
|
+
return this.request("GET", `/v1/orgs/${orgId}/mfa-policy`);
|
|
776
|
+
}
|
|
777
|
+
setMfaPolicy(orgId, input) {
|
|
778
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/mfa-policy`, input);
|
|
779
|
+
}
|
|
780
|
+
listInvites(orgId) {
|
|
781
|
+
return this.request("GET", `/v1/orgs/${orgId}/invites`);
|
|
782
|
+
}
|
|
783
|
+
createInvite(orgId, input) {
|
|
784
|
+
return this.request("POST", `/v1/orgs/${orgId}/invites`, input);
|
|
785
|
+
}
|
|
786
|
+
revokeInvite(orgId, inviteId) {
|
|
787
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/invites/${inviteId}`);
|
|
788
|
+
}
|
|
789
|
+
listApps(orgId) {
|
|
790
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps`);
|
|
791
|
+
}
|
|
792
|
+
createApp(orgId, input) {
|
|
793
|
+
return this.request("POST", `/v1/orgs/${orgId}/apps`, input);
|
|
794
|
+
}
|
|
795
|
+
getApp(orgId, appId) {
|
|
796
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}`);
|
|
797
|
+
}
|
|
798
|
+
/** Rename an application (display name only — the slug is immutable). */
|
|
799
|
+
updateApp(orgId, appId, input) {
|
|
800
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/apps/${appId}`, input);
|
|
801
|
+
}
|
|
802
|
+
deleteApp(orgId, appId) {
|
|
803
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/apps/${appId}`);
|
|
804
|
+
}
|
|
805
|
+
listEnvs(orgId, appId) {
|
|
806
|
+
return this.request("GET", `/v1/orgs/${orgId}/apps/${appId}/envs`);
|
|
807
|
+
}
|
|
808
|
+
createEnv(orgId, appId, input) {
|
|
809
|
+
return this.request("POST", `/v1/orgs/${orgId}/apps/${appId}/envs`, input);
|
|
810
|
+
}
|
|
811
|
+
getEnv(orgId, envId) {
|
|
812
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}`);
|
|
813
|
+
}
|
|
814
|
+
deleteEnv(orgId, envId) {
|
|
815
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
|
|
816
|
+
}
|
|
817
|
+
listGroups(orgId) {
|
|
818
|
+
return this.request("GET", `/v1/orgs/${orgId}/groups`);
|
|
819
|
+
}
|
|
820
|
+
createGroup(orgId, input) {
|
|
821
|
+
return this.request("POST", `/v1/orgs/${orgId}/groups`, input);
|
|
822
|
+
}
|
|
823
|
+
getGroup(orgId, groupId) {
|
|
824
|
+
return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}`);
|
|
825
|
+
}
|
|
826
|
+
/** Rename a group (display name only — the slug is immutable). */
|
|
827
|
+
updateGroup(orgId, groupId, input) {
|
|
828
|
+
return this.request("PATCH", `/v1/orgs/${orgId}/groups/${groupId}`, input);
|
|
829
|
+
}
|
|
830
|
+
deleteGroup(orgId, groupId) {
|
|
831
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/groups/${groupId}`);
|
|
832
|
+
}
|
|
833
|
+
listGroupEnvs(orgId, groupId) {
|
|
834
|
+
return this.request("GET", `/v1/orgs/${orgId}/groups/${groupId}/envs`);
|
|
835
|
+
}
|
|
836
|
+
createGroupEnv(orgId, groupId, input) {
|
|
837
|
+
return this.request("POST", `/v1/orgs/${orgId}/groups/${groupId}/envs`, input);
|
|
838
|
+
}
|
|
839
|
+
listEnvGroups(orgId, envId) {
|
|
840
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/groups`);
|
|
841
|
+
}
|
|
842
|
+
linkEnvGroup(orgId, envId, input) {
|
|
843
|
+
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/groups`, input);
|
|
844
|
+
}
|
|
845
|
+
unlinkEnvGroup(orgId, envId, groupId) {
|
|
846
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/groups/${groupId}`);
|
|
847
|
+
}
|
|
848
|
+
/** Ordered ciphertext layers + wrapped DEKs for the calling principal. */
|
|
849
|
+
resolve(query = {}) {
|
|
850
|
+
const params = new URLSearchParams();
|
|
851
|
+
if (query.env) params.set("env", query.env);
|
|
852
|
+
for (const [group, slug] of Object.entries(query.with ?? {})) params.append("with", `${group}:${slug}`);
|
|
853
|
+
const qs = params.size > 0 ? `?${params}` : "";
|
|
854
|
+
return this.request("GET", `/v1/resolve${qs}`);
|
|
855
|
+
}
|
|
856
|
+
listSecrets(orgId, envId) {
|
|
857
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/secrets`);
|
|
858
|
+
}
|
|
859
|
+
setSecret(orgId, envId, name, ciphertext) {
|
|
860
|
+
return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext });
|
|
861
|
+
}
|
|
862
|
+
deleteSecret(orgId, envId, name) {
|
|
863
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`);
|
|
864
|
+
}
|
|
865
|
+
/** The calling principal's wrapped DEK for this environment. */
|
|
866
|
+
getMyEnvKey(orgId, envId) {
|
|
867
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/key`);
|
|
868
|
+
}
|
|
869
|
+
listEnvKeys(orgId, envId) {
|
|
870
|
+
return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}/keys`);
|
|
871
|
+
}
|
|
872
|
+
grantEnvKey(orgId, envId, input) {
|
|
873
|
+
return this.request("POST", `/v1/orgs/${orgId}/envs/${envId}/keys`, input);
|
|
874
|
+
}
|
|
875
|
+
revokeEnvKey(orgId, envId, grantId) {
|
|
876
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}/keys/${grantId}`);
|
|
877
|
+
}
|
|
878
|
+
/** Org recovery status: threshold, custodians, and environment coverage. */
|
|
879
|
+
getRecovery(orgId) {
|
|
880
|
+
return this.request("GET", `/v1/orgs/${orgId}/recovery`);
|
|
881
|
+
}
|
|
882
|
+
/** Enable recovery with the recovery public key, custodian shares, and grants. */
|
|
883
|
+
configureRecovery(orgId, input) {
|
|
884
|
+
return this.request("POST", `/v1/orgs/${orgId}/recovery`, input);
|
|
885
|
+
}
|
|
886
|
+
/** Rotate the recovery key: new keypair, custodian set, and env re-wraps. */
|
|
887
|
+
rotateRecovery(orgId, input) {
|
|
888
|
+
return this.request("POST", `/v1/orgs/${orgId}/recovery/rotate`, input);
|
|
889
|
+
}
|
|
890
|
+
disableRecovery(orgId) {
|
|
891
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/recovery`);
|
|
892
|
+
}
|
|
893
|
+
/** The calling principal's own wrapped recovery share (custodian only). */
|
|
894
|
+
getMyRecoveryShare(orgId) {
|
|
895
|
+
return this.request("GET", `/v1/orgs/${orgId}/recovery/share`);
|
|
896
|
+
}
|
|
897
|
+
/** Every environment DEK wrapped to the org recovery key (admin only). */
|
|
898
|
+
getRecoveryEnvKeys(orgId) {
|
|
899
|
+
return this.request("GET", `/v1/orgs/${orgId}/recovery/env-keys`);
|
|
900
|
+
}
|
|
901
|
+
/** Backfill recovery grants for environments the caller can decrypt. */
|
|
902
|
+
uploadRecoveryGrants(orgId, input) {
|
|
903
|
+
return this.request("POST", `/v1/orgs/${orgId}/recovery/grants`, input);
|
|
904
|
+
}
|
|
905
|
+
listRecoveryRequests(orgId) {
|
|
906
|
+
return this.request("GET", `/v1/orgs/${orgId}/recovery/requests`);
|
|
907
|
+
}
|
|
908
|
+
createRecoveryRequest(orgId, input) {
|
|
909
|
+
return this.request("POST", `/v1/orgs/${orgId}/recovery/requests`, input);
|
|
910
|
+
}
|
|
911
|
+
getRecoveryRequest(orgId, requestId) {
|
|
912
|
+
return this.request("GET", `/v1/orgs/${orgId}/recovery/requests/${requestId}`);
|
|
913
|
+
}
|
|
914
|
+
/** A custodian contributes their share, re-wrapped to the request target. */
|
|
915
|
+
contributeRecoveryShare(orgId, requestId, input) {
|
|
916
|
+
return this.request("POST", `/v1/orgs/${orgId}/recovery/requests/${requestId}/shares`, input);
|
|
917
|
+
}
|
|
918
|
+
/** The target finalizes recovery, re-granting itself the recovered DEKs. */
|
|
919
|
+
completeRecoveryRequest(orgId, requestId, input) {
|
|
920
|
+
return this.request("POST", `/v1/orgs/${orgId}/recovery/requests/${requestId}/complete`, input);
|
|
921
|
+
}
|
|
922
|
+
cancelRecoveryRequest(orgId, requestId) {
|
|
923
|
+
return this.request("POST", `/v1/orgs/${orgId}/recovery/requests/${requestId}/cancel`);
|
|
924
|
+
}
|
|
925
|
+
listTokens(orgId) {
|
|
926
|
+
return this.request("GET", `/v1/orgs/${orgId}/tokens`);
|
|
927
|
+
}
|
|
928
|
+
createToken(orgId, input) {
|
|
929
|
+
return this.request("POST", `/v1/orgs/${orgId}/tokens`, input);
|
|
930
|
+
}
|
|
931
|
+
revokeToken(orgId, tokenId) {
|
|
932
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
|
|
933
|
+
}
|
|
934
|
+
/** Permanently delete a token. Only allowed once it has been revoked. */
|
|
935
|
+
deleteToken(orgId, tokenId) {
|
|
936
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}/permanent`);
|
|
937
|
+
}
|
|
938
|
+
/** Keys the caller can see: all org keys for admins, granted keys otherwise. */
|
|
939
|
+
listKmsKeys(orgId) {
|
|
940
|
+
return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
|
|
941
|
+
}
|
|
942
|
+
createKmsKey(orgId, input) {
|
|
943
|
+
return this.request("POST", `/v1/orgs/${orgId}/kms/keys`, input);
|
|
944
|
+
}
|
|
945
|
+
/** Key metadata + every version (admin). */
|
|
946
|
+
getKmsKey(orgId, keyId) {
|
|
947
|
+
return this.request("GET", `/v1/orgs/${orgId}/kms/keys/${keyId}`);
|
|
948
|
+
}
|
|
949
|
+
/** The caller's wrapped key material for a key, across granted versions. */
|
|
950
|
+
getMyKmsKey(orgId, keyId) {
|
|
951
|
+
return this.request("GET", `/v1/orgs/${orgId}/kms/keys/${keyId}/key`);
|
|
952
|
+
}
|
|
953
|
+
/** Published public keys of a `sign` key (grant-free within the org). */
|
|
954
|
+
getKmsPublicKeys(orgId, keyId) {
|
|
955
|
+
return this.request("GET", `/v1/orgs/${orgId}/kms/keys/${keyId}/public`);
|
|
956
|
+
}
|
|
957
|
+
listKmsGrants(orgId, keyId) {
|
|
958
|
+
return this.request("GET", `/v1/orgs/${orgId}/kms/keys/${keyId}/grants`);
|
|
959
|
+
}
|
|
960
|
+
grantKmsKey(orgId, keyId, input) {
|
|
961
|
+
return this.request("POST", `/v1/orgs/${orgId}/kms/keys/${keyId}/grants`, input);
|
|
962
|
+
}
|
|
963
|
+
/** Revoke a principal entirely (all versions). */
|
|
964
|
+
revokeKmsKey(orgId, keyId, principal) {
|
|
965
|
+
const qs = new URLSearchParams(principal).toString();
|
|
966
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/kms/keys/${keyId}/grants?${qs}`);
|
|
967
|
+
}
|
|
968
|
+
rotateKmsKey(orgId, keyId, input) {
|
|
969
|
+
return this.request("POST", `/v1/orgs/${orgId}/kms/keys/${keyId}/rotate`, input);
|
|
970
|
+
}
|
|
971
|
+
disableKmsKey(orgId, keyId) {
|
|
972
|
+
return this.request("POST", `/v1/orgs/${orgId}/kms/keys/${keyId}/disable`);
|
|
973
|
+
}
|
|
974
|
+
/** The broker's public key — wrap the admin credential to it before registering a target. */
|
|
975
|
+
getLeaseBrokerKey(orgId) {
|
|
976
|
+
return this.request("GET", `/v1/orgs/${orgId}/leases/broker-key`);
|
|
977
|
+
}
|
|
978
|
+
listLeaseTargets(orgId) {
|
|
979
|
+
return this.request("GET", `/v1/orgs/${orgId}/leases/targets`);
|
|
980
|
+
}
|
|
981
|
+
registerLeaseTarget(orgId, input) {
|
|
982
|
+
return this.request("POST", `/v1/orgs/${orgId}/leases/targets`, input);
|
|
983
|
+
}
|
|
984
|
+
deleteLeaseTarget(orgId, targetId) {
|
|
985
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/leases/targets/${targetId}`);
|
|
986
|
+
}
|
|
987
|
+
listLeases(orgId) {
|
|
988
|
+
return this.request("GET", `/v1/orgs/${orgId}/leases`);
|
|
989
|
+
}
|
|
990
|
+
mintLease(orgId, input) {
|
|
991
|
+
return this.request("POST", `/v1/orgs/${orgId}/leases`, input);
|
|
992
|
+
}
|
|
993
|
+
revokeLease(orgId, leaseId) {
|
|
994
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/leases/${leaseId}`);
|
|
995
|
+
}
|
|
996
|
+
listAudit(orgId, query = {}) {
|
|
997
|
+
const params = new URLSearchParams();
|
|
998
|
+
if (query.cursor) params.set("cursor", query.cursor);
|
|
999
|
+
if (query.limit) params.set("limit", String(query.limit));
|
|
1000
|
+
if (query.action) params.set("action", query.action);
|
|
1001
|
+
if (query.resourceType) params.set("resourceType", query.resourceType);
|
|
1002
|
+
const qs = params.size > 0 ? `?${params}` : "";
|
|
1003
|
+
return this.request("GET", `/v1/orgs/${orgId}/audit${qs}`);
|
|
1004
|
+
}
|
|
1005
|
+
getLogSink(orgId) {
|
|
1006
|
+
return this.request("GET", `/v1/orgs/${orgId}/log-sink`);
|
|
1007
|
+
}
|
|
1008
|
+
setLogSink(orgId, input) {
|
|
1009
|
+
return this.request("PUT", `/v1/orgs/${orgId}/log-sink`, input);
|
|
1010
|
+
}
|
|
1011
|
+
deleteLogSink(orgId) {
|
|
1012
|
+
return this.request("DELETE", `/v1/orgs/${orgId}/log-sink`);
|
|
1013
|
+
}
|
|
1014
|
+
/** Send a synthetic record to the configured endpoint to verify connectivity. */
|
|
1015
|
+
testLogSink(orgId) {
|
|
1016
|
+
return this.request("POST", `/v1/orgs/${orgId}/log-sink/test`);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* The org's plan, effective entitlements, current metered usage ("X of Y"),
|
|
1020
|
+
* overrides, and which self-serve actions are available (`manage`). Readable
|
|
1021
|
+
* by any member; `enforced` reports whether limits are currently active
|
|
1022
|
+
* (false until plans are turned on, when every org has full access).
|
|
1023
|
+
*/
|
|
1024
|
+
getBilling(orgId) {
|
|
1025
|
+
return this.request("GET", `/v1/orgs/${orgId}/billing`);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Start self-serve checkout to upgrade the org to a plan family. Returns a
|
|
1029
|
+
* biller-hosted URL to redirect the browser to. Admin-only; the biller must
|
|
1030
|
+
* be configured (see `getBilling().manage`). The resulting subscription links
|
|
1031
|
+
* back to the org via the checkout webhook.
|
|
1032
|
+
*/
|
|
1033
|
+
startCheckout(orgId, input) {
|
|
1034
|
+
return this.request("POST", `/v1/orgs/${orgId}/billing/checkout`, input);
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Open the biller's Billing Portal to manage the existing subscription
|
|
1038
|
+
* (update card, change plan, cancel). Returns a URL to redirect to. Admin-only
|
|
1039
|
+
* and only once a biller customer is linked (see `getBilling().manage.portal`).
|
|
1040
|
+
*/
|
|
1041
|
+
openBillingPortal(orgId) {
|
|
1042
|
+
return this.request("POST", `/v1/orgs/${orgId}/billing/portal`);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Downgrade the org to the Free (default) plan: cancels any active paid
|
|
1046
|
+
* subscription in the biller and reverts the org to Free immediately.
|
|
1047
|
+
* Admin-only. Refetch `getBilling` for the new state.
|
|
1048
|
+
*/
|
|
1049
|
+
cancelSubscription(orgId) {
|
|
1050
|
+
return this.request("POST", `/v1/orgs/${orgId}/billing/cancel`);
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
//#endregion
|
|
1054
|
+
//#region ../cli/src/io.ts
|
|
1055
|
+
let failThrows = false;
|
|
1056
|
+
/**
|
|
1057
|
+
* In `seekrit mcp` the process is a long-lived stdio server, so a `fail()`
|
|
1058
|
+
* must surface as a catchable error (→ a tool error result) rather than
|
|
1059
|
+
* exiting and killing every other tool. Toggled on once at MCP startup.
|
|
1060
|
+
*/
|
|
1061
|
+
function setFailThrows(value) {
|
|
1062
|
+
failThrows = value;
|
|
1063
|
+
}
|
|
1064
|
+
function fail(message) {
|
|
1065
|
+
if (failThrows) throw new Error(message);
|
|
1066
|
+
console.error(`error: ${message}`);
|
|
1067
|
+
process.exit(1);
|
|
1068
|
+
}
|
|
1069
|
+
/** Prompt without echoing input (for passphrases). */
|
|
1070
|
+
function promptHidden(question) {
|
|
1071
|
+
const muted = new Writable({ write(_chunk, _encoding, callback) {
|
|
1072
|
+
callback();
|
|
1073
|
+
} });
|
|
1074
|
+
process.stderr.write(question);
|
|
1075
|
+
const rl = createInterface({
|
|
1076
|
+
input: process.stdin,
|
|
1077
|
+
output: muted,
|
|
1078
|
+
terminal: true
|
|
1079
|
+
});
|
|
1080
|
+
return new Promise((resolve) => {
|
|
1081
|
+
rl.question("", (answer) => {
|
|
1082
|
+
rl.close();
|
|
1083
|
+
process.stderr.write("\n");
|
|
1084
|
+
resolve(answer);
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
//#endregion
|
|
1089
|
+
//#region ../cli/src/context.ts
|
|
1090
|
+
/**
|
|
1091
|
+
* Build the client context from configured credentials, or return null when
|
|
1092
|
+
* none are set. `seekrit run` uses this to degrade to a plain launcher instead
|
|
1093
|
+
* of exiting; every other command goes through `buildContext`, which fails.
|
|
1094
|
+
*
|
|
1095
|
+
* `dotenvVars` supplies `SEEKRIT_*` values read from a `.env` file. They sit
|
|
1096
|
+
* below the live `process.env` but above the saved config, so a project-local
|
|
1097
|
+
* `.env` can carry the token / API URL — matching `seekrit-run`'s
|
|
1098
|
+
* `flag > env > .env` credential resolution. Empty for every command but
|
|
1099
|
+
* `seekrit run`, which loads `.env` before authenticating.
|
|
1100
|
+
*/
|
|
1101
|
+
function tryBuildContext(dotenvVars = {}) {
|
|
1102
|
+
const config = readGlobalConfig();
|
|
1103
|
+
const fromEnv = (key) => process.env[key] ?? dotenvVars[key];
|
|
1104
|
+
const apiUrl = fromEnv("SEEKRIT_API_URL") ?? config.apiUrl ?? "https://api.seekrit.dev";
|
|
1105
|
+
const token = fromEnv("SEEKRIT_TOKEN") ?? config.token;
|
|
1106
|
+
const devUser = fromEnv("SEEKRIT_DEV_USER") ?? config.devUser;
|
|
1107
|
+
let auth;
|
|
1108
|
+
if (token) auth = {
|
|
1109
|
+
type: "bearer",
|
|
1110
|
+
token
|
|
1111
|
+
};
|
|
1112
|
+
else if (devUser) auth = {
|
|
1113
|
+
type: "dev",
|
|
1114
|
+
email: devUser
|
|
1115
|
+
};
|
|
1116
|
+
else return null;
|
|
1117
|
+
return {
|
|
1118
|
+
client: new SeekritClient({
|
|
1119
|
+
baseUrl: apiUrl,
|
|
1120
|
+
auth
|
|
1121
|
+
}),
|
|
1122
|
+
auth
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
function isTokenAuth(ctx) {
|
|
1126
|
+
return ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token);
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Recover the calling principal's private key:
|
|
1130
|
+
* - service tokens carry their private key in the token string;
|
|
1131
|
+
* - users fetch their passphrase-encrypted key from the API and unlock it.
|
|
1132
|
+
*/
|
|
1133
|
+
async function getPrivateKey(ctx) {
|
|
1134
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
|
|
1135
|
+
const { privateKey } = await parseServiceToken(ctx.auth.token);
|
|
1136
|
+
return privateKey;
|
|
1137
|
+
}
|
|
1138
|
+
const { encryptedPrivateKey } = await ctx.client.getMyKeys();
|
|
1139
|
+
return importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey));
|
|
1140
|
+
}
|
|
1141
|
+
/** Recover one environment's DEK for the current principal. */
|
|
1142
|
+
async function getDek(ctx, orgId, envId) {
|
|
1143
|
+
const [{ wrappedDek }, privateKey] = await Promise.all([ctx.client.getMyEnvKey(orgId, envId), getPrivateKey(ctx)]);
|
|
1144
|
+
return unwrapDek(wrappedDek, privateKey);
|
|
1145
|
+
}
|
|
1146
|
+
//#endregion
|
|
1147
|
+
//#region ../cli/src/target.ts
|
|
1148
|
+
/** Resolve the target org from a flag, the committed config, or a lone org. */
|
|
1149
|
+
async function resolveOrg(ctx, orgSlug) {
|
|
1150
|
+
const wanted = orgSlug ?? findProjectConfig()?.org;
|
|
1151
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
1152
|
+
if (wanted) {
|
|
1153
|
+
const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
|
|
1154
|
+
if (!org) fail(`no accessible org "${wanted}"`);
|
|
1155
|
+
return {
|
|
1156
|
+
id: org.id,
|
|
1157
|
+
slug: org.slug
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
const only = orgs[0];
|
|
1161
|
+
if (orgs.length === 1 && only) return {
|
|
1162
|
+
id: only.id,
|
|
1163
|
+
slug: only.slug
|
|
1164
|
+
};
|
|
1165
|
+
fail("specify --org (or run `seekrit init`)");
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
1169
|
+
* or the config's app + `--env`) or a group env (`--group --env`).
|
|
1170
|
+
*/
|
|
1171
|
+
async function resolveEnvTarget(ctx, opts) {
|
|
1172
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
1173
|
+
if (!opts.env) fail("specify --env");
|
|
1174
|
+
if (opts.group) {
|
|
1175
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
1176
|
+
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1177
|
+
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1178
|
+
const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
|
|
1179
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1180
|
+
if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
|
|
1181
|
+
return {
|
|
1182
|
+
orgId: org.id,
|
|
1183
|
+
envId: env.id,
|
|
1184
|
+
label: `${group.slug}@${env.slug}`
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1188
|
+
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
1189
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
1190
|
+
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1191
|
+
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1192
|
+
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1193
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1194
|
+
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1195
|
+
return {
|
|
1196
|
+
orgId: org.id,
|
|
1197
|
+
envId: env.id,
|
|
1198
|
+
label: `${app.slug}/${env.slug}`
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
1202
|
+
async function resolveAppEnv(ctx, opts) {
|
|
1203
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
1204
|
+
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1205
|
+
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
1206
|
+
if (!opts.env) fail("specify --env");
|
|
1207
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
1208
|
+
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1209
|
+
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1210
|
+
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1211
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1212
|
+
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1213
|
+
return {
|
|
1214
|
+
orgId: org.id,
|
|
1215
|
+
appId: app.id,
|
|
1216
|
+
appSlug: app.slug,
|
|
1217
|
+
envId: env.id,
|
|
1218
|
+
envSlug: env.slug
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
/** Resolve a group by slug within the target org. */
|
|
1222
|
+
async function resolveGroup(ctx, opts) {
|
|
1223
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
1224
|
+
if (!opts.group) fail("specify --group");
|
|
1225
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
1226
|
+
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1227
|
+
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1228
|
+
return {
|
|
1229
|
+
orgId: org.id,
|
|
1230
|
+
id: group.id,
|
|
1231
|
+
slug: group.slug
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
//#endregion
|
|
1235
|
+
//#region ../cli/src/kms.ts
|
|
1236
|
+
/** The calling principal's identity + public key (for a self-grant). */
|
|
1237
|
+
async function kmsCallerIdentity(ctx) {
|
|
1238
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
|
|
1239
|
+
const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
|
|
1240
|
+
const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
1241
|
+
return {
|
|
1242
|
+
principalType: "service_token",
|
|
1243
|
+
principalId: tokenId,
|
|
1244
|
+
publicKeyJwk: JSON.stringify(pub)
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
const { user } = await ctx.client.me();
|
|
1248
|
+
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
1249
|
+
return {
|
|
1250
|
+
principalType: "user",
|
|
1251
|
+
principalId: user.id,
|
|
1252
|
+
publicKeyJwk: user.publicKeyJwk
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
/** Look up an org member (by email) or service token (by id) as a grant recipient. */
|
|
1256
|
+
async function kmsResolveRecipient(ctx, orgId, who) {
|
|
1257
|
+
if (who.user) {
|
|
1258
|
+
const { members } = await ctx.client.listMembers(orgId);
|
|
1259
|
+
const m = members.find((x) => x.email === who.user);
|
|
1260
|
+
if (!m) fail(`no member ${who.user}`);
|
|
1261
|
+
if (!m.publicKeyJwk) fail(`${who.user} has not completed key setup`);
|
|
1262
|
+
return {
|
|
1263
|
+
principalType: "user",
|
|
1264
|
+
principalId: m.userId,
|
|
1265
|
+
publicKeyJwk: m.publicKeyJwk
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
if (who.token) {
|
|
1269
|
+
const { tokens } = await ctx.client.listTokens(orgId);
|
|
1270
|
+
const t = tokens.find((x) => x.id === who.token);
|
|
1271
|
+
if (!t) fail(`no service token ${who.token}`);
|
|
1272
|
+
return {
|
|
1273
|
+
principalType: "service_token",
|
|
1274
|
+
principalId: t.id,
|
|
1275
|
+
publicKeyJwk: t.publicKeyJwk
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
fail("specify --user <email> or --token <id>");
|
|
1279
|
+
}
|
|
1280
|
+
async function kmsResolveKey(ctx, orgId, ref) {
|
|
1281
|
+
const { keys } = await ctx.client.listKmsKeys(orgId);
|
|
1282
|
+
const key = keys.find((k) => k.id === ref || k.name === ref);
|
|
1283
|
+
if (!key) fail(`no KMS key "${ref}"`);
|
|
1284
|
+
return key;
|
|
1285
|
+
}
|
|
1286
|
+
/** Recover a key's material for one version (default: current), for the caller. */
|
|
1287
|
+
async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
|
|
1288
|
+
const mat = await ctx.client.getMyKmsKey(orgId, keyId);
|
|
1289
|
+
const v = version ?? mat.currentVersion;
|
|
1290
|
+
const grant = mat.grants.find((g) => g.version === v);
|
|
1291
|
+
if (!grant) fail(`no grant for version ${v} of this key`);
|
|
1292
|
+
return {
|
|
1293
|
+
material: await unwrapDek(grant.wrappedKey, await getPrivateKey(ctx)),
|
|
1294
|
+
version: v,
|
|
1295
|
+
currentVersion: mat.currentVersion
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region ../cli/src/dotenv.ts
|
|
1300
|
+
/**
|
|
1301
|
+
* Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
|
|
1302
|
+
* prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
|
|
1303
|
+
* escapes; unquoted values drop trailing ` # comments`). Multiline values are
|
|
1304
|
+
* not supported — keep those in seekrit itself.
|
|
1305
|
+
*/
|
|
1306
|
+
function parseDotenv(content) {
|
|
1307
|
+
const out = {};
|
|
1308
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
1309
|
+
let line = raw.trim();
|
|
1310
|
+
if (!line || line.startsWith("#")) continue;
|
|
1311
|
+
if (line.startsWith("export ")) line = line.slice(7).trimStart();
|
|
1312
|
+
const eq = line.indexOf("=");
|
|
1313
|
+
if (eq === -1) continue;
|
|
1314
|
+
const key = line.slice(0, eq).trim();
|
|
1315
|
+
if (!key) continue;
|
|
1316
|
+
let value = line.slice(eq + 1).trim();
|
|
1317
|
+
const quote = value[0];
|
|
1318
|
+
if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
|
|
1319
|
+
value = value.slice(1, -1);
|
|
1320
|
+
if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
1321
|
+
} else {
|
|
1322
|
+
const comment = value.indexOf(" #");
|
|
1323
|
+
if (comment !== -1) value = value.slice(0, comment).trim();
|
|
1324
|
+
}
|
|
1325
|
+
out[key] = value;
|
|
1326
|
+
}
|
|
1327
|
+
return out;
|
|
1328
|
+
}
|
|
1329
|
+
//#endregion
|
|
1330
|
+
//#region ../cli/src/secrets.ts
|
|
1331
|
+
/** Fetch + decrypt every secret in a single environment. */
|
|
1332
|
+
async function fetchDecryptedSecrets(ctx, orgId, envId) {
|
|
1333
|
+
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
1334
|
+
const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
|
|
1335
|
+
return Object.fromEntries(entries);
|
|
1336
|
+
}
|
|
1337
|
+
async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
1338
|
+
const ciphertext = await encryptSecret(await getDek(ctx, orgId, envId), value, secretAad(envId, name));
|
|
1339
|
+
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Resolve the full, layered environment for a running app: composed group
|
|
1343
|
+
* secrets (lowest precedence) → the app env's own secrets → `.env` files.
|
|
1344
|
+
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
1345
|
+
* ciphertext decrypted locally. `process.env` is NOT applied here — callers
|
|
1346
|
+
* that spawn a process layer it on top so the live shell always wins.
|
|
1347
|
+
*/
|
|
1348
|
+
async function materializeEnv(ctx, opts) {
|
|
1349
|
+
const query = {};
|
|
1350
|
+
if (opts.with && Object.keys(opts.with).length > 0) query.with = opts.with;
|
|
1351
|
+
if (!isTokenAuth(ctx)) {
|
|
1352
|
+
if (!opts.envId) fail("specify --app and --env, or authenticate with a service token (SEEKRIT_TOKEN)");
|
|
1353
|
+
query.env = opts.envId;
|
|
1354
|
+
}
|
|
1355
|
+
const { scope, layers } = await ctx.client.resolve(query);
|
|
1356
|
+
const privateKey = await getPrivateKey(ctx);
|
|
1357
|
+
const values = {};
|
|
1358
|
+
const provenance = {};
|
|
1359
|
+
for (const layer of layers) {
|
|
1360
|
+
const dek = await unwrapDek(layer.wrappedDek, privateKey);
|
|
1361
|
+
const label = layer.source === "group" ? `group:${layer.groupSlug}@${layer.slug}` : `app:${scope.appSlug}/${layer.slug}`;
|
|
1362
|
+
for (const secret of layer.secrets) {
|
|
1363
|
+
values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
|
|
1364
|
+
provenance[secret.name] = label;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return {
|
|
1368
|
+
values,
|
|
1369
|
+
provenance,
|
|
1370
|
+
scope,
|
|
1371
|
+
loadedEnvFiles: overlayEnvFiles(values, provenance, opts.envFiles)
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Overlay `.env` files onto an existing value/provenance set (later files win).
|
|
1376
|
+
* Missing files are skipped. Returns the files that were actually loaded. Used
|
|
1377
|
+
* both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
|
|
1378
|
+
* managed secrets are unavailable but `.env` should still apply.
|
|
1379
|
+
*/
|
|
1380
|
+
function overlayEnvFiles(values, provenance, envFiles) {
|
|
1381
|
+
const loaded = [];
|
|
1382
|
+
for (const file of envFiles) {
|
|
1383
|
+
if (!existsSync(file)) continue;
|
|
1384
|
+
loaded.push(file);
|
|
1385
|
+
for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
|
|
1386
|
+
values[name] = value;
|
|
1387
|
+
provenance[name] = `dotenv:${file}`;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return loaded;
|
|
1391
|
+
}
|
|
1392
|
+
//#endregion
|
|
1393
|
+
//#region ../cli/src/mcp.ts
|
|
1394
|
+
/** Lowercase-alphanumeric string for a fresh Postgres role name. */
|
|
1395
|
+
function randomLower(length) {
|
|
1396
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
1397
|
+
let out = "";
|
|
1398
|
+
for (const b of crypto.getRandomValues(new Uint8Array(length))) out += alphabet[b % 36];
|
|
1399
|
+
return out;
|
|
1400
|
+
}
|
|
1401
|
+
function jsonText(data) {
|
|
1402
|
+
return { content: [{
|
|
1403
|
+
type: "text",
|
|
1404
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2)
|
|
1405
|
+
}] };
|
|
1406
|
+
}
|
|
1407
|
+
function errText(err) {
|
|
1408
|
+
return {
|
|
1409
|
+
content: [{
|
|
1410
|
+
type: "text",
|
|
1411
|
+
text: `error: ${err instanceof Error ? err.message : String(err)}`
|
|
1412
|
+
}],
|
|
1413
|
+
isError: true
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
/** Build the client context or throw a friendly, agent-readable error. */
|
|
1417
|
+
function getCtx() {
|
|
1418
|
+
const ctx = tryBuildContext();
|
|
1419
|
+
if (!ctx) throw new Error("no credentials — set SEEKRIT_TOKEN (a skt_… token) or SEEKRIT_DEV_USER in the MCP server env, or run `seekrit login` first");
|
|
1420
|
+
return ctx;
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Guard tools that decrypt: under user auth we cannot prompt for a passphrase
|
|
1424
|
+
* (stdin is the transport), so it must be supplied out-of-band.
|
|
1425
|
+
*/
|
|
1426
|
+
function ensureDecryptable(ctx) {
|
|
1427
|
+
if (!isTokenAuth(ctx) && !process.env.SEEKRIT_PASSPHRASE) throw new Error("this operation decrypts data; set SEEKRIT_PASSPHRASE in the MCP server env, or use a service token (SEEKRIT_TOKEN)");
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* The calling principal's public key JWK — needed to wrap a freshly generated
|
|
1431
|
+
* DEK when creating an environment. Service tokens carry their private key, so
|
|
1432
|
+
* we derive the public half from it; users publish theirs at key setup.
|
|
1433
|
+
*/
|
|
1434
|
+
async function principalPublicKeyJwk(ctx) {
|
|
1435
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
|
|
1436
|
+
const { privateKey } = await parseServiceToken(ctx.auth.token);
|
|
1437
|
+
const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
1438
|
+
return JSON.stringify(pub);
|
|
1439
|
+
}
|
|
1440
|
+
const { user } = await ctx.client.me();
|
|
1441
|
+
if (!user.publicKeyJwk) throw new Error("this user has no keypair yet — run `seekrit keys setup` first");
|
|
1442
|
+
return user.publicKeyJwk;
|
|
1443
|
+
}
|
|
1444
|
+
/** Run a child command with `env` and capture its output (values never returned). */
|
|
1445
|
+
function runChild(cmd, args, env, cwd) {
|
|
1446
|
+
return new Promise((resolve, reject) => {
|
|
1447
|
+
const child = spawn(cmd, args, {
|
|
1448
|
+
env,
|
|
1449
|
+
cwd,
|
|
1450
|
+
stdio: [
|
|
1451
|
+
"ignore",
|
|
1452
|
+
"pipe",
|
|
1453
|
+
"pipe"
|
|
1454
|
+
]
|
|
1455
|
+
});
|
|
1456
|
+
let stdout = "";
|
|
1457
|
+
let stderr = "";
|
|
1458
|
+
const cap = 64e3;
|
|
1459
|
+
child.stdout.on("data", (d) => {
|
|
1460
|
+
if (stdout.length < cap) stdout += d.toString("utf8");
|
|
1461
|
+
});
|
|
1462
|
+
child.stderr.on("data", (d) => {
|
|
1463
|
+
if (stderr.length < cap) stderr += d.toString("utf8");
|
|
1464
|
+
});
|
|
1465
|
+
child.on("error", reject);
|
|
1466
|
+
child.on("close", (code) => resolve({
|
|
1467
|
+
exitCode: code ?? 1,
|
|
1468
|
+
stdout,
|
|
1469
|
+
stderr
|
|
1470
|
+
}));
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
/** Resolve the layered environment for run/export (token path infers the env). */
|
|
1474
|
+
async function materializeFor(ctx, o) {
|
|
1475
|
+
let envId;
|
|
1476
|
+
if (!isTokenAuth(ctx)) envId = (await resolveAppEnv(ctx, o)).envId;
|
|
1477
|
+
return materializeEnv(ctx, {
|
|
1478
|
+
envId,
|
|
1479
|
+
with: o.with,
|
|
1480
|
+
envFiles: o.envFile ?? [".env"]
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
const targetShape = {
|
|
1484
|
+
org: z.string().optional().describe("organization slug or id (defaults to a lone org)"),
|
|
1485
|
+
app: z.string().optional().describe("application slug or id (or set via configure_project)"),
|
|
1486
|
+
group: z.string().optional().describe("target a group environment instead of an app"),
|
|
1487
|
+
env: z.string().optional().describe("environment slug or id (a service token infers its own)")
|
|
1488
|
+
};
|
|
1489
|
+
/**
|
|
1490
|
+
* Resolve which environment a secret tool addresses. A service token with no
|
|
1491
|
+
* explicit app/group targets its own bound environment (no flags needed);
|
|
1492
|
+
* everyone else names app|group + env.
|
|
1493
|
+
*/
|
|
1494
|
+
async function resolveTargetEnv(ctx, o) {
|
|
1495
|
+
if (isTokenAuth(ctx) && !o.app && !o.group) {
|
|
1496
|
+
const { scope } = await ctx.client.resolve();
|
|
1497
|
+
return {
|
|
1498
|
+
orgId: scope.orgId,
|
|
1499
|
+
envId: scope.envId,
|
|
1500
|
+
label: `${scope.appSlug}/${scope.envSlug}`
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
return resolveEnvTarget(ctx, o);
|
|
1504
|
+
}
|
|
1505
|
+
async function runMcpServer(options = {}) {
|
|
1506
|
+
setFailThrows(true);
|
|
1507
|
+
const server = new McpServer({
|
|
1508
|
+
name: "seekrit",
|
|
1509
|
+
version: options.version ?? version$1
|
|
1510
|
+
});
|
|
1511
|
+
/** Register a tool whose handler returns data (serialized) or throws (→ isError). */
|
|
1512
|
+
const tool = (name, description, shape, handler) => {
|
|
1513
|
+
server.registerTool(name, {
|
|
1514
|
+
description,
|
|
1515
|
+
inputSchema: shape
|
|
1516
|
+
}, (async (args) => {
|
|
1517
|
+
try {
|
|
1518
|
+
return jsonText(await handler(args));
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
return errText(err);
|
|
1521
|
+
}
|
|
1522
|
+
}));
|
|
1523
|
+
};
|
|
1524
|
+
tool("whoami", "Show the authenticated identity, its role, and accessible orgs.", {}, async () => {
|
|
1525
|
+
const ctx = getCtx();
|
|
1526
|
+
if (isTokenAuth(ctx) && ctx.auth.type === "bearer") {
|
|
1527
|
+
const { tokenId } = await parseServiceToken(ctx.auth.token);
|
|
1528
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
1529
|
+
let scope = null;
|
|
1530
|
+
try {
|
|
1531
|
+
scope = (await ctx.client.resolve()).scope;
|
|
1532
|
+
} catch {}
|
|
1533
|
+
return {
|
|
1534
|
+
kind: "service_token",
|
|
1535
|
+
tokenId,
|
|
1536
|
+
org: orgs[0]?.slug ?? null,
|
|
1537
|
+
boundScope: scope
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
kind: "user",
|
|
1542
|
+
...await ctx.client.me()
|
|
1543
|
+
};
|
|
1544
|
+
});
|
|
1545
|
+
tool("list_orgs", "List organizations the caller can access.", {}, async () => (await getCtx().client.listOrgs()).orgs);
|
|
1546
|
+
tool("list_apps", "List applications in an organization.", { org: z.string().optional() }, async ({ org }) => {
|
|
1547
|
+
const ctx = getCtx();
|
|
1548
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1549
|
+
return (await ctx.client.listApps(orgRef.id)).apps;
|
|
1550
|
+
});
|
|
1551
|
+
tool("list_envs", "List environments of an application.", {
|
|
1552
|
+
org: z.string().optional(),
|
|
1553
|
+
app: z.string()
|
|
1554
|
+
}, async ({ org, app }) => {
|
|
1555
|
+
const ctx = getCtx();
|
|
1556
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1557
|
+
const { apps } = await ctx.client.listApps(orgRef.id);
|
|
1558
|
+
const appRow = apps.find((a) => a.slug === app || a.id === app);
|
|
1559
|
+
if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
|
|
1560
|
+
return (await ctx.client.listEnvs(orgRef.id, appRow.id)).environments;
|
|
1561
|
+
});
|
|
1562
|
+
tool("list_groups", "List shared groups (reusable secret bags) in an organization.", { org: z.string().optional() }, async ({ org }) => {
|
|
1563
|
+
const ctx = getCtx();
|
|
1564
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1565
|
+
return (await ctx.client.listGroups(orgRef.id)).groups;
|
|
1566
|
+
});
|
|
1567
|
+
tool("list_group_envs", "List a group's environments (per-slug value sets).", {
|
|
1568
|
+
org: z.string().optional(),
|
|
1569
|
+
group: z.string()
|
|
1570
|
+
}, async ({ org, group }) => {
|
|
1571
|
+
const ctx = getCtx();
|
|
1572
|
+
const g = await resolveGroup(ctx, {
|
|
1573
|
+
org,
|
|
1574
|
+
group
|
|
1575
|
+
});
|
|
1576
|
+
return (await ctx.client.listGroupEnvs(g.orgId, g.id)).environments;
|
|
1577
|
+
});
|
|
1578
|
+
tool("list_env_groups", "List the groups composed into an application environment (precedence order).", {
|
|
1579
|
+
org: z.string().optional(),
|
|
1580
|
+
app: z.string(),
|
|
1581
|
+
env: z.string()
|
|
1582
|
+
}, async ({ org, app, env }) => {
|
|
1583
|
+
const ctx = getCtx();
|
|
1584
|
+
const target = await resolveAppEnv(ctx, {
|
|
1585
|
+
org,
|
|
1586
|
+
app,
|
|
1587
|
+
env
|
|
1588
|
+
});
|
|
1589
|
+
return (await ctx.client.listEnvGroups(target.orgId, target.envId)).groups;
|
|
1590
|
+
});
|
|
1591
|
+
tool("list_members", "List organization members and their public keys (for granting access).", { org: z.string().optional() }, async ({ org }) => {
|
|
1592
|
+
const ctx = getCtx();
|
|
1593
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1594
|
+
return (await ctx.client.listMembers(orgRef.id)).members;
|
|
1595
|
+
});
|
|
1596
|
+
tool("kms_list_keys", "List managed KMS keys the caller can see (metadata only).", { org: z.string().optional() }, async ({ org }) => {
|
|
1597
|
+
const ctx = getCtx();
|
|
1598
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1599
|
+
return (await ctx.client.listKmsKeys(orgRef.id)).keys;
|
|
1600
|
+
});
|
|
1601
|
+
tool("kms_create_key", "Create an org-scoped managed key. Material is generated locally and wrapped to each grantee (self plus any listed users/tokens); the server never sees it. Use the CLI for app/group-scoped keys.", {
|
|
1602
|
+
org: z.string().optional(),
|
|
1603
|
+
name: z.string(),
|
|
1604
|
+
purpose: z.enum(["encrypt", "sign"]),
|
|
1605
|
+
grantUsers: z.array(z.string()).optional(),
|
|
1606
|
+
grantTokens: z.array(z.string()).optional()
|
|
1607
|
+
}, async ({ org, name, purpose, grantUsers, grantTokens }) => {
|
|
1608
|
+
const ctx = getCtx();
|
|
1609
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1610
|
+
let toWrap;
|
|
1611
|
+
let publicKeyJwk;
|
|
1612
|
+
if (purpose === "encrypt") toWrap = generateEncryptKeyMaterial();
|
|
1613
|
+
else {
|
|
1614
|
+
const km = await generateSigningKeyMaterial();
|
|
1615
|
+
toWrap = km.privateKeyPkcs8;
|
|
1616
|
+
publicKeyJwk = km.publicKeyJwk;
|
|
1617
|
+
}
|
|
1618
|
+
const recipients = [await kmsCallerIdentity(ctx)];
|
|
1619
|
+
for (const u of grantUsers ?? []) recipients.push(await kmsResolveRecipient(ctx, orgRef.id, { user: u }));
|
|
1620
|
+
for (const t of grantTokens ?? []) recipients.push(await kmsResolveRecipient(ctx, orgRef.id, { token: t }));
|
|
1621
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1622
|
+
const grants = [];
|
|
1623
|
+
for (const r of recipients) {
|
|
1624
|
+
const dedupeKey = `${r.principalType}:${r.principalId}`;
|
|
1625
|
+
if (seen.has(dedupeKey)) continue;
|
|
1626
|
+
seen.add(dedupeKey);
|
|
1627
|
+
grants.push({
|
|
1628
|
+
principalType: r.principalType,
|
|
1629
|
+
principalId: r.principalId,
|
|
1630
|
+
wrappedKey: await wrapDek(toWrap, r.publicKeyJwk)
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
const { key } = await ctx.client.createKmsKey(orgRef.id, {
|
|
1634
|
+
name,
|
|
1635
|
+
purpose,
|
|
1636
|
+
spec: purpose === "sign" ? "ecdsa-p256" : "aes-256-gcm",
|
|
1637
|
+
...publicKeyJwk ? { publicKeyJwk } : {},
|
|
1638
|
+
grants
|
|
1639
|
+
});
|
|
1640
|
+
return key;
|
|
1641
|
+
});
|
|
1642
|
+
tool("kms_grant", "Grant a principal (user email or token id) use of a key's current version.", {
|
|
1643
|
+
org: z.string().optional(),
|
|
1644
|
+
key: z.string(),
|
|
1645
|
+
user: z.string().optional(),
|
|
1646
|
+
token: z.string().optional()
|
|
1647
|
+
}, async ({ org, key, user, token }) => {
|
|
1648
|
+
const ctx = getCtx();
|
|
1649
|
+
ensureDecryptable(ctx);
|
|
1650
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1651
|
+
const k = await kmsResolveKey(ctx, orgRef.id, key);
|
|
1652
|
+
const recipient = await kmsResolveRecipient(ctx, orgRef.id, {
|
|
1653
|
+
user,
|
|
1654
|
+
token
|
|
1655
|
+
});
|
|
1656
|
+
const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id);
|
|
1657
|
+
await ctx.client.grantKmsKey(orgRef.id, k.id, {
|
|
1658
|
+
principalType: recipient.principalType,
|
|
1659
|
+
principalId: recipient.principalId,
|
|
1660
|
+
wrappedKey: await wrapDek(material, recipient.publicKeyJwk)
|
|
1661
|
+
});
|
|
1662
|
+
return {
|
|
1663
|
+
granted: recipient.principalId,
|
|
1664
|
+
key: k.name
|
|
1665
|
+
};
|
|
1666
|
+
});
|
|
1667
|
+
tool("kms_encrypt", "Encrypt a value under a managed encrypt key; returns a ce1 ciphertext blob. `context` (if given) is bound as AAD and must be supplied identically to decrypt.", {
|
|
1668
|
+
org: z.string().optional(),
|
|
1669
|
+
key: z.string(),
|
|
1670
|
+
plaintext: z.string(),
|
|
1671
|
+
context: z.string().optional()
|
|
1672
|
+
}, async ({ org, key, plaintext, context }) => {
|
|
1673
|
+
const ctx = getCtx();
|
|
1674
|
+
ensureDecryptable(ctx);
|
|
1675
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1676
|
+
const k = await kmsResolveKey(ctx, orgRef.id, key);
|
|
1677
|
+
if (k.purpose !== "encrypt") throw new Error(`${k.name} is a ${k.purpose} key`);
|
|
1678
|
+
const { material, currentVersion } = await kmsRecoverMaterial(ctx, orgRef.id, k.id);
|
|
1679
|
+
return { ciphertext: await kmsEncrypt(material, {
|
|
1680
|
+
keyId: k.id,
|
|
1681
|
+
version: currentVersion
|
|
1682
|
+
}, plaintext, context ?? "") };
|
|
1683
|
+
});
|
|
1684
|
+
tool("kms_decrypt", "Decrypt a ce1 blob. Supply the same `context` used to encrypt.", {
|
|
1685
|
+
org: z.string().optional(),
|
|
1686
|
+
key: z.string(),
|
|
1687
|
+
ciphertext: z.string(),
|
|
1688
|
+
context: z.string().optional()
|
|
1689
|
+
}, async ({ org, key, ciphertext, context }) => {
|
|
1690
|
+
const ctx = getCtx();
|
|
1691
|
+
ensureDecryptable(ctx);
|
|
1692
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1693
|
+
const k = await kmsResolveKey(ctx, orgRef.id, key);
|
|
1694
|
+
const ref = kmsBlobKeyRef(ciphertext);
|
|
1695
|
+
const { material } = await kmsRecoverMaterial(ctx, orgRef.id, k.id, ref.version);
|
|
1696
|
+
return { plaintext: await kmsDecrypt(material, ciphertext, context ?? "") };
|
|
1697
|
+
});
|
|
1698
|
+
tool("kms_generate_data_key", "Generate a data key under a managed encrypt key (envelope encryption). Returns the plaintext key (base64) and its wrapped form to store.", {
|
|
1699
|
+
org: z.string().optional(),
|
|
1700
|
+
key: z.string()
|
|
1701
|
+
}, async ({ org, key }) => {
|
|
1702
|
+
const ctx = getCtx();
|
|
1703
|
+
ensureDecryptable(ctx);
|
|
1704
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1705
|
+
const k = await kmsResolveKey(ctx, orgRef.id, key);
|
|
1706
|
+
if (k.purpose !== "encrypt") throw new Error(`${k.name} is a ${k.purpose} key`);
|
|
1707
|
+
const { material, currentVersion } = await kmsRecoverMaterial(ctx, orgRef.id, k.id);
|
|
1708
|
+
const dk = await generateDataKey(material, {
|
|
1709
|
+
keyId: k.id,
|
|
1710
|
+
version: currentVersion
|
|
1711
|
+
});
|
|
1712
|
+
return {
|
|
1713
|
+
plaintextBase64: toBase64(dk.plaintext),
|
|
1714
|
+
wrapped: dk.wrapped
|
|
1715
|
+
};
|
|
1716
|
+
});
|
|
1717
|
+
tool("kms_sign", "Sign a message with a managed signing key; returns an sg1 signature blob.", {
|
|
1718
|
+
org: z.string().optional(),
|
|
1719
|
+
key: z.string(),
|
|
1720
|
+
message: z.string()
|
|
1721
|
+
}, async ({ org, key, message }) => {
|
|
1722
|
+
const ctx = getCtx();
|
|
1723
|
+
ensureDecryptable(ctx);
|
|
1724
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1725
|
+
const k = await kmsResolveKey(ctx, orgRef.id, key);
|
|
1726
|
+
if (k.purpose !== "sign") throw new Error(`${k.name} is a ${k.purpose} key`);
|
|
1727
|
+
const { material, currentVersion } = await kmsRecoverMaterial(ctx, orgRef.id, k.id);
|
|
1728
|
+
return { signature: await signMessage(await importSigningKey(material), {
|
|
1729
|
+
keyId: k.id,
|
|
1730
|
+
version: currentVersion
|
|
1731
|
+
}, message) };
|
|
1732
|
+
});
|
|
1733
|
+
tool("kms_verify", "Verify an sg1 signature over a message using a signing key's published public key (no grant needed).", {
|
|
1734
|
+
org: z.string().optional(),
|
|
1735
|
+
key: z.string(),
|
|
1736
|
+
signature: z.string(),
|
|
1737
|
+
message: z.string()
|
|
1738
|
+
}, async ({ org, key, signature, message }) => {
|
|
1739
|
+
const ctx = getCtx();
|
|
1740
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1741
|
+
const k = await kmsResolveKey(ctx, orgRef.id, key);
|
|
1742
|
+
const ref = signatureKeyRef(signature);
|
|
1743
|
+
const { versions } = await ctx.client.getKmsPublicKeys(orgRef.id, k.id);
|
|
1744
|
+
const pub = versions.find((v) => v.version === ref.version)?.publicKeyJwk;
|
|
1745
|
+
if (!pub) throw new Error(`no published public key for version ${ref.version}`);
|
|
1746
|
+
return { valid: await verifyMessage(await importVerifyingKey(pub), signature, message) };
|
|
1747
|
+
});
|
|
1748
|
+
tool("list_secrets", "List secret names + versions in an environment (never values).", targetShape, async (o) => {
|
|
1749
|
+
const ctx = getCtx();
|
|
1750
|
+
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
1751
|
+
const { secrets } = await ctx.client.listSecrets(orgId, envId);
|
|
1752
|
+
return secrets.map((s) => ({
|
|
1753
|
+
name: s.name,
|
|
1754
|
+
version: s.version,
|
|
1755
|
+
updatedAt: s.updatedAt
|
|
1756
|
+
}));
|
|
1757
|
+
});
|
|
1758
|
+
tool("list_tokens", "List an organization's service tokens (never the secret token strings).", { org: z.string().optional() }, async ({ org }) => {
|
|
1759
|
+
const ctx = getCtx();
|
|
1760
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1761
|
+
return (await ctx.client.listTokens(orgRef.id)).tokens;
|
|
1762
|
+
});
|
|
1763
|
+
tool("audit", "Read the organization's audit trail (most recent first).", {
|
|
1764
|
+
org: z.string().optional(),
|
|
1765
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
1766
|
+
action: z.string().optional().describe("filter by action, e.g. secret.updated")
|
|
1767
|
+
}, async ({ org, limit, action }) => {
|
|
1768
|
+
const ctx = getCtx();
|
|
1769
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1770
|
+
return (await ctx.client.listAudit(orgRef.id, {
|
|
1771
|
+
limit: limit ?? 50,
|
|
1772
|
+
action
|
|
1773
|
+
})).entries;
|
|
1774
|
+
});
|
|
1775
|
+
tool("create_org", "Create an organization. Requires a user session — service tokens cannot own a Stytch org.", {
|
|
1776
|
+
name: z.string(),
|
|
1777
|
+
slug: z.string()
|
|
1778
|
+
}, async ({ name, slug }) => (await getCtx().client.createOrg({
|
|
1779
|
+
name,
|
|
1780
|
+
slug
|
|
1781
|
+
})).org);
|
|
1782
|
+
tool("create_app", "Create an application in an organization.", {
|
|
1783
|
+
org: z.string().optional(),
|
|
1784
|
+
name: z.string(),
|
|
1785
|
+
slug: z.string()
|
|
1786
|
+
}, async ({ org, name, slug }) => {
|
|
1787
|
+
const ctx = getCtx();
|
|
1788
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1789
|
+
return (await ctx.client.createApp(orgRef.id, {
|
|
1790
|
+
name,
|
|
1791
|
+
slug
|
|
1792
|
+
})).app;
|
|
1793
|
+
});
|
|
1794
|
+
tool("create_group", "Create a shared group (reusable secret bag) in an organization.", {
|
|
1795
|
+
org: z.string().optional(),
|
|
1796
|
+
name: z.string(),
|
|
1797
|
+
slug: z.string()
|
|
1798
|
+
}, async ({ org, name, slug }) => {
|
|
1799
|
+
const ctx = getCtx();
|
|
1800
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1801
|
+
return (await ctx.client.createGroup(orgRef.id, {
|
|
1802
|
+
name,
|
|
1803
|
+
slug
|
|
1804
|
+
})).group;
|
|
1805
|
+
});
|
|
1806
|
+
tool("create_env", "Create an application environment. Generates the data key locally and grants it to the caller.", {
|
|
1807
|
+
org: z.string().optional(),
|
|
1808
|
+
app: z.string(),
|
|
1809
|
+
name: z.string(),
|
|
1810
|
+
slug: z.string()
|
|
1811
|
+
}, async ({ org, app, name, slug }) => {
|
|
1812
|
+
const ctx = getCtx();
|
|
1813
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
1814
|
+
const { apps } = await ctx.client.listApps(orgRef.id);
|
|
1815
|
+
const appRow = apps.find((a) => a.slug === app || a.id === app);
|
|
1816
|
+
if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
|
|
1817
|
+
const wrappedDek = await wrapDek(generateDek(), await principalPublicKeyJwk(ctx));
|
|
1818
|
+
return (await ctx.client.createEnv(orgRef.id, appRow.id, {
|
|
1819
|
+
name,
|
|
1820
|
+
slug,
|
|
1821
|
+
wrappedDek
|
|
1822
|
+
})).environment;
|
|
1823
|
+
});
|
|
1824
|
+
tool("create_group_env", "Create a group environment. Generates the data key locally and grants it to the caller.", {
|
|
1825
|
+
org: z.string().optional(),
|
|
1826
|
+
group: z.string(),
|
|
1827
|
+
name: z.string(),
|
|
1828
|
+
slug: z.string()
|
|
1829
|
+
}, async ({ org, group, name, slug }) => {
|
|
1830
|
+
const ctx = getCtx();
|
|
1831
|
+
const g = await resolveGroup(ctx, {
|
|
1832
|
+
org,
|
|
1833
|
+
group
|
|
1834
|
+
});
|
|
1835
|
+
const wrappedDek = await wrapDek(generateDek(), await principalPublicKeyJwk(ctx));
|
|
1836
|
+
return (await ctx.client.createGroupEnv(g.orgId, g.id, {
|
|
1837
|
+
name,
|
|
1838
|
+
slug,
|
|
1839
|
+
wrappedDek
|
|
1840
|
+
})).environment;
|
|
1841
|
+
});
|
|
1842
|
+
tool("compose_group", "Compose a group into an application environment (higher position wins on name clashes).", {
|
|
1843
|
+
org: z.string().optional(),
|
|
1844
|
+
app: z.string(),
|
|
1845
|
+
env: z.string(),
|
|
1846
|
+
group: z.string(),
|
|
1847
|
+
position: z.number().int().min(0).optional()
|
|
1848
|
+
}, async ({ org, app, env, group, position }) => {
|
|
1849
|
+
const ctx = getCtx();
|
|
1850
|
+
const target = await resolveAppEnv(ctx, {
|
|
1851
|
+
org,
|
|
1852
|
+
app,
|
|
1853
|
+
env
|
|
1854
|
+
});
|
|
1855
|
+
const g = await resolveGroup(ctx, {
|
|
1856
|
+
org,
|
|
1857
|
+
group
|
|
1858
|
+
});
|
|
1859
|
+
return (await ctx.client.linkEnvGroup(target.orgId, target.envId, {
|
|
1860
|
+
groupId: g.id,
|
|
1861
|
+
position
|
|
1862
|
+
})).group;
|
|
1863
|
+
});
|
|
1864
|
+
tool("uncompose_group", "Remove a composed group from an application environment.", {
|
|
1865
|
+
org: z.string().optional(),
|
|
1866
|
+
app: z.string(),
|
|
1867
|
+
env: z.string(),
|
|
1868
|
+
group: z.string()
|
|
1869
|
+
}, async ({ org, app, env, group }) => {
|
|
1870
|
+
const ctx = getCtx();
|
|
1871
|
+
const target = await resolveAppEnv(ctx, {
|
|
1872
|
+
org,
|
|
1873
|
+
app,
|
|
1874
|
+
env
|
|
1875
|
+
});
|
|
1876
|
+
const g = await resolveGroup(ctx, {
|
|
1877
|
+
org,
|
|
1878
|
+
group
|
|
1879
|
+
});
|
|
1880
|
+
await ctx.client.unlinkEnvGroup(target.orgId, target.envId, g.id);
|
|
1881
|
+
return { ok: true };
|
|
1882
|
+
});
|
|
1883
|
+
tool("set_secret", "Encrypt a value locally and store it in an environment.", {
|
|
1884
|
+
...targetShape,
|
|
1885
|
+
name: z.string(),
|
|
1886
|
+
value: z.string()
|
|
1887
|
+
}, async (o) => {
|
|
1888
|
+
const ctx = getCtx();
|
|
1889
|
+
ensureDecryptable(ctx);
|
|
1890
|
+
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
1891
|
+
await encryptAndSetSecret(ctx, orgId, envId, o.name, o.value);
|
|
1892
|
+
return {
|
|
1893
|
+
ok: true,
|
|
1894
|
+
name: o.name
|
|
1895
|
+
};
|
|
1896
|
+
});
|
|
1897
|
+
tool("get_secret", "Return one secret. By default only reports presence + version; pass reveal:true to decrypt the plaintext into this response (avoid unless the value is actually needed — prefer run_command).", {
|
|
1898
|
+
...targetShape,
|
|
1899
|
+
name: z.string(),
|
|
1900
|
+
reveal: z.boolean().optional()
|
|
1901
|
+
}, async (o) => {
|
|
1902
|
+
const ctx = getCtx();
|
|
1903
|
+
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
1904
|
+
if (!o.reveal) {
|
|
1905
|
+
const { secrets } = await ctx.client.listSecrets(orgId, envId);
|
|
1906
|
+
const row = secrets.find((s) => s.name === o.name);
|
|
1907
|
+
if (!row) throw new Error(`no secret named ${o.name}`);
|
|
1908
|
+
return {
|
|
1909
|
+
name: row.name,
|
|
1910
|
+
version: row.version,
|
|
1911
|
+
revealed: false
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
ensureDecryptable(ctx);
|
|
1915
|
+
const values = await fetchDecryptedSecrets(ctx, orgId, envId);
|
|
1916
|
+
if (!(o.name in values)) throw new Error(`no secret named ${o.name}`);
|
|
1917
|
+
return {
|
|
1918
|
+
name: o.name,
|
|
1919
|
+
value: values[o.name],
|
|
1920
|
+
revealed: true
|
|
1921
|
+
};
|
|
1922
|
+
});
|
|
1923
|
+
tool("delete_secret", "Delete a secret from an environment.", {
|
|
1924
|
+
...targetShape,
|
|
1925
|
+
name: z.string()
|
|
1926
|
+
}, async (o) => {
|
|
1927
|
+
const ctx = getCtx();
|
|
1928
|
+
const { orgId, envId } = await resolveTargetEnv(ctx, o);
|
|
1929
|
+
await ctx.client.deleteSecret(orgId, envId, o.name);
|
|
1930
|
+
return {
|
|
1931
|
+
ok: true,
|
|
1932
|
+
name: o.name
|
|
1933
|
+
};
|
|
1934
|
+
});
|
|
1935
|
+
tool("run_command", "Run a command with the resolved secrets injected as environment variables, and return its exit code + captured output. Secret VALUES are never returned — this is the preferred way to use secrets. process env > .env > app env > groups.", {
|
|
1936
|
+
command: z.string().describe("executable to run"),
|
|
1937
|
+
args: z.array(z.string()).optional(),
|
|
1938
|
+
org: z.string().optional(),
|
|
1939
|
+
app: z.string().optional(),
|
|
1940
|
+
env: z.string().optional().describe("environment slug (token auth infers this)"),
|
|
1941
|
+
with: z.record(z.string(), z.string()).optional().describe("group=env slice overrides"),
|
|
1942
|
+
envFile: z.array(z.string()).optional().describe(".env files to overlay (default [.env])"),
|
|
1943
|
+
cwd: z.string().optional()
|
|
1944
|
+
}, async (o) => {
|
|
1945
|
+
const ctx = getCtx();
|
|
1946
|
+
ensureDecryptable(ctx);
|
|
1947
|
+
const { values } = await materializeFor(ctx, o);
|
|
1948
|
+
const childEnv = {
|
|
1949
|
+
...values,
|
|
1950
|
+
...process.env
|
|
1951
|
+
};
|
|
1952
|
+
return {
|
|
1953
|
+
...await runChild(o.command, o.args ?? [], childEnv, o.cwd),
|
|
1954
|
+
injectedVarCount: Object.keys(values).length
|
|
1955
|
+
};
|
|
1956
|
+
});
|
|
1957
|
+
tool("export_env", "Write the resolved secrets to a dotenv file on disk and return the variable names written (never the values). Use to materialize a .env for local tooling.", {
|
|
1958
|
+
file: z.string().describe("path to write, e.g. .env"),
|
|
1959
|
+
org: z.string().optional(),
|
|
1960
|
+
app: z.string().optional(),
|
|
1961
|
+
env: z.string().optional(),
|
|
1962
|
+
with: z.record(z.string(), z.string()).optional()
|
|
1963
|
+
}, async (o) => {
|
|
1964
|
+
const ctx = getCtx();
|
|
1965
|
+
ensureDecryptable(ctx);
|
|
1966
|
+
const { values } = await materializeFor(ctx, o);
|
|
1967
|
+
const { writeFileSync } = await import("node:fs");
|
|
1968
|
+
const body = Object.entries(values).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join("\n");
|
|
1969
|
+
writeFileSync(o.file, `${body}\n`, { mode: 384 });
|
|
1970
|
+
return {
|
|
1971
|
+
file: o.file,
|
|
1972
|
+
names: Object.keys(values).sort()
|
|
1973
|
+
};
|
|
1974
|
+
});
|
|
1975
|
+
tool("create_token", "Mint a service token, printed once. Runtime tokens bind to one app environment (auto-granted its keys, so a command/agent can decrypt it). Pass admin:true for an org-scoped provisioning token (create apps/groups/envs, grant, mint tokens) — admin tokens need no env binding.", {
|
|
1976
|
+
name: z.string().describe("display name, e.g. ci-deploy or agent-session"),
|
|
1977
|
+
org: z.string().optional(),
|
|
1978
|
+
app: z.string().optional().describe("bind to this app (runtime tokens)"),
|
|
1979
|
+
env: z.string().optional().describe("bind to this env (runtime tokens)"),
|
|
1980
|
+
admin: z.boolean().optional().describe("mint an org-scoped admin token")
|
|
1981
|
+
}, async ({ name, org, app, env, admin }) => {
|
|
1982
|
+
const ctx = getCtx();
|
|
1983
|
+
const role = admin ? "admin" : "member";
|
|
1984
|
+
const boundToEnv = Boolean(app || env);
|
|
1985
|
+
if (!admin && !boundToEnv) throw new Error("runtime tokens need app + env, or pass admin:true for an org-scoped token");
|
|
1986
|
+
if (boundToEnv && !(app && env)) throw new Error("pass both app and env to bind a token");
|
|
1987
|
+
const target = boundToEnv ? await resolveAppEnv(ctx, {
|
|
1988
|
+
org,
|
|
1989
|
+
app,
|
|
1990
|
+
env
|
|
1991
|
+
}) : { orgId: (await resolveOrg(ctx, org)).id };
|
|
1992
|
+
const created = await createServiceToken();
|
|
1993
|
+
await ctx.client.createToken(target.orgId, {
|
|
1994
|
+
name,
|
|
1995
|
+
tokenId: created.tokenId,
|
|
1996
|
+
tokenHash: created.tokenHash,
|
|
1997
|
+
publicKeyJwk: created.publicKeyJwk,
|
|
1998
|
+
role,
|
|
1999
|
+
environmentId: "envId" in target ? target.envId : null
|
|
2000
|
+
});
|
|
2001
|
+
if (boundToEnv && "envId" in target) {
|
|
2002
|
+
ensureDecryptable(ctx);
|
|
2003
|
+
const grantEnv = async (envId) => {
|
|
2004
|
+
const dek = await getDek(ctx, target.orgId, envId);
|
|
2005
|
+
await ctx.client.grantEnvKey(target.orgId, envId, {
|
|
2006
|
+
principalType: "service_token",
|
|
2007
|
+
principalId: created.tokenId,
|
|
2008
|
+
wrappedDek: await wrapDek(dek, created.publicKeyJwk)
|
|
2009
|
+
});
|
|
2010
|
+
};
|
|
2011
|
+
await grantEnv(target.envId);
|
|
2012
|
+
const { groups } = await ctx.client.listEnvGroups(target.orgId, target.envId);
|
|
2013
|
+
for (const g of groups) {
|
|
2014
|
+
const { environments } = await ctx.client.listGroupEnvs(target.orgId, g.groupId);
|
|
2015
|
+
const slice = environments.find((e) => e.slug === target.envSlug);
|
|
2016
|
+
if (slice) await grantEnv(slice.id);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
return {
|
|
2020
|
+
token: created.token,
|
|
2021
|
+
tokenId: created.tokenId,
|
|
2022
|
+
role,
|
|
2023
|
+
note: "save this now — the secret token string is not stored and cannot be retrieved"
|
|
2024
|
+
};
|
|
2025
|
+
});
|
|
2026
|
+
tool("revoke_token", "Revoke a service token by id.", {
|
|
2027
|
+
org: z.string().optional(),
|
|
2028
|
+
tokenId: z.string()
|
|
2029
|
+
}, async ({ org, tokenId }) => {
|
|
2030
|
+
const ctx = getCtx();
|
|
2031
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2032
|
+
await ctx.client.revokeToken(orgRef.id, tokenId);
|
|
2033
|
+
return {
|
|
2034
|
+
ok: true,
|
|
2035
|
+
tokenId
|
|
2036
|
+
};
|
|
2037
|
+
});
|
|
2038
|
+
tool("grant_env", "Grant a member (by email) or service token (by id) access to an environment's data key. Re-wraps the DEK to the grantee — the caller must already hold the key.", {
|
|
2039
|
+
...targetShape,
|
|
2040
|
+
user: z.string().optional().describe("org member email"),
|
|
2041
|
+
token: z.string().optional().describe("service token id (skt_…)")
|
|
2042
|
+
}, async (o) => {
|
|
2043
|
+
if (Boolean(o.user) === Boolean(o.token)) throw new Error("pass exactly one of user or token");
|
|
2044
|
+
const ctx = getCtx();
|
|
2045
|
+
ensureDecryptable(ctx);
|
|
2046
|
+
const { orgId, envId, label } = await resolveTargetEnv(ctx, o);
|
|
2047
|
+
const dek = await getDek(ctx, orgId, envId);
|
|
2048
|
+
let principalType;
|
|
2049
|
+
let principalId;
|
|
2050
|
+
let publicKeyJwk;
|
|
2051
|
+
if (o.user) {
|
|
2052
|
+
const { members } = await ctx.client.listMembers(orgId);
|
|
2053
|
+
const member = members.find((m) => m.email === o.user);
|
|
2054
|
+
if (!member) throw new Error(`no member ${o.user}`);
|
|
2055
|
+
if (!member.publicKeyJwk) throw new Error(`${o.user} has not completed key setup`);
|
|
2056
|
+
[principalType, principalId, publicKeyJwk] = [
|
|
2057
|
+
"user",
|
|
2058
|
+
member.userId,
|
|
2059
|
+
member.publicKeyJwk
|
|
2060
|
+
];
|
|
2061
|
+
} else {
|
|
2062
|
+
const { tokens } = await ctx.client.listTokens(orgId);
|
|
2063
|
+
const t = tokens.find((x) => x.id === o.token);
|
|
2064
|
+
if (!t) throw new Error(`no service token ${o.token}`);
|
|
2065
|
+
[principalType, principalId, publicKeyJwk] = [
|
|
2066
|
+
"service_token",
|
|
2067
|
+
t.id,
|
|
2068
|
+
t.publicKeyJwk
|
|
2069
|
+
];
|
|
2070
|
+
}
|
|
2071
|
+
await ctx.client.grantEnvKey(orgId, envId, {
|
|
2072
|
+
principalType,
|
|
2073
|
+
principalId,
|
|
2074
|
+
wrappedDek: await wrapDek(dek, publicKeyJwk)
|
|
2075
|
+
});
|
|
2076
|
+
return {
|
|
2077
|
+
ok: true,
|
|
2078
|
+
env: label,
|
|
2079
|
+
principalType,
|
|
2080
|
+
principalId
|
|
2081
|
+
};
|
|
2082
|
+
});
|
|
2083
|
+
tool("list_pg_targets", "List registered Postgres provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
|
|
2084
|
+
const ctx = getCtx();
|
|
2085
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2086
|
+
return (await ctx.client.listLeaseTargets(orgRef.id)).targets;
|
|
2087
|
+
});
|
|
2088
|
+
tool("create_pg_lease", "Mint a short-lived Postgres credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its SCRAM verifier is sent to the API — the plaintext never reaches seekrit or Postgres at rest. The role auto-expires; revoke early with revoke_pg_lease.", {
|
|
2089
|
+
org: z.string().optional(),
|
|
2090
|
+
target: z.string().describe("target id or name"),
|
|
2091
|
+
role: z.string().optional().describe("role name to create (default: random tmp_ name)"),
|
|
2092
|
+
ttlSeconds: z.number().int().min(60).max(604800).optional().describe("lifetime (default 3600)")
|
|
2093
|
+
}, async ({ org, target, role, ttlSeconds }) => {
|
|
2094
|
+
const ctx = getCtx();
|
|
2095
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2096
|
+
const { targets } = await ctx.client.listLeaseTargets(orgRef.id);
|
|
2097
|
+
const t = targets.find((x) => x.id === target || x.name === target);
|
|
2098
|
+
if (!t) throw new Error(`no target "${target}" in ${orgRef.slug}`);
|
|
2099
|
+
const roleName = role ?? `tmp_${randomLower(12)}`;
|
|
2100
|
+
const { password, verifier } = await generatePostgresCredential();
|
|
2101
|
+
const { lease, connection } = await ctx.client.mintLease(orgRef.id, {
|
|
2102
|
+
provider: "postgres",
|
|
2103
|
+
targetId: t.id,
|
|
2104
|
+
roleName,
|
|
2105
|
+
verifier,
|
|
2106
|
+
ttlSeconds: ttlSeconds ?? 3600
|
|
2107
|
+
});
|
|
2108
|
+
const url = `postgres://${roleName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
|
|
2109
|
+
return {
|
|
2110
|
+
leaseId: lease.id,
|
|
2111
|
+
url,
|
|
2112
|
+
username: roleName,
|
|
2113
|
+
expiresAt: connection.expiresAt,
|
|
2114
|
+
note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
|
|
2115
|
+
};
|
|
2116
|
+
});
|
|
2117
|
+
tool("list_pg_leases", "List Postgres leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
|
|
2118
|
+
const ctx = getCtx();
|
|
2119
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2120
|
+
return (await ctx.client.listLeases(orgRef.id)).leases;
|
|
2121
|
+
});
|
|
2122
|
+
tool("revoke_pg_lease", "Revoke a Postgres lease now (drops the role immediately).", {
|
|
2123
|
+
org: z.string().optional(),
|
|
2124
|
+
leaseId: z.string()
|
|
2125
|
+
}, async ({ org, leaseId }) => {
|
|
2126
|
+
const ctx = getCtx();
|
|
2127
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2128
|
+
await ctx.client.revokeLease(orgRef.id, leaseId);
|
|
2129
|
+
return {
|
|
2130
|
+
ok: true,
|
|
2131
|
+
leaseId
|
|
2132
|
+
};
|
|
2133
|
+
});
|
|
2134
|
+
tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
|
|
2135
|
+
const ctx = getCtx();
|
|
2136
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2137
|
+
return (await ctx.client.listLeaseTargets(orgRef.id)).targets.filter((t) => t.provider === "mysql");
|
|
2138
|
+
});
|
|
2139
|
+
tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", {
|
|
2140
|
+
org: z.string().optional(),
|
|
2141
|
+
target: z.string().describe("target id or name"),
|
|
2142
|
+
user: z.string().optional().describe("user name to create (default: random tmp_ name)"),
|
|
2143
|
+
ttlSeconds: z.number().int().min(60).max(604800).optional().describe("lifetime (default 3600)")
|
|
2144
|
+
}, async ({ org, target, user, ttlSeconds }) => {
|
|
2145
|
+
const ctx = getCtx();
|
|
2146
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2147
|
+
const { targets } = await ctx.client.listLeaseTargets(orgRef.id);
|
|
2148
|
+
const t = targets.find((x) => x.id === target || x.name === target);
|
|
2149
|
+
if (!t) throw new Error(`no target "${target}" in ${orgRef.slug}`);
|
|
2150
|
+
if (t.provider !== "mysql") throw new Error(`target "${target}" is not a MySQL target`);
|
|
2151
|
+
const userName = user ?? `tmp_${randomLower(12)}`;
|
|
2152
|
+
const { password, verifier } = await generateMysqlCredential();
|
|
2153
|
+
const { lease, connection } = await ctx.client.mintLease(orgRef.id, {
|
|
2154
|
+
provider: "mysql",
|
|
2155
|
+
targetId: t.id,
|
|
2156
|
+
roleName: userName,
|
|
2157
|
+
verifier,
|
|
2158
|
+
ttlSeconds: ttlSeconds ?? 3600
|
|
2159
|
+
});
|
|
2160
|
+
const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
|
|
2161
|
+
return {
|
|
2162
|
+
leaseId: lease.id,
|
|
2163
|
+
url,
|
|
2164
|
+
username: userName,
|
|
2165
|
+
expiresAt: connection.expiresAt,
|
|
2166
|
+
note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
|
|
2167
|
+
};
|
|
2168
|
+
});
|
|
2169
|
+
tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
|
|
2170
|
+
const ctx = getCtx();
|
|
2171
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2172
|
+
return (await ctx.client.listLeases(orgRef.id)).leases.filter((l) => l.provider === "mysql");
|
|
2173
|
+
});
|
|
2174
|
+
tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", {
|
|
2175
|
+
org: z.string().optional(),
|
|
2176
|
+
leaseId: z.string()
|
|
2177
|
+
}, async ({ org, leaseId }) => {
|
|
2178
|
+
const ctx = getCtx();
|
|
2179
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2180
|
+
await ctx.client.revokeLease(orgRef.id, leaseId);
|
|
2181
|
+
return {
|
|
2182
|
+
ok: true,
|
|
2183
|
+
leaseId
|
|
2184
|
+
};
|
|
2185
|
+
});
|
|
2186
|
+
tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", {
|
|
2187
|
+
org: z.string(),
|
|
2188
|
+
app: z.string(),
|
|
2189
|
+
dir: z.string().optional()
|
|
2190
|
+
}, async ({ org, app, dir }) => {
|
|
2191
|
+
const ctx = getCtx();
|
|
2192
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
2193
|
+
const { apps } = await ctx.client.listApps(orgRef.id);
|
|
2194
|
+
const appRow = apps.find((a) => a.slug === app || a.id === app);
|
|
2195
|
+
if (!appRow) throw new Error(`no app "${app}" in ${orgRef.slug}`);
|
|
2196
|
+
return {
|
|
2197
|
+
path: writeProjectConfig({
|
|
2198
|
+
org: orgRef.slug,
|
|
2199
|
+
app: appRow.slug
|
|
2200
|
+
}, dir),
|
|
2201
|
+
guidance: "Run commands with `seekrit run -- <cmd>` (or the seekrit-run binary) and a SEEKRIT_TOKEN bound to the desired environment; the token selects org+app+env."
|
|
2202
|
+
};
|
|
2203
|
+
});
|
|
2204
|
+
await server.connect(new StdioServerTransport());
|
|
2205
|
+
}
|
|
2206
|
+
//#endregion
|
|
2207
|
+
//#region src/index.ts
|
|
2208
|
+
/**
|
|
2209
|
+
* `@seekrit/mcp` — a standalone, `npx`-able entrypoint for seekrit's MCP server.
|
|
2210
|
+
*
|
|
2211
|
+
* This is a thin wrapper: the server itself (all 27 tools, the crypto plane of
|
|
2212
|
+
* the two-server design) lives in `@seekrit/cli` and is shared with the
|
|
2213
|
+
* `seekrit mcp` subcommand — this package just publishes it as its own binary so
|
|
2214
|
+
* an agent can run it with zero prior install:
|
|
2215
|
+
*
|
|
2216
|
+
* npx -y @seekrit/mcp
|
|
2217
|
+
*
|
|
2218
|
+
* Everything else — auth resolution (`SEEKRIT_TOKEN` / `SEEKRIT_API_URL` /
|
|
2219
|
+
* `SEEKRIT_DEV_USER` or `~/.config/seekrit/config.json`), the zero-knowledge
|
|
2220
|
+
* invariant (decryption only ever happens here, on the client) — is identical to
|
|
2221
|
+
* `seekrit mcp`. tsdown bundles the shared server source in at build time, so the
|
|
2222
|
+
* published package is self-contained and needs no `@seekrit/cli` install.
|
|
2223
|
+
*/
|
|
2224
|
+
runMcpServer({ version: "0.1.0" }).catch((err) => {
|
|
2225
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2226
|
+
process.stderr.write(`seekrit-mcp: fatal: ${message}\n`);
|
|
2227
|
+
process.exit(1);
|
|
2228
|
+
});
|
|
2229
|
+
//#endregion
|
|
2230
|
+
export {};
|