@seekrit/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +380 -94
- package/dist/{mcp-DNUBSbcd.js → mcp-BEcV_KpM.js} +53 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -140,6 +140,75 @@ async function importPrivateKeyPkcs8(pkcs8) {
|
|
|
140
140
|
}, true, ["deriveBits"]);
|
|
141
141
|
}
|
|
142
142
|
//#endregion
|
|
143
|
+
//#region ../../packages/crypto/src/mysql.ts
|
|
144
|
+
/**
|
|
145
|
+
* Client-side construction of a MySQL/MariaDB `mysql_native_password`
|
|
146
|
+
* authentication string, for minting *temporary MySQL login credentials*
|
|
147
|
+
* without the password plaintext ever reaching seekrit's control plane OR
|
|
148
|
+
* MySQL itself.
|
|
149
|
+
*
|
|
150
|
+
* The trick mirrors the Postgres SCRAM one (scram.ts): the stored auth string
|
|
151
|
+
* is `*<UPPER(HEX(SHA1(SHA1(password))))>`, and
|
|
152
|
+
* `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'` stores that
|
|
153
|
+
* string verbatim — MySQL does NOT re-hash it. So the flow is:
|
|
154
|
+
*
|
|
155
|
+
* 1. the machine that will connect generates a random password locally,
|
|
156
|
+
* 2. computes this hash locally,
|
|
157
|
+
* 3. sends only the hash to the broker → `CREATE USER … AS '<hash>'`,
|
|
158
|
+
* 4. connects directly to MySQL with the plaintext it never shared.
|
|
159
|
+
*
|
|
160
|
+
* Zero-knowledge at both layers: the control plane relays only the hash, and
|
|
161
|
+
* the hash is NOT sufficient to authenticate. `mysql_native_password` login is
|
|
162
|
+
* a challenge-response — the server proves knowledge of `SHA1(SHA1(password))`
|
|
163
|
+
* against a fresh scramble, and verifying a client requires `SHA1(password)`
|
|
164
|
+
* (the preimage of the first inner hash), which the stored double-SHA1 does not
|
|
165
|
+
* reveal. A dump of `mysql.user` / the query log therefore cannot log in.
|
|
166
|
+
*
|
|
167
|
+
* SHA-1 is available via WebCrypto (`crypto.subtle.digest("SHA-1", …)`) in the
|
|
168
|
+
* browser, the CLI, the MCP server, and Workers — so this runs everywhere the
|
|
169
|
+
* SCRAM helper does, with no hand-rolled hash primitive.
|
|
170
|
+
*/
|
|
171
|
+
const DEFAULT_PASSWORD_LENGTH$1 = 32;
|
|
172
|
+
const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
173
|
+
async function sha1(data) {
|
|
174
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
|
|
175
|
+
}
|
|
176
|
+
function toUpperHex(bytes) {
|
|
177
|
+
let hex = "";
|
|
178
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
179
|
+
return hex.toUpperCase();
|
|
180
|
+
}
|
|
181
|
+
function randomPassword$1(length) {
|
|
182
|
+
let out = "";
|
|
183
|
+
while (out.length < length) {
|
|
184
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
185
|
+
for (const byte of bytes) {
|
|
186
|
+
if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
|
|
187
|
+
if (out.length === length) break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Compute the `mysql_native_password` auth string `*<UPPER(HEX(SHA1(SHA1(pw))))>`
|
|
194
|
+
* for a known password. Pass the result straight to
|
|
195
|
+
* `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`.
|
|
196
|
+
*/
|
|
197
|
+
async function mysqlNativePasswordVerifier(password) {
|
|
198
|
+
return `*${toUpperHex(await sha1(await sha1(utf8Encode(password))))}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Mint a fresh random password and its `mysql_native_password` hash in one step
|
|
202
|
+
* — the client-side half of a Vault-style dynamic MySQL credential.
|
|
203
|
+
*/
|
|
204
|
+
async function generateMysqlCredential(options = {}) {
|
|
205
|
+
const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
|
|
206
|
+
return {
|
|
207
|
+
password,
|
|
208
|
+
verifier: await mysqlNativePasswordVerifier(password)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
143
212
|
//#region ../../packages/crypto/src/passphrase.ts
|
|
144
213
|
/**
|
|
145
214
|
* User private keys are stored server-side encrypted under a key derived from
|
|
@@ -517,7 +586,7 @@ async function unwrapDek(wrapped, privateKey) {
|
|
|
517
586
|
}
|
|
518
587
|
//#endregion
|
|
519
588
|
//#region package.json
|
|
520
|
-
var version = "0.
|
|
589
|
+
var version = "0.9.0";
|
|
521
590
|
const PROJECT_FILE = "seekrit.json";
|
|
522
591
|
function globalConfigPath() {
|
|
523
592
|
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
@@ -606,6 +675,12 @@ var SeekritClient = class {
|
|
|
606
675
|
setMyKeys(input) {
|
|
607
676
|
return this.request("PUT", "/v1/me/keys", input);
|
|
608
677
|
}
|
|
678
|
+
getMyNotificationPrefs() {
|
|
679
|
+
return this.request("GET", "/v1/me/notifications");
|
|
680
|
+
}
|
|
681
|
+
setMyNotificationPrefs(input) {
|
|
682
|
+
return this.request("PUT", "/v1/me/notifications", input);
|
|
683
|
+
}
|
|
609
684
|
listOrgs() {
|
|
610
685
|
return this.request("GET", "/v1/orgs");
|
|
611
686
|
}
|
|
@@ -864,7 +939,234 @@ function formatSecrets(values, format) {
|
|
|
864
939
|
case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
|
|
865
940
|
}
|
|
866
941
|
}
|
|
867
|
-
|
|
942
|
+
//#endregion
|
|
943
|
+
//#region src/target.ts
|
|
944
|
+
/** Resolve the target org from a flag, the committed config, or a lone org. */
|
|
945
|
+
async function resolveOrg(ctx, orgSlug) {
|
|
946
|
+
const wanted = orgSlug ?? findProjectConfig()?.org;
|
|
947
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
948
|
+
if (wanted) {
|
|
949
|
+
const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
|
|
950
|
+
if (!org) fail(`no accessible org "${wanted}"`);
|
|
951
|
+
return {
|
|
952
|
+
id: org.id,
|
|
953
|
+
slug: org.slug
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
const only = orgs[0];
|
|
957
|
+
if (orgs.length === 1 && only) return {
|
|
958
|
+
id: only.id,
|
|
959
|
+
slug: only.slug
|
|
960
|
+
};
|
|
961
|
+
fail("specify --org (or run `seekrit init`)");
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
965
|
+
* or the config's app + `--env`) or a group env (`--group --env`).
|
|
966
|
+
*/
|
|
967
|
+
async function resolveEnvTarget(ctx, opts) {
|
|
968
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
969
|
+
if (!opts.env) fail("specify --env");
|
|
970
|
+
if (opts.group) {
|
|
971
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
972
|
+
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
973
|
+
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
974
|
+
const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
|
|
975
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
976
|
+
if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
|
|
977
|
+
return {
|
|
978
|
+
orgId: org.id,
|
|
979
|
+
envId: env.id,
|
|
980
|
+
label: `${group.slug}@${env.slug}`
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
984
|
+
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
985
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
986
|
+
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
987
|
+
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
988
|
+
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
989
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
990
|
+
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
991
|
+
return {
|
|
992
|
+
orgId: org.id,
|
|
993
|
+
envId: env.id,
|
|
994
|
+
label: `${app.slug}/${env.slug}`
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
998
|
+
async function resolveAppEnv(ctx, opts) {
|
|
999
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
1000
|
+
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1001
|
+
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
1002
|
+
if (!opts.env) fail("specify --env");
|
|
1003
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
1004
|
+
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1005
|
+
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1006
|
+
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1007
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1008
|
+
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1009
|
+
return {
|
|
1010
|
+
orgId: org.id,
|
|
1011
|
+
appId: app.id,
|
|
1012
|
+
appSlug: app.slug,
|
|
1013
|
+
envId: env.id,
|
|
1014
|
+
envSlug: env.slug
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
/** Resolve a group by slug within the target org. */
|
|
1018
|
+
async function resolveGroup(ctx, opts) {
|
|
1019
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
1020
|
+
if (!opts.group) fail("specify --group");
|
|
1021
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
1022
|
+
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1023
|
+
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1024
|
+
return {
|
|
1025
|
+
orgId: org.id,
|
|
1026
|
+
id: group.id,
|
|
1027
|
+
slug: group.slug
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region src/mysql.ts
|
|
1032
|
+
/**
|
|
1033
|
+
* `seekrit mysql` — temporary MySQL/MariaDB credentials (Vault-style dynamic
|
|
1034
|
+
* secrets).
|
|
1035
|
+
*
|
|
1036
|
+
* Zero-knowledge: minting generates the password and its
|
|
1037
|
+
* `mysql_native_password` hash on THIS machine and sends only the hash; the
|
|
1038
|
+
* plaintext password never reaches the API or gets stored. Registering a target
|
|
1039
|
+
* wraps the admin connection string to the broker's public key locally, so the
|
|
1040
|
+
* control plane only ever stores ciphertext.
|
|
1041
|
+
*/
|
|
1042
|
+
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
1043
|
+
function parseTtlSeconds$2(input) {
|
|
1044
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1045
|
+
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
1046
|
+
return Number(m[1]) * ({
|
|
1047
|
+
s: 1,
|
|
1048
|
+
m: 60,
|
|
1049
|
+
h: 3600,
|
|
1050
|
+
d: 86400
|
|
1051
|
+
}[m[2] || "s"] ?? 1);
|
|
1052
|
+
}
|
|
1053
|
+
/** A fresh, valid MySQL user name: `tmp_` + lowercase alphanumerics. */
|
|
1054
|
+
function generateUserName(prefix = "tmp") {
|
|
1055
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
1056
|
+
let out = "";
|
|
1057
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
1058
|
+
for (const b of bytes) out += alphabet[b % 36];
|
|
1059
|
+
return `${prefix}_${out}`;
|
|
1060
|
+
}
|
|
1061
|
+
function registerMysqlCommands(program) {
|
|
1062
|
+
const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
|
|
1063
|
+
const target = mysql.command("target").description("manage provisioning targets");
|
|
1064
|
+
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--admin-url <url>", "admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$2, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$2, []).action(async (options) => {
|
|
1065
|
+
const ctx = buildContext();
|
|
1066
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1067
|
+
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
1068
|
+
if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
|
|
1069
|
+
if (![
|
|
1070
|
+
"readonly",
|
|
1071
|
+
"readwrite",
|
|
1072
|
+
"custom"
|
|
1073
|
+
].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
|
|
1074
|
+
const accessLevel = options.access;
|
|
1075
|
+
const adminSecret = options.adminUrl ?? process.env.SEEKRIT_MYSQL_ADMIN_URL;
|
|
1076
|
+
if (!adminSecret) fail(executor === "remote" ? "provide the shared HMAC key via --admin-url or SEEKRIT_MYSQL_ADMIN_URL" : "provide the admin connection string via --admin-url or SEEKRIT_MYSQL_ADMIN_URL");
|
|
1077
|
+
const config = {
|
|
1078
|
+
provider: "mysql",
|
|
1079
|
+
executor,
|
|
1080
|
+
accessLevel,
|
|
1081
|
+
connection: {
|
|
1082
|
+
host: options.host,
|
|
1083
|
+
port: Number.parseInt(options.port, 10),
|
|
1084
|
+
database: options.database
|
|
1085
|
+
},
|
|
1086
|
+
userHost: options.userHost,
|
|
1087
|
+
...accessLevel === "custom" ? {
|
|
1088
|
+
...options.createStatement.length ? { createStatements: options.createStatement } : {},
|
|
1089
|
+
...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
|
|
1090
|
+
} : {},
|
|
1091
|
+
...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
|
|
1092
|
+
};
|
|
1093
|
+
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
1094
|
+
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
|
|
1095
|
+
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
1096
|
+
name: options.name,
|
|
1097
|
+
config,
|
|
1098
|
+
wrappedAdminSecret
|
|
1099
|
+
});
|
|
1100
|
+
console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
|
|
1101
|
+
});
|
|
1102
|
+
target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
|
|
1103
|
+
const ctx = buildContext();
|
|
1104
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1105
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1106
|
+
for (const t of targets) {
|
|
1107
|
+
if (t.provider !== "mysql") continue;
|
|
1108
|
+
const cfg = t.config;
|
|
1109
|
+
console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
|
|
1113
|
+
const ctx = buildContext();
|
|
1114
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1115
|
+
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
1116
|
+
console.error(`removed ${targetId}`);
|
|
1117
|
+
});
|
|
1118
|
+
mysql.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--user <name>", "user name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
|
|
1119
|
+
const ctx = buildContext();
|
|
1120
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1121
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1122
|
+
const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
|
|
1123
|
+
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1124
|
+
if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
|
|
1125
|
+
const userName = options.user ?? generateUserName();
|
|
1126
|
+
const ttlSeconds = parseTtlSeconds$2(options.ttl);
|
|
1127
|
+
const { password, verifier } = await generateMysqlCredential();
|
|
1128
|
+
const { connection } = await ctx.client.mintLease(org.id, {
|
|
1129
|
+
provider: "mysql",
|
|
1130
|
+
targetId: target.id,
|
|
1131
|
+
roleName: userName,
|
|
1132
|
+
verifier,
|
|
1133
|
+
ttlSeconds
|
|
1134
|
+
});
|
|
1135
|
+
const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
|
|
1136
|
+
console.error(`leased ${userName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
|
|
1137
|
+
if (options.json) console.log(JSON.stringify({
|
|
1138
|
+
...connection,
|
|
1139
|
+
password,
|
|
1140
|
+
url
|
|
1141
|
+
}, null, 2));
|
|
1142
|
+
else console.log(url);
|
|
1143
|
+
});
|
|
1144
|
+
mysql.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
|
|
1145
|
+
const ctx = buildContext();
|
|
1146
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1147
|
+
const { leases } = await ctx.client.listLeases(org.id);
|
|
1148
|
+
for (const l of leases) {
|
|
1149
|
+
if (l.provider !== "mysql") continue;
|
|
1150
|
+
console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
mysql.command("revoke <leaseId>").description("revoke a lease now (drops the user immediately)").option("--org <slug>").action(async (leaseId, options) => {
|
|
1154
|
+
const ctx = buildContext();
|
|
1155
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1156
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
1157
|
+
console.error(`revoked ${leaseId}`);
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
/** Collect a repeatable option into an array. */
|
|
1161
|
+
function collect$2(value, acc) {
|
|
1162
|
+
acc.push(value);
|
|
1163
|
+
return acc;
|
|
1164
|
+
}
|
|
1165
|
+
z.enum([
|
|
1166
|
+
"postgres",
|
|
1167
|
+
"mysql",
|
|
1168
|
+
"ssh"
|
|
1169
|
+
]);
|
|
868
1170
|
const executorModeSchema = z.enum(["in_do", "remote"]);
|
|
869
1171
|
/**
|
|
870
1172
|
* A Postgres role name we are willing to create. Deliberately strict — this
|
|
@@ -888,6 +1190,20 @@ const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1
|
|
|
888
1190
|
const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
|
|
889
1191
|
/** An SSH certificate extension name, e.g. `permit-pty`. */
|
|
890
1192
|
const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
|
|
1193
|
+
/**
|
|
1194
|
+
* A MySQL/MariaDB user name we are willing to create. Interpolated into a
|
|
1195
|
+
* quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
|
|
1196
|
+
* alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
|
|
1197
|
+
*/
|
|
1198
|
+
const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
|
|
1199
|
+
/**
|
|
1200
|
+
* A `mysql_native_password` authentication string — `*` followed by 40 upper
|
|
1201
|
+
* hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
|
|
1202
|
+
* @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
|
|
1203
|
+
* `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
|
|
1204
|
+
* alphabet contains no single quote, so it is safe in a quoted SQL literal.
|
|
1205
|
+
*/
|
|
1206
|
+
const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
|
|
891
1207
|
const postgresAccessLevelSchema = z.enum([
|
|
892
1208
|
"readonly",
|
|
893
1209
|
"readwrite",
|
|
@@ -898,6 +1214,11 @@ const POSTGRES_GROUP_ROLES = {
|
|
|
898
1214
|
readonly: "seekrit_readonly",
|
|
899
1215
|
readwrite: "seekrit_readwrite"
|
|
900
1216
|
};
|
|
1217
|
+
const mysqlAccessLevelSchema = z.enum([
|
|
1218
|
+
"readonly",
|
|
1219
|
+
"readwrite",
|
|
1220
|
+
"custom"
|
|
1221
|
+
]);
|
|
901
1222
|
const connectionSchema = z.object({
|
|
902
1223
|
host: z.string().min(1),
|
|
903
1224
|
port: z.number().int().min(1).max(65535),
|
|
@@ -917,6 +1238,18 @@ const postgresTargetConfigSchema = z.object({
|
|
|
917
1238
|
createStatements: z.array(statementSchema).max(16).optional(),
|
|
918
1239
|
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
919
1240
|
});
|
|
1241
|
+
/** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
|
|
1242
|
+
const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
|
|
1243
|
+
const mysqlTargetConfigSchema = z.object({
|
|
1244
|
+
provider: z.literal("mysql"),
|
|
1245
|
+
executor: executorModeSchema,
|
|
1246
|
+
accessLevel: mysqlAccessLevelSchema.optional(),
|
|
1247
|
+
connection: connectionSchema,
|
|
1248
|
+
userHost: mysqlHostSchema.optional(),
|
|
1249
|
+
provisionerUrl: z.url().optional(),
|
|
1250
|
+
createStatements: z.array(statementSchema).max(16).optional(),
|
|
1251
|
+
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
1252
|
+
});
|
|
920
1253
|
const sshTargetConfigSchema = z.object({
|
|
921
1254
|
provider: z.literal("ssh"),
|
|
922
1255
|
executor: z.literal("in_do"),
|
|
@@ -929,7 +1262,11 @@ const sshTargetConfigSchema = z.object({
|
|
|
929
1262
|
user: sshPrincipalSchema.optional()
|
|
930
1263
|
}).optional()
|
|
931
1264
|
});
|
|
932
|
-
const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
|
|
1265
|
+
const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
|
|
1266
|
+
postgresTargetConfigSchema,
|
|
1267
|
+
mysqlTargetConfigSchema,
|
|
1268
|
+
sshTargetConfigSchema
|
|
1269
|
+
]);
|
|
933
1270
|
z.object({
|
|
934
1271
|
name: z.string().trim().min(1).max(128),
|
|
935
1272
|
config: leaseTargetConfigSchema,
|
|
@@ -955,6 +1292,18 @@ const mintPostgresLeaseSchema = z.object({
|
|
|
955
1292
|
ttlSeconds: ttlSecondsSchema
|
|
956
1293
|
});
|
|
957
1294
|
/**
|
|
1295
|
+
* Client → API: mint a MySQL/MariaDB lease. The client generates the password
|
|
1296
|
+
* and its `mysql_native_password` hash locally and sends only the hash — the
|
|
1297
|
+
* plaintext password never leaves the requesting machine.
|
|
1298
|
+
*/
|
|
1299
|
+
const mintMysqlLeaseSchema = z.object({
|
|
1300
|
+
provider: z.literal("mysql"),
|
|
1301
|
+
targetId: z.string().min(1),
|
|
1302
|
+
roleName: mysqlUserNameSchema,
|
|
1303
|
+
verifier: mysqlNativeVerifierSchema,
|
|
1304
|
+
ttlSeconds: ttlSecondsSchema
|
|
1305
|
+
});
|
|
1306
|
+
/**
|
|
958
1307
|
* Client → API: mint an SSH lease. The client generates an ephemeral keypair
|
|
959
1308
|
* locally and sends only the public key; the signed certificate comes back in
|
|
960
1309
|
* the response. The private key never leaves the requesting machine.
|
|
@@ -966,7 +1315,11 @@ const mintSshLeaseSchema = z.object({
|
|
|
966
1315
|
principals: z.array(sshPrincipalSchema).min(1).max(32),
|
|
967
1316
|
ttlSeconds: ttlSecondsSchema
|
|
968
1317
|
});
|
|
969
|
-
z.discriminatedUnion("provider", [
|
|
1318
|
+
z.discriminatedUnion("provider", [
|
|
1319
|
+
mintPostgresLeaseSchema,
|
|
1320
|
+
mintMysqlLeaseSchema,
|
|
1321
|
+
mintSshLeaseSchema
|
|
1322
|
+
]);
|
|
970
1323
|
//#endregion
|
|
971
1324
|
//#region ../../packages/core/src/providers/postgres.ts
|
|
972
1325
|
/**
|
|
@@ -1021,6 +1374,25 @@ function sshHostSetupInstructions(config) {
|
|
|
1021
1374
|
].join("\n");
|
|
1022
1375
|
}
|
|
1023
1376
|
//#endregion
|
|
1377
|
+
//#region ../../packages/core/src/types.ts
|
|
1378
|
+
/**
|
|
1379
|
+
* Transactional notification emails seekrit can send. Each id is one
|
|
1380
|
+
* user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
|
|
1381
|
+
* audit-grade metadata — never secret material — and every one is opt-out
|
|
1382
|
+
* (defaults on). Kept as a const array so the API, api-client, and dashboard
|
|
1383
|
+
* share a single source of truth (mirrors `AUDIT_ACTIONS`).
|
|
1384
|
+
*/
|
|
1385
|
+
const NOTIFICATION_TYPES = [
|
|
1386
|
+
"token_created",
|
|
1387
|
+
"token_revoked",
|
|
1388
|
+
"env_access_granted",
|
|
1389
|
+
"env_access_revoked",
|
|
1390
|
+
"resolve_denied",
|
|
1391
|
+
"org_welcome",
|
|
1392
|
+
"token_expiring",
|
|
1393
|
+
"lease_expired"
|
|
1394
|
+
];
|
|
1395
|
+
//#endregion
|
|
1024
1396
|
//#region ../../packages/core/src/schemas.ts
|
|
1025
1397
|
/** URL-safe identifier segment: `my-app`, `production`, … */
|
|
1026
1398
|
const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
|
|
@@ -1093,6 +1465,7 @@ z.object({
|
|
|
1093
1465
|
environmentId: z.string().min(1).nullish(),
|
|
1094
1466
|
expiresAt: z.iso.datetime().nullish()
|
|
1095
1467
|
});
|
|
1468
|
+
z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
|
|
1096
1469
|
z.object({
|
|
1097
1470
|
cursor: z.string().optional(),
|
|
1098
1471
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
@@ -1100,94 +1473,6 @@ z.object({
|
|
|
1100
1473
|
resourceType: z.string().optional()
|
|
1101
1474
|
});
|
|
1102
1475
|
//#endregion
|
|
1103
|
-
//#region src/target.ts
|
|
1104
|
-
/** Resolve the target org from a flag, the committed config, or a lone org. */
|
|
1105
|
-
async function resolveOrg(ctx, orgSlug) {
|
|
1106
|
-
const wanted = orgSlug ?? findProjectConfig()?.org;
|
|
1107
|
-
const { orgs } = await ctx.client.listOrgs();
|
|
1108
|
-
if (wanted) {
|
|
1109
|
-
const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
|
|
1110
|
-
if (!org) fail(`no accessible org "${wanted}"`);
|
|
1111
|
-
return {
|
|
1112
|
-
id: org.id,
|
|
1113
|
-
slug: org.slug
|
|
1114
|
-
};
|
|
1115
|
-
}
|
|
1116
|
-
const only = orgs[0];
|
|
1117
|
-
if (orgs.length === 1 && only) return {
|
|
1118
|
-
id: only.id,
|
|
1119
|
-
slug: only.slug
|
|
1120
|
-
};
|
|
1121
|
-
fail("specify --org (or run `seekrit init`)");
|
|
1122
|
-
}
|
|
1123
|
-
/**
|
|
1124
|
-
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
1125
|
-
* or the config's app + `--env`) or a group env (`--group --env`).
|
|
1126
|
-
*/
|
|
1127
|
-
async function resolveEnvTarget(ctx, opts) {
|
|
1128
|
-
const org = await resolveOrg(ctx, opts.org);
|
|
1129
|
-
if (!opts.env) fail("specify --env");
|
|
1130
|
-
if (opts.group) {
|
|
1131
|
-
const { groups } = await ctx.client.listGroups(org.id);
|
|
1132
|
-
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1133
|
-
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1134
|
-
const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
|
|
1135
|
-
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1136
|
-
if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
|
|
1137
|
-
return {
|
|
1138
|
-
orgId: org.id,
|
|
1139
|
-
envId: env.id,
|
|
1140
|
-
label: `${group.slug}@${env.slug}`
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
|
-
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1144
|
-
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
1145
|
-
const { apps } = await ctx.client.listApps(org.id);
|
|
1146
|
-
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1147
|
-
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1148
|
-
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1149
|
-
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1150
|
-
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1151
|
-
return {
|
|
1152
|
-
orgId: org.id,
|
|
1153
|
-
envId: env.id,
|
|
1154
|
-
label: `${app.slug}/${env.slug}`
|
|
1155
|
-
};
|
|
1156
|
-
}
|
|
1157
|
-
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
1158
|
-
async function resolveAppEnv(ctx, opts) {
|
|
1159
|
-
const org = await resolveOrg(ctx, opts.org);
|
|
1160
|
-
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1161
|
-
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
1162
|
-
if (!opts.env) fail("specify --env");
|
|
1163
|
-
const { apps } = await ctx.client.listApps(org.id);
|
|
1164
|
-
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1165
|
-
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1166
|
-
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1167
|
-
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1168
|
-
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1169
|
-
return {
|
|
1170
|
-
orgId: org.id,
|
|
1171
|
-
appId: app.id,
|
|
1172
|
-
appSlug: app.slug,
|
|
1173
|
-
envId: env.id,
|
|
1174
|
-
envSlug: env.slug
|
|
1175
|
-
};
|
|
1176
|
-
}
|
|
1177
|
-
/** Resolve a group by slug within the target org. */
|
|
1178
|
-
async function resolveGroup(ctx, opts) {
|
|
1179
|
-
const org = await resolveOrg(ctx, opts.org);
|
|
1180
|
-
if (!opts.group) fail("specify --group");
|
|
1181
|
-
const { groups } = await ctx.client.listGroups(org.id);
|
|
1182
|
-
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1183
|
-
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1184
|
-
return {
|
|
1185
|
-
orgId: org.id,
|
|
1186
|
-
id: group.id,
|
|
1187
|
-
slug: group.slug
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
//#endregion
|
|
1191
1476
|
//#region src/pg.ts
|
|
1192
1477
|
/**
|
|
1193
1478
|
* `seekrit pg` — temporary Postgres credentials (Vault-style dynamic secrets).
|
|
@@ -1933,9 +2218,10 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
|
|
|
1933
2218
|
console.error(`${tokenId} revoked`);
|
|
1934
2219
|
});
|
|
1935
2220
|
registerPgCommands(program);
|
|
2221
|
+
registerMysqlCommands(program);
|
|
1936
2222
|
registerSshCommands(program);
|
|
1937
2223
|
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|
|
1938
|
-
const { runMcpServer } = await import("./mcp-
|
|
2224
|
+
const { runMcpServer } = await import("./mcp-BEcV_KpM.js");
|
|
1939
2225
|
await runMcpServer();
|
|
1940
2226
|
});
|
|
1941
2227
|
program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
|
|
@@ -1949,4 +2235,4 @@ program.parseAsync(argv).catch((err) => {
|
|
|
1949
2235
|
fail(err instanceof Error ? err.message : String(err));
|
|
1950
2236
|
});
|
|
1951
2237
|
//#endregion
|
|
1952
|
-
export { parseServiceToken as _, resolveEnvTarget as a, getDek as c, setFailThrows as d, writeProjectConfig as f, isServiceToken as g, createServiceToken as h, resolveAppEnv as i, isTokenAuth as l, wrapDek as m, fetchDecryptedSecrets as n, resolveGroup as o, version as p, materializeEnv as r, resolveOrg as s, encryptAndSetSecret as t, tryBuildContext as u, generatePostgresCredential as v,
|
|
2238
|
+
export { parseServiceToken as _, resolveEnvTarget as a, generateDek as b, getDek as c, setFailThrows as d, writeProjectConfig as f, isServiceToken as g, createServiceToken as h, resolveAppEnv as i, isTokenAuth as l, wrapDek as m, fetchDecryptedSecrets as n, resolveGroup as o, version as p, materializeEnv as r, resolveOrg as s, encryptAndSetSecret as t, tryBuildContext as u, generatePostgresCredential as v, generateMysqlCredential as y };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as parseServiceToken, a as resolveEnvTarget, c as getDek, d as setFailThrows, f as writeProjectConfig, g as isServiceToken, h as createServiceToken, i as resolveAppEnv, l as isTokenAuth, m as wrapDek, n as fetchDecryptedSecrets, o as resolveGroup, p as version, r as materializeEnv, s as resolveOrg, t as encryptAndSetSecret, u as tryBuildContext, v as generatePostgresCredential, y as
|
|
1
|
+
import { _ as parseServiceToken, a as resolveEnvTarget, b as generateDek, c as getDek, d as setFailThrows, f as writeProjectConfig, g as isServiceToken, h as createServiceToken, i as resolveAppEnv, l as isTokenAuth, m as wrapDek, n as fetchDecryptedSecrets, o as resolveGroup, p as version, r as materializeEnv, s as resolveOrg, t as encryptAndSetSecret, u as tryBuildContext, v as generatePostgresCredential, y as generateMysqlCredential } from "./index.js";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -592,6 +592,58 @@ async function runMcpServer() {
|
|
|
592
592
|
leaseId
|
|
593
593
|
};
|
|
594
594
|
});
|
|
595
|
+
tool("list_mysql_targets", "List registered MySQL/MariaDB provisioning targets for temporary credentials.", { org: z.string().optional() }, async ({ org }) => {
|
|
596
|
+
const ctx = getCtx();
|
|
597
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
598
|
+
return (await ctx.client.listLeaseTargets(orgRef.id)).targets.filter((t) => t.provider === "mysql");
|
|
599
|
+
});
|
|
600
|
+
tool("create_mysql_lease", "Mint a short-lived MySQL/MariaDB credential against a target and return a ready-to-use connection URL. The password is generated on THIS machine and only its mysql_native_password hash is sent to the API — the plaintext never reaches seekrit or MySQL at rest. The user auto-expires; revoke early with revoke_mysql_lease.", {
|
|
601
|
+
org: z.string().optional(),
|
|
602
|
+
target: z.string().describe("target id or name"),
|
|
603
|
+
user: z.string().optional().describe("user name to create (default: random tmp_ name)"),
|
|
604
|
+
ttlSeconds: z.number().int().min(60).max(604800).optional().describe("lifetime (default 3600)")
|
|
605
|
+
}, async ({ org, target, user, ttlSeconds }) => {
|
|
606
|
+
const ctx = getCtx();
|
|
607
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
608
|
+
const { targets } = await ctx.client.listLeaseTargets(orgRef.id);
|
|
609
|
+
const t = targets.find((x) => x.id === target || x.name === target);
|
|
610
|
+
if (!t) throw new Error(`no target "${target}" in ${orgRef.slug}`);
|
|
611
|
+
if (t.provider !== "mysql") throw new Error(`target "${target}" is not a MySQL target`);
|
|
612
|
+
const userName = user ?? `tmp_${randomLower(12)}`;
|
|
613
|
+
const { password, verifier } = await generateMysqlCredential();
|
|
614
|
+
const { lease, connection } = await ctx.client.mintLease(orgRef.id, {
|
|
615
|
+
provider: "mysql",
|
|
616
|
+
targetId: t.id,
|
|
617
|
+
roleName: userName,
|
|
618
|
+
verifier,
|
|
619
|
+
ttlSeconds: ttlSeconds ?? 3600
|
|
620
|
+
});
|
|
621
|
+
const url = `mysql://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
|
|
622
|
+
return {
|
|
623
|
+
leaseId: lease.id,
|
|
624
|
+
url,
|
|
625
|
+
username: userName,
|
|
626
|
+
expiresAt: connection.expiresAt,
|
|
627
|
+
note: "short-lived credential; it auto-expires and the plaintext is not stored anywhere"
|
|
628
|
+
};
|
|
629
|
+
});
|
|
630
|
+
tool("list_mysql_leases", "List MySQL/MariaDB leases (the ledger — never secret material).", { org: z.string().optional() }, async ({ org }) => {
|
|
631
|
+
const ctx = getCtx();
|
|
632
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
633
|
+
return (await ctx.client.listLeases(orgRef.id)).leases.filter((l) => l.provider === "mysql");
|
|
634
|
+
});
|
|
635
|
+
tool("revoke_mysql_lease", "Revoke a MySQL/MariaDB lease now (drops the user immediately).", {
|
|
636
|
+
org: z.string().optional(),
|
|
637
|
+
leaseId: z.string()
|
|
638
|
+
}, async ({ org, leaseId }) => {
|
|
639
|
+
const ctx = getCtx();
|
|
640
|
+
const orgRef = await resolveOrg(ctx, org);
|
|
641
|
+
await ctx.client.revokeLease(orgRef.id, leaseId);
|
|
642
|
+
return {
|
|
643
|
+
ok: true,
|
|
644
|
+
leaseId
|
|
645
|
+
};
|
|
646
|
+
});
|
|
595
647
|
tool("configure_project", "Link a directory to an org/app by writing seekrit.json (like `seekrit init`). The environment is chosen by the service token at runtime.", {
|
|
596
648
|
org: z.string(),
|
|
597
649
|
app: z.string(),
|