@seekrit/cli 0.11.0 → 0.13.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 +665 -359
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +1,380 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { Command } from "commander";
|
|
4
3
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { Command } from "commander";
|
|
5
6
|
import { homedir, tmpdir } from "node:os";
|
|
6
7
|
import { dirname, join, parse } from "node:path";
|
|
7
8
|
import { createInterface } from "node:readline";
|
|
8
9
|
import { Writable } from "node:stream";
|
|
9
|
-
|
|
10
|
+
z.enum([
|
|
11
|
+
"postgres",
|
|
12
|
+
"mysql",
|
|
13
|
+
"ssh",
|
|
14
|
+
"redis"
|
|
15
|
+
]);
|
|
16
|
+
const executorModeSchema = z.enum(["in_do", "remote"]);
|
|
17
|
+
/**
|
|
18
|
+
* A Postgres role name we are willing to create. Deliberately strict — this
|
|
19
|
+
* value is interpolated into a SQL template, so it must be a bare identifier
|
|
20
|
+
* with no way to break out of quoting (no quotes, whitespace, or semicolons).
|
|
21
|
+
*/
|
|
22
|
+
const postgresRoleNameSchema = z.string().regex(/^[a-z_][a-z0-9_]{2,62}$/, "must be 3–63 chars, lowercase letters/digits/underscore, starting with a letter or underscore");
|
|
23
|
+
/**
|
|
24
|
+
* A SCRAM-SHA-256 verifier string as produced by @seekrit/crypto. Validated so
|
|
25
|
+
* it, too, is safe to interpolate into a quoted SQL literal (the alphabet is
|
|
26
|
+
* base64 + the fixed structural characters, none of which is a single quote).
|
|
27
|
+
*/
|
|
28
|
+
const scramVerifierSchema = z.string().regex(/^SCRAM-SHA-256\$\d{3,}:[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/, "must be a SCRAM-SHA-256 verifier");
|
|
29
|
+
/**
|
|
30
|
+
* An SSH login principal (a Unix-style username the certificate authorizes).
|
|
31
|
+
* Bounded and restricted to a safe charset — principals are SSH-wire-encoded,
|
|
32
|
+
* not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
|
|
33
|
+
*/
|
|
34
|
+
const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
|
|
35
|
+
/** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
|
|
36
|
+
const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
|
|
37
|
+
/** An SSH certificate extension name, e.g. `permit-pty`. */
|
|
38
|
+
const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
|
|
39
|
+
/**
|
|
40
|
+
* A MySQL/MariaDB user name we are willing to create. Interpolated into a
|
|
41
|
+
* quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
|
|
42
|
+
* alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
|
|
43
|
+
*/
|
|
44
|
+
const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
|
|
45
|
+
/**
|
|
46
|
+
* A `mysql_native_password` authentication string — `*` followed by 40 upper
|
|
47
|
+
* hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
|
|
48
|
+
* @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
|
|
49
|
+
* `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
|
|
50
|
+
* alphabet contains no single quote, so it is safe in a quoted SQL literal.
|
|
51
|
+
*/
|
|
52
|
+
const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
|
|
53
|
+
/**
|
|
54
|
+
* A Redis ACL user name we are willing to create. Interpolated into a Redis
|
|
55
|
+
* command line as a bare token (`ACL SETUSER <name> …`), so it is kept strict —
|
|
56
|
+
* plain alphanumerics/underscore, no whitespace to split the arg or ACL rule
|
|
57
|
+
* characters (`~ + @ # & %`) that could be read as a permission.
|
|
58
|
+
*/
|
|
59
|
+
const redisUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
|
|
60
|
+
/**
|
|
61
|
+
* A Redis password verifier — the lowercase-hex SHA-256 of the password, as
|
|
62
|
+
* produced by @seekrit/crypto `redisSha256Verifier`. `ACL SETUSER … on #<hex>`
|
|
63
|
+
* stores this digest verbatim, and it cannot authenticate: Redis `AUTH` hashes
|
|
64
|
+
* the *plaintext* it receives with SHA-256 and compares, so the stored digest
|
|
65
|
+
* is preimage-resistant (the password is high-entropy and machine-generated).
|
|
66
|
+
* The alphabet is bare hex, so it is a safe bare command token.
|
|
67
|
+
*/
|
|
68
|
+
const redisSha256VerifierSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase-hex SHA-256 digest (64 chars)");
|
|
69
|
+
const postgresAccessLevelSchema = z.enum([
|
|
70
|
+
"readonly",
|
|
71
|
+
"readwrite",
|
|
72
|
+
"custom"
|
|
73
|
+
]);
|
|
74
|
+
/** The group role each preset's leased credentials inherit. */
|
|
75
|
+
const POSTGRES_GROUP_ROLES = {
|
|
76
|
+
readonly: "seekrit_readonly",
|
|
77
|
+
readwrite: "seekrit_readwrite"
|
|
78
|
+
};
|
|
79
|
+
const mysqlAccessLevelSchema = z.enum([
|
|
80
|
+
"readonly",
|
|
81
|
+
"readwrite",
|
|
82
|
+
"custom"
|
|
83
|
+
]);
|
|
84
|
+
const redisAccessLevelSchema = z.enum([
|
|
85
|
+
"readonly",
|
|
86
|
+
"readwrite",
|
|
87
|
+
"custom"
|
|
88
|
+
]);
|
|
89
|
+
const connectionSchema = z.object({
|
|
90
|
+
host: z.string().min(1),
|
|
91
|
+
port: z.number().int().min(1).max(65535),
|
|
92
|
+
database: z.string().min(1)
|
|
93
|
+
});
|
|
94
|
+
/** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
|
|
95
|
+
const statementSchema = z.string().min(1).max(4e3);
|
|
96
|
+
/** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
|
|
97
|
+
const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
|
|
98
|
+
const postgresTargetConfigSchema = z.object({
|
|
99
|
+
provider: z.literal("postgres"),
|
|
100
|
+
executor: executorModeSchema,
|
|
101
|
+
accessLevel: postgresAccessLevelSchema.optional(),
|
|
102
|
+
schema: identifierSchema.optional(),
|
|
103
|
+
connection: connectionSchema,
|
|
104
|
+
provisionerUrl: z.url().optional(),
|
|
105
|
+
createStatements: z.array(statementSchema).max(16).optional(),
|
|
106
|
+
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
107
|
+
});
|
|
108
|
+
/** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
|
|
109
|
+
const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
|
|
110
|
+
const mysqlTargetConfigSchema = z.object({
|
|
111
|
+
provider: z.literal("mysql"),
|
|
112
|
+
executor: executorModeSchema,
|
|
113
|
+
accessLevel: mysqlAccessLevelSchema.optional(),
|
|
114
|
+
connection: connectionSchema,
|
|
115
|
+
userHost: mysqlHostSchema.optional(),
|
|
116
|
+
provisionerUrl: z.url().optional(),
|
|
117
|
+
createStatements: z.array(statementSchema).max(16).optional(),
|
|
118
|
+
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
119
|
+
});
|
|
120
|
+
const redisConnectionSchema = z.object({
|
|
121
|
+
host: z.string().min(1),
|
|
122
|
+
port: z.number().int().min(1).max(65535),
|
|
123
|
+
/** Redis logical database index (the `/<n>` in a connection URL). */
|
|
124
|
+
db: z.number().int().min(0).max(15).optional()
|
|
125
|
+
});
|
|
126
|
+
const redisTargetConfigSchema = z.object({
|
|
127
|
+
provider: z.literal("redis"),
|
|
128
|
+
executor: executorModeSchema,
|
|
129
|
+
accessLevel: redisAccessLevelSchema.optional(),
|
|
130
|
+
connection: redisConnectionSchema,
|
|
131
|
+
provisionerUrl: z.url().optional(),
|
|
132
|
+
createStatements: z.array(statementSchema).max(16).optional(),
|
|
133
|
+
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
134
|
+
});
|
|
135
|
+
const sshTargetConfigSchema = z.object({
|
|
136
|
+
provider: z.literal("ssh"),
|
|
137
|
+
executor: z.literal("in_do"),
|
|
138
|
+
caPublicKey: sshPublicKeySchema,
|
|
139
|
+
allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
|
|
140
|
+
extensions: z.array(sshExtensionSchema).max(16).optional(),
|
|
141
|
+
maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
|
|
142
|
+
connection: z.object({
|
|
143
|
+
host: z.string().min(1).optional(),
|
|
144
|
+
user: sshPrincipalSchema.optional()
|
|
145
|
+
}).optional()
|
|
146
|
+
});
|
|
147
|
+
const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
|
|
148
|
+
postgresTargetConfigSchema,
|
|
149
|
+
mysqlTargetConfigSchema,
|
|
150
|
+
redisTargetConfigSchema,
|
|
151
|
+
sshTargetConfigSchema
|
|
152
|
+
]);
|
|
153
|
+
z.object({
|
|
154
|
+
name: z.string().trim().min(1).max(128),
|
|
155
|
+
config: leaseTargetConfigSchema,
|
|
156
|
+
/**
|
|
157
|
+
* The admin/provisioning credential (e.g. a Postgres connection string),
|
|
158
|
+
* encrypted client-side to the broker's public key (a `wd1.` wrap). The
|
|
159
|
+
* control plane stores only this ciphertext — it never sees the plaintext.
|
|
160
|
+
*/
|
|
161
|
+
wrappedAdminSecret: z.string().min(1)
|
|
162
|
+
});
|
|
163
|
+
/** Requested lease lifetime, shared by all providers. */
|
|
164
|
+
const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
|
|
165
|
+
/**
|
|
166
|
+
* Client → API: mint a Postgres lease. The client generates the password and
|
|
167
|
+
* its SCRAM verifier locally and sends only the verifier — the plaintext
|
|
168
|
+
* password never leaves the requesting machine.
|
|
169
|
+
*/
|
|
170
|
+
const mintPostgresLeaseSchema = z.object({
|
|
171
|
+
provider: z.literal("postgres"),
|
|
172
|
+
targetId: z.string().min(1),
|
|
173
|
+
roleName: postgresRoleNameSchema,
|
|
174
|
+
verifier: scramVerifierSchema,
|
|
175
|
+
ttlSeconds: ttlSecondsSchema
|
|
176
|
+
});
|
|
177
|
+
/**
|
|
178
|
+
* Client → API: mint a MySQL/MariaDB lease. The client generates the password
|
|
179
|
+
* and its `mysql_native_password` hash locally and sends only the hash — the
|
|
180
|
+
* plaintext password never leaves the requesting machine.
|
|
181
|
+
*/
|
|
182
|
+
const mintMysqlLeaseSchema = z.object({
|
|
183
|
+
provider: z.literal("mysql"),
|
|
184
|
+
targetId: z.string().min(1),
|
|
185
|
+
roleName: mysqlUserNameSchema,
|
|
186
|
+
verifier: mysqlNativeVerifierSchema,
|
|
187
|
+
ttlSeconds: ttlSecondsSchema
|
|
188
|
+
});
|
|
189
|
+
/**
|
|
190
|
+
* Client → API: mint a Redis lease. The client generates the password and its
|
|
191
|
+
* SHA-256 hex digest locally and sends only the digest — the plaintext password
|
|
192
|
+
* never leaves the requesting machine.
|
|
193
|
+
*/
|
|
194
|
+
const mintRedisLeaseSchema = z.object({
|
|
195
|
+
provider: z.literal("redis"),
|
|
196
|
+
targetId: z.string().min(1),
|
|
197
|
+
roleName: redisUserNameSchema,
|
|
198
|
+
verifier: redisSha256VerifierSchema,
|
|
199
|
+
ttlSeconds: ttlSecondsSchema
|
|
200
|
+
});
|
|
201
|
+
/**
|
|
202
|
+
* Client → API: mint an SSH lease. The client generates an ephemeral keypair
|
|
203
|
+
* locally and sends only the public key; the signed certificate comes back in
|
|
204
|
+
* the response. The private key never leaves the requesting machine.
|
|
205
|
+
*/
|
|
206
|
+
const mintSshLeaseSchema = z.object({
|
|
207
|
+
provider: z.literal("ssh"),
|
|
208
|
+
targetId: z.string().min(1),
|
|
209
|
+
publicKey: sshPublicKeySchema,
|
|
210
|
+
principals: z.array(sshPrincipalSchema).min(1).max(32),
|
|
211
|
+
ttlSeconds: ttlSecondsSchema
|
|
212
|
+
});
|
|
213
|
+
z.discriminatedUnion("provider", [
|
|
214
|
+
mintPostgresLeaseSchema,
|
|
215
|
+
mintMysqlLeaseSchema,
|
|
216
|
+
mintRedisLeaseSchema,
|
|
217
|
+
mintSshLeaseSchema
|
|
218
|
+
]);
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region ../../packages/core/src/providers/postgres.ts
|
|
221
|
+
/**
|
|
222
|
+
* The one-time setup SQL an admin runs to create the shared group role that a
|
|
223
|
+
* read-only / read-write target's leased credentials inherit. Idempotent (safe
|
|
224
|
+
* to re-run). Returns null for custom targets (the admin owns their own SQL).
|
|
225
|
+
*
|
|
226
|
+
* Identifiers are interpolated from admin-supplied config (database/schema) and
|
|
227
|
+
* fixed group-role constants — this SQL is displayed for the admin to run in
|
|
228
|
+
* their own database, not executed by seekrit.
|
|
229
|
+
*/
|
|
230
|
+
function postgresGroupBootstrapSql(config) {
|
|
231
|
+
if (config.accessLevel !== "readonly" && config.accessLevel !== "readwrite") return null;
|
|
232
|
+
const group = POSTGRES_GROUP_ROLES[config.accessLevel];
|
|
233
|
+
const schema = config.schema ?? "public";
|
|
234
|
+
const db = config.connection.database;
|
|
235
|
+
const privileges = config.accessLevel === "readonly" ? "SELECT" : "SELECT, INSERT, UPDATE, DELETE";
|
|
236
|
+
const lines = [
|
|
237
|
+
`-- Run once as an admin on "${db}". Temporary ${config.accessLevel} credentials inherit this role.`,
|
|
238
|
+
"DO $$ BEGIN",
|
|
239
|
+
` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${group}') THEN`,
|
|
240
|
+
` CREATE ROLE ${group} NOLOGIN;`,
|
|
241
|
+
" END IF;",
|
|
242
|
+
"END $$;",
|
|
243
|
+
`GRANT CONNECT ON DATABASE "${db}" TO ${group};`,
|
|
244
|
+
`GRANT USAGE ON SCHEMA "${schema}" TO ${group};`,
|
|
245
|
+
`GRANT ${privileges} ON ALL TABLES IN SCHEMA "${schema}" TO ${group};`,
|
|
246
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT ${privileges} ON TABLES TO ${group};`
|
|
247
|
+
];
|
|
248
|
+
if (config.accessLevel === "readwrite") lines.push(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "${schema}" TO ${group};`, `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT USAGE, SELECT ON SEQUENCES TO ${group};`);
|
|
249
|
+
return lines.join("\n");
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region ../../packages/core/src/providers/ssh.ts
|
|
253
|
+
/**
|
|
254
|
+
* The one-time host setup an admin runs so a target's certificates are accepted.
|
|
255
|
+
* Analogue of `postgresGroupBootstrapSql` — displayed for the admin to run, not
|
|
256
|
+
* executed by seekrit. Embeds the CA public key so it's copy-paste runnable.
|
|
257
|
+
*/
|
|
258
|
+
function sshHostSetupInstructions(config) {
|
|
259
|
+
const principals = config.allowedPrincipals?.length ? config.allowedPrincipals : ["<login-user>"];
|
|
260
|
+
return [
|
|
261
|
+
"# Run once on each target host so it trusts seekrit-issued certificates.",
|
|
262
|
+
"# 1. Install the CA public key and trust it for user authentication:",
|
|
263
|
+
`echo '${config.caPublicKey}' | sudo tee /etc/ssh/seekrit_ca.pub`,
|
|
264
|
+
"sudo sh -c 'echo \"TrustedUserCAKeys /etc/ssh/seekrit_ca.pub\" >> /etc/ssh/sshd_config'",
|
|
265
|
+
"# 2. (optional) Restrict which cert principals may log in as which users via",
|
|
266
|
+
"# AuthorizedPrincipalsFile, e.g. /etc/ssh/auth_principals/<user> listing:",
|
|
267
|
+
...principals.map((p) => `# ${p}`),
|
|
268
|
+
"# 3. Reload sshd:",
|
|
269
|
+
"sudo systemctl reload sshd"
|
|
270
|
+
].join("\n");
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region ../../packages/core/src/types.ts
|
|
274
|
+
/**
|
|
275
|
+
* Transactional notification emails seekrit can send. Each id is one
|
|
276
|
+
* user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
|
|
277
|
+
* audit-grade metadata — never secret material — and every one is opt-out
|
|
278
|
+
* (defaults on). Kept as a const array so the API, api-client, and dashboard
|
|
279
|
+
* share a single source of truth (mirrors `AUDIT_ACTIONS`).
|
|
280
|
+
*/
|
|
281
|
+
const NOTIFICATION_TYPES = [
|
|
282
|
+
"token_created",
|
|
283
|
+
"token_revoked",
|
|
284
|
+
"env_access_granted",
|
|
285
|
+
"env_access_revoked",
|
|
286
|
+
"resolve_denied",
|
|
287
|
+
"org_welcome",
|
|
288
|
+
"token_expiring",
|
|
289
|
+
"lease_expired"
|
|
290
|
+
];
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region ../../packages/core/src/schemas.ts
|
|
293
|
+
/** URL-safe identifier segment: `my-app`, `production`, … */
|
|
294
|
+
const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
|
|
295
|
+
const nameSchema = z.string().trim().min(1).max(128);
|
|
296
|
+
/** Env-var style secret name: FOO, DATABASE_URL, apiKey2 … */
|
|
297
|
+
const secretNameSchema = z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
|
|
298
|
+
z.enum([
|
|
299
|
+
"owner",
|
|
300
|
+
"admin",
|
|
301
|
+
"member"
|
|
302
|
+
]);
|
|
303
|
+
const principalTypeSchema = z.enum(["user", "service_token"]);
|
|
304
|
+
/** Org-level capability a service token can hold (never `owner`). */
|
|
305
|
+
const serviceTokenRoleSchema = z.enum(["admin", "member"]);
|
|
306
|
+
z.object({
|
|
307
|
+
name: nameSchema,
|
|
308
|
+
slug: slugSchema
|
|
309
|
+
});
|
|
310
|
+
z.object({
|
|
311
|
+
name: nameSchema,
|
|
312
|
+
slug: slugSchema
|
|
313
|
+
});
|
|
314
|
+
z.object({
|
|
315
|
+
name: nameSchema,
|
|
316
|
+
slug: slugSchema
|
|
317
|
+
});
|
|
318
|
+
z.object({
|
|
319
|
+
groupId: z.string().min(1),
|
|
320
|
+
/** Precedence among an env's groups (higher wins). Appended if omitted. */
|
|
321
|
+
position: z.number().int().min(0).optional()
|
|
322
|
+
});
|
|
323
|
+
z.object({
|
|
324
|
+
name: nameSchema,
|
|
325
|
+
slug: slugSchema,
|
|
326
|
+
/** Environment DEK wrapped to the creator's public key — created client-side. */
|
|
327
|
+
wrappedDek: z.string().min(1)
|
|
328
|
+
});
|
|
329
|
+
z.object({
|
|
330
|
+
/** Opaque versioned ciphertext blob from @seekrit/crypto. */
|
|
331
|
+
ciphertext: z.string().min(1).max(65536) });
|
|
332
|
+
z.object({
|
|
333
|
+
publicKeyJwk: z.string().min(1),
|
|
334
|
+
/**
|
|
335
|
+
* Private key encrypted with a passphrase-derived KEK; opaque to the
|
|
336
|
+
* server. Self-contained blob (embeds KDF salt + iterations).
|
|
337
|
+
*/
|
|
338
|
+
encryptedPrivateKey: z.string().min(1)
|
|
339
|
+
});
|
|
340
|
+
z.object({
|
|
341
|
+
principalType: principalTypeSchema,
|
|
342
|
+
principalId: z.string().min(1),
|
|
343
|
+
wrappedDek: z.string().min(1)
|
|
344
|
+
});
|
|
345
|
+
z.object({
|
|
346
|
+
name: nameSchema,
|
|
347
|
+
tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
|
|
348
|
+
/** SHA-256 hash (base64url) of the full token string. */
|
|
349
|
+
tokenHash: z.string().min(1),
|
|
350
|
+
publicKeyJwk: z.string().min(1),
|
|
351
|
+
/**
|
|
352
|
+
* Org-level capability. Defaults to `member` (a runtime credential); pass
|
|
353
|
+
* `admin` to mint a headless provisioning token. Only an admin caller may
|
|
354
|
+
* create an `admin` token, so capability cannot escalate itself.
|
|
355
|
+
*/
|
|
356
|
+
role: serviceTokenRoleSchema.default("member"),
|
|
357
|
+
/**
|
|
358
|
+
* The application environment this token is bound to (org + app + env).
|
|
359
|
+
* Optional so org-admin tokens can exist, but required for runtime tokens
|
|
360
|
+
* that resolve secrets via `GET /v1/resolve`.
|
|
361
|
+
*/
|
|
362
|
+
environmentId: z.string().min(1).nullish(),
|
|
363
|
+
expiresAt: z.iso.datetime().nullish()
|
|
364
|
+
});
|
|
365
|
+
z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
|
|
366
|
+
z.object({
|
|
367
|
+
endpoint: z.url().max(2048),
|
|
368
|
+
headers: z.record(z.string().min(1).max(256), z.string().max(4096)).optional(),
|
|
369
|
+
enabled: z.boolean().default(true)
|
|
370
|
+
});
|
|
371
|
+
z.object({
|
|
372
|
+
cursor: z.string().optional(),
|
|
373
|
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
374
|
+
action: z.string().optional(),
|
|
375
|
+
resourceType: z.string().optional()
|
|
376
|
+
});
|
|
377
|
+
//#endregion
|
|
10
378
|
//#region ../../packages/crypto/src/encoding.ts
|
|
11
379
|
const CHUNK = 32768;
|
|
12
380
|
/** Base64url (no padding) — portable across browsers, Workers, and Node. */
|
|
@@ -168,8 +536,8 @@ async function importPrivateKeyPkcs8(pkcs8) {
|
|
|
168
536
|
* browser, the CLI, the MCP server, and Workers — so this runs everywhere the
|
|
169
537
|
* SCRAM helper does, with no hand-rolled hash primitive.
|
|
170
538
|
*/
|
|
171
|
-
const DEFAULT_PASSWORD_LENGTH$
|
|
172
|
-
const PASSWORD_ALPHABET$
|
|
539
|
+
const DEFAULT_PASSWORD_LENGTH$2 = 32;
|
|
540
|
+
const PASSWORD_ALPHABET$2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
173
541
|
async function sha1(data) {
|
|
174
542
|
return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
|
|
175
543
|
}
|
|
@@ -178,12 +546,12 @@ function toUpperHex(bytes) {
|
|
|
178
546
|
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
179
547
|
return hex.toUpperCase();
|
|
180
548
|
}
|
|
181
|
-
function randomPassword$
|
|
549
|
+
function randomPassword$2(length) {
|
|
182
550
|
let out = "";
|
|
183
551
|
while (out.length < length) {
|
|
184
552
|
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
185
553
|
for (const byte of bytes) {
|
|
186
|
-
if (byte < 248) out += PASSWORD_ALPHABET$
|
|
554
|
+
if (byte < 248) out += PASSWORD_ALPHABET$2[byte % 62];
|
|
187
555
|
if (out.length === length) break;
|
|
188
556
|
}
|
|
189
557
|
}
|
|
@@ -202,7 +570,7 @@ async function mysqlNativePasswordVerifier(password) {
|
|
|
202
570
|
* — the client-side half of a Vault-style dynamic MySQL credential.
|
|
203
571
|
*/
|
|
204
572
|
async function generateMysqlCredential(options = {}) {
|
|
205
|
-
const password = randomPassword$
|
|
573
|
+
const password = randomPassword$2(options.length ?? DEFAULT_PASSWORD_LENGTH$2);
|
|
206
574
|
return {
|
|
207
575
|
password,
|
|
208
576
|
verifier: await mysqlNativePasswordVerifier(password)
|
|
@@ -268,6 +636,73 @@ async function decryptPrivateKey(passphrase, blob) {
|
|
|
268
636
|
throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
|
|
269
637
|
}
|
|
270
638
|
}
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region ../../packages/crypto/src/redis.ts
|
|
641
|
+
/**
|
|
642
|
+
* Client-side construction of a Redis (6+) ACL password verifier, for minting
|
|
643
|
+
* *temporary Redis login credentials* without the password plaintext ever
|
|
644
|
+
* reaching seekrit's control plane OR Redis itself.
|
|
645
|
+
*
|
|
646
|
+
* The trick mirrors the Postgres SCRAM (scram.ts) and MySQL (mysql.ts) ones:
|
|
647
|
+
* `ACL SETUSER <name> on #<hex>` stores the lowercase-hex SHA-256 of the
|
|
648
|
+
* password verbatim — Redis does NOT re-hash it. So the flow is:
|
|
649
|
+
*
|
|
650
|
+
* 1. the machine that will connect generates a random password locally,
|
|
651
|
+
* 2. computes this digest locally,
|
|
652
|
+
* 3. sends only the digest to the broker → `ACL SETUSER … on #<digest>`,
|
|
653
|
+
* 4. connects directly to Redis with the plaintext it never shared.
|
|
654
|
+
*
|
|
655
|
+
* Zero-knowledge at both layers: the control plane relays only the digest, and
|
|
656
|
+
* the digest is NOT sufficient to authenticate. Redis `AUTH <user> <password>`
|
|
657
|
+
* hashes the *plaintext* it receives with SHA-256 and compares it to the stored
|
|
658
|
+
* digest — verifying a client needs the password, not the digest, and SHA-256
|
|
659
|
+
* is preimage-resistant for a high-entropy machine-generated password. A dump of
|
|
660
|
+
* the ACL rules (`ACL GETUSER`, `CONFIG REWRITE`'d aclfile) therefore cannot log
|
|
661
|
+
* in.
|
|
662
|
+
*
|
|
663
|
+
* SHA-256 is available via WebCrypto (`crypto.subtle.digest`) in the browser,
|
|
664
|
+
* the CLI, the MCP server, and Workers — so this runs everywhere the SCRAM
|
|
665
|
+
* helper does, with no hand-rolled hash primitive.
|
|
666
|
+
*/
|
|
667
|
+
const DEFAULT_PASSWORD_LENGTH$1 = 32;
|
|
668
|
+
const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
669
|
+
async function sha256$1(data) {
|
|
670
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
|
|
671
|
+
}
|
|
672
|
+
function toLowerHex(bytes) {
|
|
673
|
+
let hex = "";
|
|
674
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
675
|
+
return hex;
|
|
676
|
+
}
|
|
677
|
+
function randomPassword$1(length) {
|
|
678
|
+
let out = "";
|
|
679
|
+
while (out.length < length) {
|
|
680
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
681
|
+
for (const byte of bytes) {
|
|
682
|
+
if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
|
|
683
|
+
if (out.length === length) break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Compute the Redis ACL password verifier `LOWER(HEX(SHA256(password)))` for a
|
|
690
|
+
* known password. Pass the result straight to `ACL SETUSER … on #<verifier>`.
|
|
691
|
+
*/
|
|
692
|
+
async function redisSha256Verifier(password) {
|
|
693
|
+
return toLowerHex(await sha256$1(utf8Encode(password)));
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Mint a fresh random password and its SHA-256 hex digest in one step — the
|
|
697
|
+
* client-side half of a Vault-style dynamic Redis credential.
|
|
698
|
+
*/
|
|
699
|
+
async function generateRedisCredential(options = {}) {
|
|
700
|
+
const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
|
|
701
|
+
return {
|
|
702
|
+
password,
|
|
703
|
+
verifier: await redisSha256Verifier(password)
|
|
704
|
+
};
|
|
705
|
+
}
|
|
271
706
|
const SALT_LENGTH = 16;
|
|
272
707
|
const DEFAULT_PASSWORD_LENGTH = 32;
|
|
273
708
|
const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
@@ -586,7 +1021,7 @@ async function unwrapDek(wrapped, privateKey) {
|
|
|
586
1021
|
}
|
|
587
1022
|
//#endregion
|
|
588
1023
|
//#region package.json
|
|
589
|
-
var version = "0.
|
|
1024
|
+
var version = "0.13.0";
|
|
590
1025
|
const PROJECT_FILE = "seekrit.json";
|
|
591
1026
|
function globalConfigPath() {
|
|
592
1027
|
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
@@ -933,6 +1368,37 @@ async function getDek(ctx, orgId, envId) {
|
|
|
933
1368
|
return unwrapDek(wrappedDek, privateKey);
|
|
934
1369
|
}
|
|
935
1370
|
//#endregion
|
|
1371
|
+
//#region src/dotenv.ts
|
|
1372
|
+
/**
|
|
1373
|
+
* Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
|
|
1374
|
+
* prefix, and single/double-quoted values (double quotes honor `\n \t \r \" \\`
|
|
1375
|
+
* escapes; unquoted values drop trailing ` # comments`). Multiline values are
|
|
1376
|
+
* not supported — keep those in seekrit itself.
|
|
1377
|
+
*/
|
|
1378
|
+
function parseDotenv(content) {
|
|
1379
|
+
const out = {};
|
|
1380
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
1381
|
+
let line = raw.trim();
|
|
1382
|
+
if (!line || line.startsWith("#")) continue;
|
|
1383
|
+
if (line.startsWith("export ")) line = line.slice(7).trimStart();
|
|
1384
|
+
const eq = line.indexOf("=");
|
|
1385
|
+
if (eq === -1) continue;
|
|
1386
|
+
const key = line.slice(0, eq).trim();
|
|
1387
|
+
if (!key) continue;
|
|
1388
|
+
let value = line.slice(eq + 1).trim();
|
|
1389
|
+
const quote = value[0];
|
|
1390
|
+
if (value.length >= 2 && (quote === "\"" || quote === "'") && value.at(-1) === quote) {
|
|
1391
|
+
value = value.slice(1, -1);
|
|
1392
|
+
if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
1393
|
+
} else {
|
|
1394
|
+
const comment = value.indexOf(" #");
|
|
1395
|
+
if (comment !== -1) value = value.slice(0, comment).trim();
|
|
1396
|
+
}
|
|
1397
|
+
out[key] = value;
|
|
1398
|
+
}
|
|
1399
|
+
return out;
|
|
1400
|
+
}
|
|
1401
|
+
//#endregion
|
|
936
1402
|
//#region src/format.ts
|
|
937
1403
|
function needsQuoting(value) {
|
|
938
1404
|
return /[\s"'`$\\#]/.test(value) || value === "";
|
|
@@ -1096,7 +1562,7 @@ async function resolveGroup(ctx, opts) {
|
|
|
1096
1562
|
* control plane only ever stores ciphertext.
|
|
1097
1563
|
*/
|
|
1098
1564
|
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
1099
|
-
function parseTtlSeconds$
|
|
1565
|
+
function parseTtlSeconds$3(input) {
|
|
1100
1566
|
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1101
1567
|
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
1102
1568
|
return Number(m[1]) * ({
|
|
@@ -1107,7 +1573,7 @@ function parseTtlSeconds$2(input) {
|
|
|
1107
1573
|
}[m[2] || "s"] ?? 1);
|
|
1108
1574
|
}
|
|
1109
1575
|
/** A fresh, valid MySQL user name: `tmp_` + lowercase alphanumerics. */
|
|
1110
|
-
function generateUserName(prefix = "tmp") {
|
|
1576
|
+
function generateUserName$1(prefix = "tmp") {
|
|
1111
1577
|
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
1112
1578
|
let out = "";
|
|
1113
1579
|
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
@@ -1117,7 +1583,7 @@ function generateUserName(prefix = "tmp") {
|
|
|
1117
1583
|
function registerMysqlCommands(program) {
|
|
1118
1584
|
const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
|
|
1119
1585
|
const target = mysql.command("target").description("manage provisioning targets");
|
|
1120
|
-
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$
|
|
1586
|
+
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$3, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$3, []).action(async (options) => {
|
|
1121
1587
|
const ctx = buildContext();
|
|
1122
1588
|
const org = await resolveOrg(ctx, options.org);
|
|
1123
1589
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -1182,8 +1648,8 @@ function registerMysqlCommands(program) {
|
|
|
1182
1648
|
const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
|
|
1183
1649
|
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1184
1650
|
if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
|
|
1185
|
-
const userName = options.user ?? generateUserName();
|
|
1186
|
-
const ttlSeconds = parseTtlSeconds$
|
|
1651
|
+
const userName = options.user ?? generateUserName$1();
|
|
1652
|
+
const ttlSeconds = parseTtlSeconds$3(options.ttl);
|
|
1187
1653
|
const { password, verifier } = await generateMysqlCredential();
|
|
1188
1654
|
const { connection } = await ctx.client.mintLease(org.id, {
|
|
1189
1655
|
provider: "mysql",
|
|
@@ -1218,325 +1684,10 @@ function registerMysqlCommands(program) {
|
|
|
1218
1684
|
});
|
|
1219
1685
|
}
|
|
1220
1686
|
/** Collect a repeatable option into an array. */
|
|
1221
|
-
function collect$
|
|
1687
|
+
function collect$3(value, acc) {
|
|
1222
1688
|
acc.push(value);
|
|
1223
1689
|
return acc;
|
|
1224
1690
|
}
|
|
1225
|
-
z.enum([
|
|
1226
|
-
"postgres",
|
|
1227
|
-
"mysql",
|
|
1228
|
-
"ssh"
|
|
1229
|
-
]);
|
|
1230
|
-
const executorModeSchema = z.enum(["in_do", "remote"]);
|
|
1231
|
-
/**
|
|
1232
|
-
* A Postgres role name we are willing to create. Deliberately strict — this
|
|
1233
|
-
* value is interpolated into a SQL template, so it must be a bare identifier
|
|
1234
|
-
* with no way to break out of quoting (no quotes, whitespace, or semicolons).
|
|
1235
|
-
*/
|
|
1236
|
-
const postgresRoleNameSchema = z.string().regex(/^[a-z_][a-z0-9_]{2,62}$/, "must be 3–63 chars, lowercase letters/digits/underscore, starting with a letter or underscore");
|
|
1237
|
-
/**
|
|
1238
|
-
* A SCRAM-SHA-256 verifier string as produced by @seekrit/crypto. Validated so
|
|
1239
|
-
* it, too, is safe to interpolate into a quoted SQL literal (the alphabet is
|
|
1240
|
-
* base64 + the fixed structural characters, none of which is a single quote).
|
|
1241
|
-
*/
|
|
1242
|
-
const scramVerifierSchema = z.string().regex(/^SCRAM-SHA-256\$\d{3,}:[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/, "must be a SCRAM-SHA-256 verifier");
|
|
1243
|
-
/**
|
|
1244
|
-
* An SSH login principal (a Unix-style username the certificate authorizes).
|
|
1245
|
-
* Bounded and restricted to a safe charset — principals are SSH-wire-encoded,
|
|
1246
|
-
* not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
|
|
1247
|
-
*/
|
|
1248
|
-
const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
|
|
1249
|
-
/** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
|
|
1250
|
-
const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
|
|
1251
|
-
/** An SSH certificate extension name, e.g. `permit-pty`. */
|
|
1252
|
-
const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
|
|
1253
|
-
/**
|
|
1254
|
-
* A MySQL/MariaDB user name we are willing to create. Interpolated into a
|
|
1255
|
-
* quoted SQL literal (`'{{name}}'@'%'`), so it is kept strict — plain
|
|
1256
|
-
* alphanumerics/underscore, no quotes/whitespace/semicolons to break out.
|
|
1257
|
-
*/
|
|
1258
|
-
const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
|
|
1259
|
-
/**
|
|
1260
|
-
* A `mysql_native_password` authentication string — `*` followed by 40 upper
|
|
1261
|
-
* hex chars (`UPPER(HEX(SHA1(SHA1(password))))`), as produced by
|
|
1262
|
-
* @seekrit/crypto `mysqlNativePasswordVerifier`. Stored verbatim by
|
|
1263
|
-
* `CREATE USER … IDENTIFIED WITH mysql_native_password AS '<str>'`, and its
|
|
1264
|
-
* alphabet contains no single quote, so it is safe in a quoted SQL literal.
|
|
1265
|
-
*/
|
|
1266
|
-
const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
|
|
1267
|
-
const postgresAccessLevelSchema = z.enum([
|
|
1268
|
-
"readonly",
|
|
1269
|
-
"readwrite",
|
|
1270
|
-
"custom"
|
|
1271
|
-
]);
|
|
1272
|
-
/** The group role each preset's leased credentials inherit. */
|
|
1273
|
-
const POSTGRES_GROUP_ROLES = {
|
|
1274
|
-
readonly: "seekrit_readonly",
|
|
1275
|
-
readwrite: "seekrit_readwrite"
|
|
1276
|
-
};
|
|
1277
|
-
const mysqlAccessLevelSchema = z.enum([
|
|
1278
|
-
"readonly",
|
|
1279
|
-
"readwrite",
|
|
1280
|
-
"custom"
|
|
1281
|
-
]);
|
|
1282
|
-
const connectionSchema = z.object({
|
|
1283
|
-
host: z.string().min(1),
|
|
1284
|
-
port: z.number().int().min(1).max(65535),
|
|
1285
|
-
database: z.string().min(1)
|
|
1286
|
-
});
|
|
1287
|
-
/** A `{{name}}`/`{{verifier}}`/`{{valid_until}}` templated SQL statement. */
|
|
1288
|
-
const statementSchema = z.string().min(1).max(4e3);
|
|
1289
|
-
/** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
|
|
1290
|
-
const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
|
|
1291
|
-
const postgresTargetConfigSchema = z.object({
|
|
1292
|
-
provider: z.literal("postgres"),
|
|
1293
|
-
executor: executorModeSchema,
|
|
1294
|
-
accessLevel: postgresAccessLevelSchema.optional(),
|
|
1295
|
-
schema: identifierSchema.optional(),
|
|
1296
|
-
connection: connectionSchema,
|
|
1297
|
-
provisionerUrl: z.url().optional(),
|
|
1298
|
-
createStatements: z.array(statementSchema).max(16).optional(),
|
|
1299
|
-
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
1300
|
-
});
|
|
1301
|
-
/** A MySQL account host part (`'name'@'<host>'`) — no quotes/whitespace. */
|
|
1302
|
-
const mysqlHostSchema = z.string().regex(/^[A-Za-z0-9_.%:-]{1,255}$/, "must be a host pattern");
|
|
1303
|
-
const mysqlTargetConfigSchema = z.object({
|
|
1304
|
-
provider: z.literal("mysql"),
|
|
1305
|
-
executor: executorModeSchema,
|
|
1306
|
-
accessLevel: mysqlAccessLevelSchema.optional(),
|
|
1307
|
-
connection: connectionSchema,
|
|
1308
|
-
userHost: mysqlHostSchema.optional(),
|
|
1309
|
-
provisionerUrl: z.url().optional(),
|
|
1310
|
-
createStatements: z.array(statementSchema).max(16).optional(),
|
|
1311
|
-
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
1312
|
-
});
|
|
1313
|
-
const sshTargetConfigSchema = z.object({
|
|
1314
|
-
provider: z.literal("ssh"),
|
|
1315
|
-
executor: z.literal("in_do"),
|
|
1316
|
-
caPublicKey: sshPublicKeySchema,
|
|
1317
|
-
allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
|
|
1318
|
-
extensions: z.array(sshExtensionSchema).max(16).optional(),
|
|
1319
|
-
maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
|
|
1320
|
-
connection: z.object({
|
|
1321
|
-
host: z.string().min(1).optional(),
|
|
1322
|
-
user: sshPrincipalSchema.optional()
|
|
1323
|
-
}).optional()
|
|
1324
|
-
});
|
|
1325
|
-
const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
|
|
1326
|
-
postgresTargetConfigSchema,
|
|
1327
|
-
mysqlTargetConfigSchema,
|
|
1328
|
-
sshTargetConfigSchema
|
|
1329
|
-
]);
|
|
1330
|
-
z.object({
|
|
1331
|
-
name: z.string().trim().min(1).max(128),
|
|
1332
|
-
config: leaseTargetConfigSchema,
|
|
1333
|
-
/**
|
|
1334
|
-
* The admin/provisioning credential (e.g. a Postgres connection string),
|
|
1335
|
-
* encrypted client-side to the broker's public key (a `wd1.` wrap). The
|
|
1336
|
-
* control plane stores only this ciphertext — it never sees the plaintext.
|
|
1337
|
-
*/
|
|
1338
|
-
wrappedAdminSecret: z.string().min(1)
|
|
1339
|
-
});
|
|
1340
|
-
/** Requested lease lifetime, shared by all providers. */
|
|
1341
|
-
const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
|
|
1342
|
-
/**
|
|
1343
|
-
* Client → API: mint a Postgres lease. The client generates the password and
|
|
1344
|
-
* its SCRAM verifier locally and sends only the verifier — the plaintext
|
|
1345
|
-
* password never leaves the requesting machine.
|
|
1346
|
-
*/
|
|
1347
|
-
const mintPostgresLeaseSchema = z.object({
|
|
1348
|
-
provider: z.literal("postgres"),
|
|
1349
|
-
targetId: z.string().min(1),
|
|
1350
|
-
roleName: postgresRoleNameSchema,
|
|
1351
|
-
verifier: scramVerifierSchema,
|
|
1352
|
-
ttlSeconds: ttlSecondsSchema
|
|
1353
|
-
});
|
|
1354
|
-
/**
|
|
1355
|
-
* Client → API: mint a MySQL/MariaDB lease. The client generates the password
|
|
1356
|
-
* and its `mysql_native_password` hash locally and sends only the hash — the
|
|
1357
|
-
* plaintext password never leaves the requesting machine.
|
|
1358
|
-
*/
|
|
1359
|
-
const mintMysqlLeaseSchema = z.object({
|
|
1360
|
-
provider: z.literal("mysql"),
|
|
1361
|
-
targetId: z.string().min(1),
|
|
1362
|
-
roleName: mysqlUserNameSchema,
|
|
1363
|
-
verifier: mysqlNativeVerifierSchema,
|
|
1364
|
-
ttlSeconds: ttlSecondsSchema
|
|
1365
|
-
});
|
|
1366
|
-
/**
|
|
1367
|
-
* Client → API: mint an SSH lease. The client generates an ephemeral keypair
|
|
1368
|
-
* locally and sends only the public key; the signed certificate comes back in
|
|
1369
|
-
* the response. The private key never leaves the requesting machine.
|
|
1370
|
-
*/
|
|
1371
|
-
const mintSshLeaseSchema = z.object({
|
|
1372
|
-
provider: z.literal("ssh"),
|
|
1373
|
-
targetId: z.string().min(1),
|
|
1374
|
-
publicKey: sshPublicKeySchema,
|
|
1375
|
-
principals: z.array(sshPrincipalSchema).min(1).max(32),
|
|
1376
|
-
ttlSeconds: ttlSecondsSchema
|
|
1377
|
-
});
|
|
1378
|
-
z.discriminatedUnion("provider", [
|
|
1379
|
-
mintPostgresLeaseSchema,
|
|
1380
|
-
mintMysqlLeaseSchema,
|
|
1381
|
-
mintSshLeaseSchema
|
|
1382
|
-
]);
|
|
1383
|
-
//#endregion
|
|
1384
|
-
//#region ../../packages/core/src/providers/postgres.ts
|
|
1385
|
-
/**
|
|
1386
|
-
* The one-time setup SQL an admin runs to create the shared group role that a
|
|
1387
|
-
* read-only / read-write target's leased credentials inherit. Idempotent (safe
|
|
1388
|
-
* to re-run). Returns null for custom targets (the admin owns their own SQL).
|
|
1389
|
-
*
|
|
1390
|
-
* Identifiers are interpolated from admin-supplied config (database/schema) and
|
|
1391
|
-
* fixed group-role constants — this SQL is displayed for the admin to run in
|
|
1392
|
-
* their own database, not executed by seekrit.
|
|
1393
|
-
*/
|
|
1394
|
-
function postgresGroupBootstrapSql(config) {
|
|
1395
|
-
if (config.accessLevel !== "readonly" && config.accessLevel !== "readwrite") return null;
|
|
1396
|
-
const group = POSTGRES_GROUP_ROLES[config.accessLevel];
|
|
1397
|
-
const schema = config.schema ?? "public";
|
|
1398
|
-
const db = config.connection.database;
|
|
1399
|
-
const privileges = config.accessLevel === "readonly" ? "SELECT" : "SELECT, INSERT, UPDATE, DELETE";
|
|
1400
|
-
const lines = [
|
|
1401
|
-
`-- Run once as an admin on "${db}". Temporary ${config.accessLevel} credentials inherit this role.`,
|
|
1402
|
-
"DO $$ BEGIN",
|
|
1403
|
-
` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${group}') THEN`,
|
|
1404
|
-
` CREATE ROLE ${group} NOLOGIN;`,
|
|
1405
|
-
" END IF;",
|
|
1406
|
-
"END $$;",
|
|
1407
|
-
`GRANT CONNECT ON DATABASE "${db}" TO ${group};`,
|
|
1408
|
-
`GRANT USAGE ON SCHEMA "${schema}" TO ${group};`,
|
|
1409
|
-
`GRANT ${privileges} ON ALL TABLES IN SCHEMA "${schema}" TO ${group};`,
|
|
1410
|
-
`ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT ${privileges} ON TABLES TO ${group};`
|
|
1411
|
-
];
|
|
1412
|
-
if (config.accessLevel === "readwrite") lines.push(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "${schema}" TO ${group};`, `ALTER DEFAULT PRIVILEGES IN SCHEMA "${schema}" GRANT USAGE, SELECT ON SEQUENCES TO ${group};`);
|
|
1413
|
-
return lines.join("\n");
|
|
1414
|
-
}
|
|
1415
|
-
//#endregion
|
|
1416
|
-
//#region ../../packages/core/src/providers/ssh.ts
|
|
1417
|
-
/**
|
|
1418
|
-
* The one-time host setup an admin runs so a target's certificates are accepted.
|
|
1419
|
-
* Analogue of `postgresGroupBootstrapSql` — displayed for the admin to run, not
|
|
1420
|
-
* executed by seekrit. Embeds the CA public key so it's copy-paste runnable.
|
|
1421
|
-
*/
|
|
1422
|
-
function sshHostSetupInstructions(config) {
|
|
1423
|
-
const principals = config.allowedPrincipals?.length ? config.allowedPrincipals : ["<login-user>"];
|
|
1424
|
-
return [
|
|
1425
|
-
"# Run once on each target host so it trusts seekrit-issued certificates.",
|
|
1426
|
-
"# 1. Install the CA public key and trust it for user authentication:",
|
|
1427
|
-
`echo '${config.caPublicKey}' | sudo tee /etc/ssh/seekrit_ca.pub`,
|
|
1428
|
-
"sudo sh -c 'echo \"TrustedUserCAKeys /etc/ssh/seekrit_ca.pub\" >> /etc/ssh/sshd_config'",
|
|
1429
|
-
"# 2. (optional) Restrict which cert principals may log in as which users via",
|
|
1430
|
-
"# AuthorizedPrincipalsFile, e.g. /etc/ssh/auth_principals/<user> listing:",
|
|
1431
|
-
...principals.map((p) => `# ${p}`),
|
|
1432
|
-
"# 3. Reload sshd:",
|
|
1433
|
-
"sudo systemctl reload sshd"
|
|
1434
|
-
].join("\n");
|
|
1435
|
-
}
|
|
1436
|
-
//#endregion
|
|
1437
|
-
//#region ../../packages/core/src/types.ts
|
|
1438
|
-
/**
|
|
1439
|
-
* Transactional notification emails seekrit can send. Each id is one
|
|
1440
|
-
* user-facing on/off toggle (see `NOTIFICATION_TYPE_META`). These carry only
|
|
1441
|
-
* audit-grade metadata — never secret material — and every one is opt-out
|
|
1442
|
-
* (defaults on). Kept as a const array so the API, api-client, and dashboard
|
|
1443
|
-
* share a single source of truth (mirrors `AUDIT_ACTIONS`).
|
|
1444
|
-
*/
|
|
1445
|
-
const NOTIFICATION_TYPES = [
|
|
1446
|
-
"token_created",
|
|
1447
|
-
"token_revoked",
|
|
1448
|
-
"env_access_granted",
|
|
1449
|
-
"env_access_revoked",
|
|
1450
|
-
"resolve_denied",
|
|
1451
|
-
"org_welcome",
|
|
1452
|
-
"token_expiring",
|
|
1453
|
-
"lease_expired"
|
|
1454
|
-
];
|
|
1455
|
-
//#endregion
|
|
1456
|
-
//#region ../../packages/core/src/schemas.ts
|
|
1457
|
-
/** URL-safe identifier segment: `my-app`, `production`, … */
|
|
1458
|
-
const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
|
|
1459
|
-
const nameSchema = z.string().trim().min(1).max(128);
|
|
1460
|
-
z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid environment variable name");
|
|
1461
|
-
z.enum([
|
|
1462
|
-
"owner",
|
|
1463
|
-
"admin",
|
|
1464
|
-
"member"
|
|
1465
|
-
]);
|
|
1466
|
-
const principalTypeSchema = z.enum(["user", "service_token"]);
|
|
1467
|
-
/** Org-level capability a service token can hold (never `owner`). */
|
|
1468
|
-
const serviceTokenRoleSchema = z.enum(["admin", "member"]);
|
|
1469
|
-
z.object({
|
|
1470
|
-
name: nameSchema,
|
|
1471
|
-
slug: slugSchema
|
|
1472
|
-
});
|
|
1473
|
-
z.object({
|
|
1474
|
-
name: nameSchema,
|
|
1475
|
-
slug: slugSchema
|
|
1476
|
-
});
|
|
1477
|
-
z.object({
|
|
1478
|
-
name: nameSchema,
|
|
1479
|
-
slug: slugSchema
|
|
1480
|
-
});
|
|
1481
|
-
z.object({
|
|
1482
|
-
groupId: z.string().min(1),
|
|
1483
|
-
/** Precedence among an env's groups (higher wins). Appended if omitted. */
|
|
1484
|
-
position: z.number().int().min(0).optional()
|
|
1485
|
-
});
|
|
1486
|
-
z.object({
|
|
1487
|
-
name: nameSchema,
|
|
1488
|
-
slug: slugSchema,
|
|
1489
|
-
/** Environment DEK wrapped to the creator's public key — created client-side. */
|
|
1490
|
-
wrappedDek: z.string().min(1)
|
|
1491
|
-
});
|
|
1492
|
-
z.object({
|
|
1493
|
-
/** Opaque versioned ciphertext blob from @seekrit/crypto. */
|
|
1494
|
-
ciphertext: z.string().min(1).max(65536) });
|
|
1495
|
-
z.object({
|
|
1496
|
-
publicKeyJwk: z.string().min(1),
|
|
1497
|
-
/**
|
|
1498
|
-
* Private key encrypted with a passphrase-derived KEK; opaque to the
|
|
1499
|
-
* server. Self-contained blob (embeds KDF salt + iterations).
|
|
1500
|
-
*/
|
|
1501
|
-
encryptedPrivateKey: z.string().min(1)
|
|
1502
|
-
});
|
|
1503
|
-
z.object({
|
|
1504
|
-
principalType: principalTypeSchema,
|
|
1505
|
-
principalId: z.string().min(1),
|
|
1506
|
-
wrappedDek: z.string().min(1)
|
|
1507
|
-
});
|
|
1508
|
-
z.object({
|
|
1509
|
-
name: nameSchema,
|
|
1510
|
-
tokenId: z.string().regex(/^skt_[0-9A-Za-z]+$/),
|
|
1511
|
-
/** SHA-256 hash (base64url) of the full token string. */
|
|
1512
|
-
tokenHash: z.string().min(1),
|
|
1513
|
-
publicKeyJwk: z.string().min(1),
|
|
1514
|
-
/**
|
|
1515
|
-
* Org-level capability. Defaults to `member` (a runtime credential); pass
|
|
1516
|
-
* `admin` to mint a headless provisioning token. Only an admin caller may
|
|
1517
|
-
* create an `admin` token, so capability cannot escalate itself.
|
|
1518
|
-
*/
|
|
1519
|
-
role: serviceTokenRoleSchema.default("member"),
|
|
1520
|
-
/**
|
|
1521
|
-
* The application environment this token is bound to (org + app + env).
|
|
1522
|
-
* Optional so org-admin tokens can exist, but required for runtime tokens
|
|
1523
|
-
* that resolve secrets via `GET /v1/resolve`.
|
|
1524
|
-
*/
|
|
1525
|
-
environmentId: z.string().min(1).nullish(),
|
|
1526
|
-
expiresAt: z.iso.datetime().nullish()
|
|
1527
|
-
});
|
|
1528
|
-
z.object({ prefs: z.partialRecord(z.enum(NOTIFICATION_TYPES), z.boolean()) });
|
|
1529
|
-
z.object({
|
|
1530
|
-
endpoint: z.url().max(2048),
|
|
1531
|
-
headers: z.record(z.string().min(1).max(256), z.string().max(4096)).optional(),
|
|
1532
|
-
enabled: z.boolean().default(true)
|
|
1533
|
-
});
|
|
1534
|
-
z.object({
|
|
1535
|
-
cursor: z.string().optional(),
|
|
1536
|
-
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
1537
|
-
action: z.string().optional(),
|
|
1538
|
-
resourceType: z.string().optional()
|
|
1539
|
-
});
|
|
1540
1691
|
//#endregion
|
|
1541
1692
|
//#region src/pg.ts
|
|
1542
1693
|
/**
|
|
@@ -1549,7 +1700,7 @@ z.object({
|
|
|
1549
1700
|
* ciphertext.
|
|
1550
1701
|
*/
|
|
1551
1702
|
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
1552
|
-
function parseTtlSeconds$
|
|
1703
|
+
function parseTtlSeconds$2(input) {
|
|
1553
1704
|
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1554
1705
|
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
1555
1706
|
return Number(m[1]) * ({
|
|
@@ -1570,7 +1721,7 @@ function generateRoleName(prefix = "tmp") {
|
|
|
1570
1721
|
function registerPgCommands(program) {
|
|
1571
1722
|
const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
|
|
1572
1723
|
const target = pg.command("target").description("manage provisioning targets");
|
|
1573
|
-
target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$
|
|
1724
|
+
target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_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) => {
|
|
1574
1725
|
const ctx = buildContext();
|
|
1575
1726
|
const org = await resolveOrg(ctx, options.org);
|
|
1576
1727
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -1652,7 +1803,7 @@ function registerPgCommands(program) {
|
|
|
1652
1803
|
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1653
1804
|
if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
|
|
1654
1805
|
const roleName = options.role ?? generateRoleName();
|
|
1655
|
-
const ttlSeconds = parseTtlSeconds$
|
|
1806
|
+
const ttlSeconds = parseTtlSeconds$2(options.ttl);
|
|
1656
1807
|
const { password, verifier } = await generatePostgresCredential();
|
|
1657
1808
|
const { connection } = await ctx.client.mintLease(org.id, {
|
|
1658
1809
|
provider: "postgres",
|
|
@@ -1684,40 +1835,148 @@ function registerPgCommands(program) {
|
|
|
1684
1835
|
});
|
|
1685
1836
|
}
|
|
1686
1837
|
/** Collect a repeatable option into an array. */
|
|
1687
|
-
function collect$
|
|
1838
|
+
function collect$2(value, acc) {
|
|
1688
1839
|
acc.push(value);
|
|
1689
1840
|
return acc;
|
|
1690
1841
|
}
|
|
1691
1842
|
//#endregion
|
|
1692
|
-
//#region src/
|
|
1843
|
+
//#region src/redis.ts
|
|
1693
1844
|
/**
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1845
|
+
* `seekrit redis` — temporary Redis (6+) credentials (Vault-style dynamic
|
|
1846
|
+
* secrets).
|
|
1847
|
+
*
|
|
1848
|
+
* Zero-knowledge: minting generates the password and its SHA-256 digest on THIS
|
|
1849
|
+
* machine and sends only the digest; the plaintext password never reaches the
|
|
1850
|
+
* API or gets stored. Registering a target wraps the admin connection string to
|
|
1851
|
+
* the broker's public key locally, so the control plane only ever stores
|
|
1852
|
+
* ciphertext.
|
|
1698
1853
|
*/
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1854
|
+
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
1855
|
+
function parseTtlSeconds$1(input) {
|
|
1856
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1857
|
+
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
1858
|
+
return Number(m[1]) * ({
|
|
1859
|
+
s: 1,
|
|
1860
|
+
m: 60,
|
|
1861
|
+
h: 3600,
|
|
1862
|
+
d: 86400
|
|
1863
|
+
}[m[2] || "s"] ?? 1);
|
|
1864
|
+
}
|
|
1865
|
+
/** A fresh, valid Redis ACL user name: `tmp_` + lowercase alphanumerics. */
|
|
1866
|
+
function generateUserName(prefix = "tmp") {
|
|
1867
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
1868
|
+
let out = "";
|
|
1869
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
1870
|
+
for (const b of bytes) out += alphabet[b % 36];
|
|
1871
|
+
return `${prefix}_${out}`;
|
|
1872
|
+
}
|
|
1873
|
+
function registerRedisCommands(program) {
|
|
1874
|
+
const redis = program.command("redis").description("temporary Redis credentials (short-lived, zero-knowledge)");
|
|
1875
|
+
const target = redis.command("target").description("manage provisioning targets");
|
|
1876
|
+
target.command("add").description("register a Redis server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-cache").option("--org <slug>").requiredOption("--host <host>", "redis host").option("--port <port>", "redis port", "6379").option("--db <index>", "logical database index (the /<n> in the URL)").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin redis:// (or rediss://) connection string (or set SEEKRIT_REDIS_ADMIN_URL); wrapped locally").option("--create-statement <cmd>", "custom SETUSER template (repeatable)", collect$1, []).option("--revoke-statement <cmd>", "custom DELUSER template (repeatable)", collect$1, []).action(async (options) => {
|
|
1877
|
+
const ctx = buildContext();
|
|
1878
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1879
|
+
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
1880
|
+
if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
|
|
1881
|
+
if (![
|
|
1882
|
+
"readonly",
|
|
1883
|
+
"readwrite",
|
|
1884
|
+
"custom"
|
|
1885
|
+
].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
|
|
1886
|
+
const accessLevel = options.access;
|
|
1887
|
+
const adminSecret = resolveLeaseAdminSecret({
|
|
1888
|
+
executor,
|
|
1889
|
+
hmacKey: options.hmacKey,
|
|
1890
|
+
adminUrl: options.adminUrl,
|
|
1891
|
+
adminUrlEnv: "SEEKRIT_REDIS_ADMIN_URL"
|
|
1892
|
+
});
|
|
1893
|
+
const config = {
|
|
1894
|
+
provider: "redis",
|
|
1895
|
+
executor,
|
|
1896
|
+
accessLevel,
|
|
1897
|
+
connection: {
|
|
1898
|
+
host: options.host,
|
|
1899
|
+
port: Number.parseInt(options.port, 10),
|
|
1900
|
+
...options.db !== void 0 ? { db: Number.parseInt(options.db, 10) } : {}
|
|
1901
|
+
},
|
|
1902
|
+
...accessLevel === "custom" ? {
|
|
1903
|
+
...options.createStatement.length ? { createStatements: options.createStatement } : {},
|
|
1904
|
+
...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
|
|
1905
|
+
} : {},
|
|
1906
|
+
...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
|
|
1907
|
+
};
|
|
1908
|
+
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
1909
|
+
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
|
|
1910
|
+
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
1911
|
+
name: options.name,
|
|
1912
|
+
config,
|
|
1913
|
+
wrappedAdminSecret
|
|
1914
|
+
});
|
|
1915
|
+
console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
|
|
1916
|
+
});
|
|
1917
|
+
target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
|
|
1918
|
+
const ctx = buildContext();
|
|
1919
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1920
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1921
|
+
for (const t of targets) {
|
|
1922
|
+
if (t.provider !== "redis") continue;
|
|
1923
|
+
const cfg = t.config;
|
|
1924
|
+
const db = cfg.connection.db ?? 0;
|
|
1925
|
+
console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${db}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
|
|
1717
1926
|
}
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1927
|
+
});
|
|
1928
|
+
target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
|
|
1929
|
+
const ctx = buildContext();
|
|
1930
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1931
|
+
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
1932
|
+
console.error(`removed ${targetId}`);
|
|
1933
|
+
});
|
|
1934
|
+
redis.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--user <name>", "ACL 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) => {
|
|
1935
|
+
const ctx = buildContext();
|
|
1936
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1937
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1938
|
+
const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
|
|
1939
|
+
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1940
|
+
if (target.provider !== "redis") fail(`target "${targetRef}" is not a Redis target`);
|
|
1941
|
+
const userName = options.user ?? generateUserName();
|
|
1942
|
+
const ttlSeconds = parseTtlSeconds$1(options.ttl);
|
|
1943
|
+
const { password, verifier } = await generateRedisCredential();
|
|
1944
|
+
const { connection } = await ctx.client.mintLease(org.id, {
|
|
1945
|
+
provider: "redis",
|
|
1946
|
+
targetId: target.id,
|
|
1947
|
+
roleName: userName,
|
|
1948
|
+
verifier,
|
|
1949
|
+
ttlSeconds
|
|
1950
|
+
});
|
|
1951
|
+
const url = `redis://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
|
|
1952
|
+
console.error(`leased ${userName} on ${connection.host}:${connection.port} — expires ${connection.expiresAt}`);
|
|
1953
|
+
if (options.json) console.log(JSON.stringify({
|
|
1954
|
+
...connection,
|
|
1955
|
+
password,
|
|
1956
|
+
url
|
|
1957
|
+
}, null, 2));
|
|
1958
|
+
else console.log(url);
|
|
1959
|
+
});
|
|
1960
|
+
redis.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
|
|
1961
|
+
const ctx = buildContext();
|
|
1962
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1963
|
+
const { leases } = await ctx.client.listLeases(org.id);
|
|
1964
|
+
for (const l of leases) {
|
|
1965
|
+
if (l.provider !== "redis") continue;
|
|
1966
|
+
console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
redis.command("revoke <leaseId>").description("revoke a lease now (deletes the ACL user immediately)").option("--org <slug>").action(async (leaseId, options) => {
|
|
1970
|
+
const ctx = buildContext();
|
|
1971
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1972
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
1973
|
+
console.error(`revoked ${leaseId}`);
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
/** Collect a repeatable option into an array. */
|
|
1977
|
+
function collect$1(value, acc) {
|
|
1978
|
+
acc.push(value);
|
|
1979
|
+
return acc;
|
|
1721
1980
|
}
|
|
1722
1981
|
//#endregion
|
|
1723
1982
|
//#region src/secrets.ts
|
|
@@ -1732,6 +1991,27 @@ async function encryptAndSetSecret(ctx, orgId, envId, name, value) {
|
|
|
1732
1991
|
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
1733
1992
|
}
|
|
1734
1993
|
/**
|
|
1994
|
+
* Encrypt and store many secrets into one environment. The DEK is fetched once
|
|
1995
|
+
* (so user auth prompts for the passphrase a single time, not per variable),
|
|
1996
|
+
* then each value is encrypted locally and written. Existing names are
|
|
1997
|
+
* overwritten; the result splits them into created vs. updated for a summary.
|
|
1998
|
+
* Callers validate the names first — a rejected name aborts before any write.
|
|
1999
|
+
*/
|
|
2000
|
+
async function importSecrets(ctx, orgId, envId, entries) {
|
|
2001
|
+
const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
|
|
2002
|
+
const existing = new Set(secrets.map((s) => s.name));
|
|
2003
|
+
const result = {
|
|
2004
|
+
created: [],
|
|
2005
|
+
updated: []
|
|
2006
|
+
};
|
|
2007
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
2008
|
+
const ciphertext = await encryptSecret(dek, value, secretAad(envId, name));
|
|
2009
|
+
await ctx.client.setSecret(orgId, envId, name, ciphertext);
|
|
2010
|
+
(existing.has(name) ? result.updated : result.created).push(name);
|
|
2011
|
+
}
|
|
2012
|
+
return result;
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
1735
2015
|
* Resolve the full, layered environment for a running app: composed group
|
|
1736
2016
|
* secrets (lowest precedence) → the app env's own secrets → `.env` files.
|
|
1737
2017
|
* Each layer's DEK is unwrapped once with the principal's private key and its
|
|
@@ -2111,6 +2391,31 @@ withTarget(secrets.command("set <name> [value]").description("encrypt and store
|
|
|
2111
2391
|
await encryptAndSetSecret(ctx, orgId, envId, name, value === void 0 || value === "-" ? (await readStdin()).replace(/\n$/, "") : value);
|
|
2112
2392
|
console.error(`${name} saved`);
|
|
2113
2393
|
});
|
|
2394
|
+
withTarget(secrets.command("import [file]").description("bulk-import secrets from a .env file (default .env; '-' reads stdin)").option("--dry-run", "list the variable names that would be imported, without writing")).action(async (file, options) => {
|
|
2395
|
+
const source = file ?? ".env";
|
|
2396
|
+
let content;
|
|
2397
|
+
if (source === "-") content = await readStdin();
|
|
2398
|
+
else {
|
|
2399
|
+
if (!existsSync(source)) fail(`no such file: ${source}`);
|
|
2400
|
+
content = readFileSync(source, "utf8");
|
|
2401
|
+
}
|
|
2402
|
+
const entries = parseDotenv(content);
|
|
2403
|
+
const names = Object.keys(entries);
|
|
2404
|
+
if (names.length === 0) fail(`no variables found in ${source === "-" ? "stdin" : source}`);
|
|
2405
|
+
const invalid = names.filter((name) => !secretNameSchema.safeParse(name).success);
|
|
2406
|
+
if (invalid.length > 0) fail(`invalid secret name(s): ${invalid.join(", ")} (must match [A-Za-z_][A-Za-z0-9_]*)`);
|
|
2407
|
+
const ctx = buildContext();
|
|
2408
|
+
const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
|
|
2409
|
+
if (options.dryRun) {
|
|
2410
|
+
const { secrets: existingRows } = await ctx.client.listSecrets(orgId, envId);
|
|
2411
|
+
const existing = new Set(existingRows.map((s) => s.name));
|
|
2412
|
+
console.error(`would import ${names.length} secret(s) into ${label}:`);
|
|
2413
|
+
for (const name of names.sort()) console.error(` ${name}\t${existing.has(name) ? "update" : "new"}`);
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
const { created, updated } = await importSecrets(ctx, orgId, envId, entries);
|
|
2417
|
+
console.error(`imported ${created.length + updated.length} secret(s) into ${label} (${created.length} new, ${updated.length} updated)`);
|
|
2418
|
+
});
|
|
2114
2419
|
withTarget(secrets.command("rm <name>").description("delete a secret")).action(async (name, options) => {
|
|
2115
2420
|
const ctx = buildContext();
|
|
2116
2421
|
const { orgId, envId } = await resolveEnvTarget(ctx, options);
|
|
@@ -2288,6 +2593,7 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
|
|
|
2288
2593
|
});
|
|
2289
2594
|
registerPgCommands(program);
|
|
2290
2595
|
registerMysqlCommands(program);
|
|
2596
|
+
registerRedisCommands(program);
|
|
2291
2597
|
registerProvisionerCommands(program);
|
|
2292
2598
|
registerSshCommands(program);
|
|
2293
2599
|
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|