@seekrit/cli 0.12.0 → 0.14.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 +724 -211
- package/dist/{mcp-BEcV_KpM.js → mcp-ARBuneH3.js} +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -10,7 +10,9 @@ import { Writable } from "node:stream";
|
|
|
10
10
|
z.enum([
|
|
11
11
|
"postgres",
|
|
12
12
|
"mysql",
|
|
13
|
-
"ssh"
|
|
13
|
+
"ssh",
|
|
14
|
+
"redis",
|
|
15
|
+
"aws"
|
|
14
16
|
]);
|
|
15
17
|
const executorModeSchema = z.enum(["in_do", "remote"]);
|
|
16
18
|
/**
|
|
@@ -49,6 +51,51 @@ const mysqlUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3
|
|
|
49
51
|
* alphabet contains no single quote, so it is safe in a quoted SQL literal.
|
|
50
52
|
*/
|
|
51
53
|
const mysqlNativeVerifierSchema = z.string().regex(/^\*[0-9A-F]{40}$/, "must be a mysql_native_password hash (*<40 hex>)");
|
|
54
|
+
/**
|
|
55
|
+
* A Redis ACL user name we are willing to create. Interpolated into a Redis
|
|
56
|
+
* command line as a bare token (`ACL SETUSER <name> …`), so it is kept strict —
|
|
57
|
+
* plain alphanumerics/underscore, no whitespace to split the arg or ACL rule
|
|
58
|
+
* characters (`~ + @ # & %`) that could be read as a permission.
|
|
59
|
+
*/
|
|
60
|
+
const redisUserNameSchema = z.string().regex(/^[A-Za-z0-9_]{3,32}$/, "must be 3–32 chars, letters/digits/underscore");
|
|
61
|
+
/**
|
|
62
|
+
* A Redis password verifier — the lowercase-hex SHA-256 of the password, as
|
|
63
|
+
* produced by @seekrit/crypto `redisSha256Verifier`. `ACL SETUSER … on #<hex>`
|
|
64
|
+
* stores this digest verbatim, and it cannot authenticate: Redis `AUTH` hashes
|
|
65
|
+
* the *plaintext* it receives with SHA-256 and compares, so the stored digest
|
|
66
|
+
* is preimage-resistant (the password is high-entropy and machine-generated).
|
|
67
|
+
* The alphabet is bare hex, so it is a safe bare command token.
|
|
68
|
+
*/
|
|
69
|
+
const redisSha256VerifierSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase-hex SHA-256 digest (64 chars)");
|
|
70
|
+
/**
|
|
71
|
+
* An IAM role ARN the broker is allowed to assume. Bounded and structurally
|
|
72
|
+
* validated: `arn:<partition>:iam::<account>:role/<path-and-name>`. Partition
|
|
73
|
+
* covers commercial (`aws`), GovCloud (`aws-us-gov`), and China (`aws-cn`).
|
|
74
|
+
*/
|
|
75
|
+
const awsRoleArnSchema = z.string().regex(/^arn:aws(?:-us-gov|-cn)?:iam::\d{12}:role\/[\w+=,.@/-]{1,512}$/, "must be an IAM role ARN (arn:aws:iam::<account>:role/<name>)");
|
|
76
|
+
/** An AWS region id, e.g. `us-east-1`, `eu-west-2`, `us-gov-west-1`. */
|
|
77
|
+
const awsRegionSchema = z.string().regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/, "must be an AWS region id (e.g. us-east-1)");
|
|
78
|
+
/**
|
|
79
|
+
* An STS external id — the shared string a role's trust policy can require so a
|
|
80
|
+
* confused-deputy can't assume it. AWS allows a broad charset; we keep to the
|
|
81
|
+
* documented safe set and bound the length.
|
|
82
|
+
*/
|
|
83
|
+
const awsExternalIdSchema = z.string().regex(/^[\w+=,.@:/-]{2,1224}$/, "must be a valid STS external id");
|
|
84
|
+
z.string().regex(/^[\w+=,.@-]{2,64}$/, "must be 2–64 chars of [A-Za-z0-9_+=,.@-]");
|
|
85
|
+
/**
|
|
86
|
+
* The consumer's ephemeral P-256 public key (JWK-serialized) that a tier-2
|
|
87
|
+
* credential is wrapped to before it is returned. Validated structurally here;
|
|
88
|
+
* the executor imports it defensively before wrapping. Bounded so a giant blob
|
|
89
|
+
* can't be pushed through the control plane.
|
|
90
|
+
*/
|
|
91
|
+
const p256PublicKeyJwkSchema = z.string().max(2048).refine((s) => {
|
|
92
|
+
try {
|
|
93
|
+
const jwk = JSON.parse(s);
|
|
94
|
+
return jwk.kty === "EC" && jwk.crv === "P-256" && !!jwk.x && !!jwk.y;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}, "must be a JWK-serialized P-256 public key");
|
|
52
99
|
const postgresAccessLevelSchema = z.enum([
|
|
53
100
|
"readonly",
|
|
54
101
|
"readwrite",
|
|
@@ -64,6 +111,11 @@ const mysqlAccessLevelSchema = z.enum([
|
|
|
64
111
|
"readwrite",
|
|
65
112
|
"custom"
|
|
66
113
|
]);
|
|
114
|
+
const redisAccessLevelSchema = z.enum([
|
|
115
|
+
"readonly",
|
|
116
|
+
"readwrite",
|
|
117
|
+
"custom"
|
|
118
|
+
]);
|
|
67
119
|
const connectionSchema = z.object({
|
|
68
120
|
host: z.string().min(1),
|
|
69
121
|
port: z.number().int().min(1).max(65535),
|
|
@@ -95,6 +147,21 @@ const mysqlTargetConfigSchema = z.object({
|
|
|
95
147
|
createStatements: z.array(statementSchema).max(16).optional(),
|
|
96
148
|
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
97
149
|
});
|
|
150
|
+
const redisConnectionSchema = z.object({
|
|
151
|
+
host: z.string().min(1),
|
|
152
|
+
port: z.number().int().min(1).max(65535),
|
|
153
|
+
/** Redis logical database index (the `/<n>` in a connection URL). */
|
|
154
|
+
db: z.number().int().min(0).max(15).optional()
|
|
155
|
+
});
|
|
156
|
+
const redisTargetConfigSchema = z.object({
|
|
157
|
+
provider: z.literal("redis"),
|
|
158
|
+
executor: executorModeSchema,
|
|
159
|
+
accessLevel: redisAccessLevelSchema.optional(),
|
|
160
|
+
connection: redisConnectionSchema,
|
|
161
|
+
provisionerUrl: z.url().optional(),
|
|
162
|
+
createStatements: z.array(statementSchema).max(16).optional(),
|
|
163
|
+
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
164
|
+
});
|
|
98
165
|
const sshTargetConfigSchema = z.object({
|
|
99
166
|
provider: z.literal("ssh"),
|
|
100
167
|
executor: z.literal("in_do"),
|
|
@@ -107,10 +174,22 @@ const sshTargetConfigSchema = z.object({
|
|
|
107
174
|
user: sshPrincipalSchema.optional()
|
|
108
175
|
}).optional()
|
|
109
176
|
});
|
|
177
|
+
const AWS_MAX_TTL_SECONDS = 3600 * 12;
|
|
178
|
+
const awsTargetConfigSchema = z.object({
|
|
179
|
+
provider: z.literal("aws"),
|
|
180
|
+
executor: z.literal("in_do"),
|
|
181
|
+
roleArn: awsRoleArnSchema,
|
|
182
|
+
region: awsRegionSchema,
|
|
183
|
+
externalId: awsExternalIdSchema.optional(),
|
|
184
|
+
sessionPolicy: z.string().min(1).max(4e3).optional(),
|
|
185
|
+
maxTtlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS).optional()
|
|
186
|
+
});
|
|
110
187
|
const leaseTargetConfigSchema = z.discriminatedUnion("provider", [
|
|
111
188
|
postgresTargetConfigSchema,
|
|
112
189
|
mysqlTargetConfigSchema,
|
|
113
|
-
|
|
190
|
+
redisTargetConfigSchema,
|
|
191
|
+
sshTargetConfigSchema,
|
|
192
|
+
awsTargetConfigSchema
|
|
114
193
|
]);
|
|
115
194
|
z.object({
|
|
116
195
|
name: z.string().trim().min(1).max(128),
|
|
@@ -149,6 +228,18 @@ const mintMysqlLeaseSchema = z.object({
|
|
|
149
228
|
ttlSeconds: ttlSecondsSchema
|
|
150
229
|
});
|
|
151
230
|
/**
|
|
231
|
+
* Client → API: mint a Redis lease. The client generates the password and its
|
|
232
|
+
* SHA-256 hex digest locally and sends only the digest — the plaintext password
|
|
233
|
+
* never leaves the requesting machine.
|
|
234
|
+
*/
|
|
235
|
+
const mintRedisLeaseSchema = z.object({
|
|
236
|
+
provider: z.literal("redis"),
|
|
237
|
+
targetId: z.string().min(1),
|
|
238
|
+
roleName: redisUserNameSchema,
|
|
239
|
+
verifier: redisSha256VerifierSchema,
|
|
240
|
+
ttlSeconds: ttlSecondsSchema
|
|
241
|
+
});
|
|
242
|
+
/**
|
|
152
243
|
* Client → API: mint an SSH lease. The client generates an ephemeral keypair
|
|
153
244
|
* locally and sends only the public key; the signed certificate comes back in
|
|
154
245
|
* the response. The private key never leaves the requesting machine.
|
|
@@ -160,12 +251,57 @@ const mintSshLeaseSchema = z.object({
|
|
|
160
251
|
principals: z.array(sshPrincipalSchema).min(1).max(32),
|
|
161
252
|
ttlSeconds: ttlSecondsSchema
|
|
162
253
|
});
|
|
254
|
+
/**
|
|
255
|
+
* Client → API: mint an AWS lease. The client generates an ephemeral P-256
|
|
256
|
+
* keypair locally and sends only the public key; STS mints the credential and
|
|
257
|
+
* the broker returns it wrapped to that key. The private key never leaves the
|
|
258
|
+
* requesting machine, so the plaintext credential is only decryptable there.
|
|
259
|
+
*
|
|
260
|
+
* TTL bounds are STS's own `DurationSeconds` limits (15 min – 12 h), not the
|
|
261
|
+
* generic lease bounds — STS rejects anything below 900 seconds.
|
|
262
|
+
*/
|
|
263
|
+
const mintAwsLeaseSchema = z.object({
|
|
264
|
+
provider: z.literal("aws"),
|
|
265
|
+
targetId: z.string().min(1),
|
|
266
|
+
recipientPublicKey: p256PublicKeyJwkSchema,
|
|
267
|
+
ttlSeconds: z.number().int().min(900).max(AWS_MAX_TTL_SECONDS)
|
|
268
|
+
});
|
|
163
269
|
z.discriminatedUnion("provider", [
|
|
164
270
|
mintPostgresLeaseSchema,
|
|
165
271
|
mintMysqlLeaseSchema,
|
|
166
|
-
|
|
272
|
+
mintRedisLeaseSchema,
|
|
273
|
+
mintSshLeaseSchema,
|
|
274
|
+
mintAwsLeaseSchema
|
|
167
275
|
]);
|
|
168
276
|
//#endregion
|
|
277
|
+
//#region ../../packages/core/src/providers/aws.ts
|
|
278
|
+
/**
|
|
279
|
+
* The one-time IAM setup an admin performs so seekrit can assume the target
|
|
280
|
+
* role. Analogue of `sshHostSetupInstructions` / `postgresGroupBootstrapSql` —
|
|
281
|
+
* printed for the admin to apply, never executed by seekrit. Shows the trust
|
|
282
|
+
* policy the role needs so the broker's base IAM principal may assume it.
|
|
283
|
+
*/
|
|
284
|
+
function awsTrustPolicyInstructions(config) {
|
|
285
|
+
return [
|
|
286
|
+
"# Attach a trust policy to the target role so the IAM principal whose",
|
|
287
|
+
"# access key you registered as the admin secret may assume it. Replace",
|
|
288
|
+
"# <ADMIN_PRINCIPAL_ARN> with that principal (user or role) ARN.",
|
|
289
|
+
"{",
|
|
290
|
+
" \"Version\": \"2012-10-17\",",
|
|
291
|
+
" \"Statement\": [",
|
|
292
|
+
" {",
|
|
293
|
+
" \"Effect\": \"Allow\",",
|
|
294
|
+
" \"Principal\": { \"AWS\": \"<ADMIN_PRINCIPAL_ARN>\" },",
|
|
295
|
+
` "Action": "sts:AssumeRole"${config.externalId ? `,\n "Condition": { "StringEquals": { "sts:ExternalId": "${config.externalId}" } }` : ""}`,
|
|
296
|
+
" }",
|
|
297
|
+
" ]",
|
|
298
|
+
"}",
|
|
299
|
+
"",
|
|
300
|
+
"# The admin principal also needs an identity policy allowing sts:AssumeRole",
|
|
301
|
+
`# on ${config.roleArn}. seekrit only ever calls AssumeRole on this role.`
|
|
302
|
+
].join("\n");
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
169
305
|
//#region ../../packages/core/src/providers/postgres.ts
|
|
170
306
|
/**
|
|
171
307
|
* The one-time setup SQL an admin runs to create the shared group role that a
|
|
@@ -457,6 +593,103 @@ async function importPrivateKeyPkcs8(pkcs8) {
|
|
|
457
593
|
}, true, ["deriveBits"]);
|
|
458
594
|
}
|
|
459
595
|
//#endregion
|
|
596
|
+
//#region ../../packages/crypto/src/wrap.ts
|
|
597
|
+
/**
|
|
598
|
+
* ECIES-style key wrapping: an ephemeral P-256 keypair performs ECDH against
|
|
599
|
+
* the recipient's public key; the shared secret is run through HKDF-SHA256 to
|
|
600
|
+
* derive a one-time AES-256-GCM wrapping key. Only the holder of the
|
|
601
|
+
* recipient private key can unwrap.
|
|
602
|
+
*
|
|
603
|
+
* Blob format: `wd1.<ephemeral pub (raw)>.<hkdf salt>.<iv>.<ciphertext>`
|
|
604
|
+
*/
|
|
605
|
+
const WRAP_PREFIX = "wd1";
|
|
606
|
+
const HKDF_INFO = "seekrit/wrap-dek/v1";
|
|
607
|
+
async function deriveWrappingKey(ownPrivateKey, peerPublicKey, salt, usage) {
|
|
608
|
+
const ecdh = {
|
|
609
|
+
name: "ECDH",
|
|
610
|
+
public: peerPublicKey
|
|
611
|
+
};
|
|
612
|
+
const sharedBits = await crypto.subtle.deriveBits(ecdh, ownPrivateKey, 256);
|
|
613
|
+
const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
|
|
614
|
+
return crypto.subtle.deriveKey({
|
|
615
|
+
name: "HKDF",
|
|
616
|
+
hash: "SHA-256",
|
|
617
|
+
salt,
|
|
618
|
+
info: utf8Encode(HKDF_INFO)
|
|
619
|
+
}, hkdfKey, {
|
|
620
|
+
name: "AES-GCM",
|
|
621
|
+
length: 256
|
|
622
|
+
}, false, [usage]);
|
|
623
|
+
}
|
|
624
|
+
/** Wrap an environment DEK to a principal's public key. */
|
|
625
|
+
async function wrapDek(dek, recipientPublicKeyJwk) {
|
|
626
|
+
const recipientKey = await importPublicKey(recipientPublicKeyJwk);
|
|
627
|
+
const ephemeral = await crypto.subtle.generateKey({
|
|
628
|
+
name: "ECDH",
|
|
629
|
+
namedCurve: "P-256"
|
|
630
|
+
}, true, ["deriveBits"]);
|
|
631
|
+
const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
632
|
+
const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, recipientKey, salt, "encrypt");
|
|
633
|
+
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
634
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
635
|
+
name: "AES-GCM",
|
|
636
|
+
iv
|
|
637
|
+
}, wrappingKey, dek);
|
|
638
|
+
const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
|
639
|
+
return [
|
|
640
|
+
WRAP_PREFIX,
|
|
641
|
+
toBase64Url(ephemeralRaw),
|
|
642
|
+
toBase64Url(salt),
|
|
643
|
+
toBase64Url(iv),
|
|
644
|
+
toBase64Url(new Uint8Array(ciphertext))
|
|
645
|
+
].join(".");
|
|
646
|
+
}
|
|
647
|
+
/** Unwrap an environment DEK with the principal's private ECDH key. */
|
|
648
|
+
async function unwrapDek(wrapped, privateKey) {
|
|
649
|
+
const [ephB64, saltB64, ivB64, ctB64] = splitBlob(wrapped, WRAP_PREFIX, 4);
|
|
650
|
+
const wrappingKey = await deriveWrappingKey(privateKey, await crypto.subtle.importKey("raw", fromBase64Url(ephB64), {
|
|
651
|
+
name: "ECDH",
|
|
652
|
+
namedCurve: "P-256"
|
|
653
|
+
}, false, []), fromBase64Url(saltB64), "decrypt");
|
|
654
|
+
try {
|
|
655
|
+
const dek = await crypto.subtle.decrypt({
|
|
656
|
+
name: "AES-GCM",
|
|
657
|
+
iv: fromBase64Url(ivB64)
|
|
658
|
+
}, wrappingKey, fromBase64Url(ctB64));
|
|
659
|
+
return new Uint8Array(dek);
|
|
660
|
+
} catch {
|
|
661
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "DEK unwrap failed: wrong private key or tampered grant");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
//#endregion
|
|
665
|
+
//#region ../../packages/crypto/src/aws.ts
|
|
666
|
+
/** Generate the ephemeral P-256 keypair a client uses to receive one AWS lease. */
|
|
667
|
+
async function generateAwsRecipientKeyPair() {
|
|
668
|
+
return generateKeyPair();
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Unwrap the `wd1.` blob returned by an AWS lease mint using the ephemeral
|
|
672
|
+
* private key generated for that lease, yielding the plaintext STS credential.
|
|
673
|
+
*/
|
|
674
|
+
async function unwrapAwsCredential(wrapped, privateKeyJwk) {
|
|
675
|
+
const bytes = await unwrapDek(wrapped, await importPrivateKey(privateKeyJwk));
|
|
676
|
+
let parsed;
|
|
677
|
+
try {
|
|
678
|
+
parsed = JSON.parse(utf8Decode(bytes));
|
|
679
|
+
} catch {
|
|
680
|
+
throw new SeekritCryptoError("DECRYPT_FAILED", "unwrapped AWS credential was not valid JSON");
|
|
681
|
+
}
|
|
682
|
+
const c = parsed;
|
|
683
|
+
if (typeof c.accessKeyId !== "string" || typeof c.secretAccessKey !== "string" || typeof c.sessionToken !== "string" || typeof c.expiration !== "string" || typeof c.region !== "string") throw new SeekritCryptoError("DECRYPT_FAILED", "unwrapped AWS credential is missing fields");
|
|
684
|
+
return {
|
|
685
|
+
accessKeyId: c.accessKeyId,
|
|
686
|
+
secretAccessKey: c.secretAccessKey,
|
|
687
|
+
sessionToken: c.sessionToken,
|
|
688
|
+
expiration: c.expiration,
|
|
689
|
+
region: c.region
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
//#endregion
|
|
460
693
|
//#region ../../packages/crypto/src/mysql.ts
|
|
461
694
|
/**
|
|
462
695
|
* Client-side construction of a MySQL/MariaDB `mysql_native_password`
|
|
@@ -485,8 +718,8 @@ async function importPrivateKeyPkcs8(pkcs8) {
|
|
|
485
718
|
* browser, the CLI, the MCP server, and Workers — so this runs everywhere the
|
|
486
719
|
* SCRAM helper does, with no hand-rolled hash primitive.
|
|
487
720
|
*/
|
|
488
|
-
const DEFAULT_PASSWORD_LENGTH$
|
|
489
|
-
const PASSWORD_ALPHABET$
|
|
721
|
+
const DEFAULT_PASSWORD_LENGTH$2 = 32;
|
|
722
|
+
const PASSWORD_ALPHABET$2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
490
723
|
async function sha1(data) {
|
|
491
724
|
return new Uint8Array(await crypto.subtle.digest("SHA-1", data));
|
|
492
725
|
}
|
|
@@ -495,12 +728,12 @@ function toUpperHex(bytes) {
|
|
|
495
728
|
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
496
729
|
return hex.toUpperCase();
|
|
497
730
|
}
|
|
498
|
-
function randomPassword$
|
|
731
|
+
function randomPassword$2(length) {
|
|
499
732
|
let out = "";
|
|
500
733
|
while (out.length < length) {
|
|
501
734
|
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
502
735
|
for (const byte of bytes) {
|
|
503
|
-
if (byte < 248) out += PASSWORD_ALPHABET$
|
|
736
|
+
if (byte < 248) out += PASSWORD_ALPHABET$2[byte % 62];
|
|
504
737
|
if (out.length === length) break;
|
|
505
738
|
}
|
|
506
739
|
}
|
|
@@ -519,7 +752,7 @@ async function mysqlNativePasswordVerifier(password) {
|
|
|
519
752
|
* — the client-side half of a Vault-style dynamic MySQL credential.
|
|
520
753
|
*/
|
|
521
754
|
async function generateMysqlCredential(options = {}) {
|
|
522
|
-
const password = randomPassword$
|
|
755
|
+
const password = randomPassword$2(options.length ?? DEFAULT_PASSWORD_LENGTH$2);
|
|
523
756
|
return {
|
|
524
757
|
password,
|
|
525
758
|
verifier: await mysqlNativePasswordVerifier(password)
|
|
@@ -585,6 +818,73 @@ async function decryptPrivateKey(passphrase, blob) {
|
|
|
585
818
|
throw new SeekritCryptoError("DECRYPT_FAILED", "wrong passphrase or corrupted key blob");
|
|
586
819
|
}
|
|
587
820
|
}
|
|
821
|
+
//#endregion
|
|
822
|
+
//#region ../../packages/crypto/src/redis.ts
|
|
823
|
+
/**
|
|
824
|
+
* Client-side construction of a Redis (6+) ACL password verifier, for minting
|
|
825
|
+
* *temporary Redis login credentials* without the password plaintext ever
|
|
826
|
+
* reaching seekrit's control plane OR Redis itself.
|
|
827
|
+
*
|
|
828
|
+
* The trick mirrors the Postgres SCRAM (scram.ts) and MySQL (mysql.ts) ones:
|
|
829
|
+
* `ACL SETUSER <name> on #<hex>` stores the lowercase-hex SHA-256 of the
|
|
830
|
+
* password verbatim — Redis does NOT re-hash it. So the flow is:
|
|
831
|
+
*
|
|
832
|
+
* 1. the machine that will connect generates a random password locally,
|
|
833
|
+
* 2. computes this digest locally,
|
|
834
|
+
* 3. sends only the digest to the broker → `ACL SETUSER … on #<digest>`,
|
|
835
|
+
* 4. connects directly to Redis with the plaintext it never shared.
|
|
836
|
+
*
|
|
837
|
+
* Zero-knowledge at both layers: the control plane relays only the digest, and
|
|
838
|
+
* the digest is NOT sufficient to authenticate. Redis `AUTH <user> <password>`
|
|
839
|
+
* hashes the *plaintext* it receives with SHA-256 and compares it to the stored
|
|
840
|
+
* digest — verifying a client needs the password, not the digest, and SHA-256
|
|
841
|
+
* is preimage-resistant for a high-entropy machine-generated password. A dump of
|
|
842
|
+
* the ACL rules (`ACL GETUSER`, `CONFIG REWRITE`'d aclfile) therefore cannot log
|
|
843
|
+
* in.
|
|
844
|
+
*
|
|
845
|
+
* SHA-256 is available via WebCrypto (`crypto.subtle.digest`) in the browser,
|
|
846
|
+
* the CLI, the MCP server, and Workers — so this runs everywhere the SCRAM
|
|
847
|
+
* helper does, with no hand-rolled hash primitive.
|
|
848
|
+
*/
|
|
849
|
+
const DEFAULT_PASSWORD_LENGTH$1 = 32;
|
|
850
|
+
const PASSWORD_ALPHABET$1 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
851
|
+
async function sha256$1(data) {
|
|
852
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", data));
|
|
853
|
+
}
|
|
854
|
+
function toLowerHex(bytes) {
|
|
855
|
+
let hex = "";
|
|
856
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
857
|
+
return hex;
|
|
858
|
+
}
|
|
859
|
+
function randomPassword$1(length) {
|
|
860
|
+
let out = "";
|
|
861
|
+
while (out.length < length) {
|
|
862
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length - out.length));
|
|
863
|
+
for (const byte of bytes) {
|
|
864
|
+
if (byte < 248) out += PASSWORD_ALPHABET$1[byte % 62];
|
|
865
|
+
if (out.length === length) break;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return out;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Compute the Redis ACL password verifier `LOWER(HEX(SHA256(password)))` for a
|
|
872
|
+
* known password. Pass the result straight to `ACL SETUSER … on #<verifier>`.
|
|
873
|
+
*/
|
|
874
|
+
async function redisSha256Verifier(password) {
|
|
875
|
+
return toLowerHex(await sha256$1(utf8Encode(password)));
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Mint a fresh random password and its SHA-256 hex digest in one step — the
|
|
879
|
+
* client-side half of a Vault-style dynamic Redis credential.
|
|
880
|
+
*/
|
|
881
|
+
async function generateRedisCredential(options = {}) {
|
|
882
|
+
const password = randomPassword$1(options.length ?? DEFAULT_PASSWORD_LENGTH$1);
|
|
883
|
+
return {
|
|
884
|
+
password,
|
|
885
|
+
verifier: await redisSha256Verifier(password)
|
|
886
|
+
};
|
|
887
|
+
}
|
|
588
888
|
const SALT_LENGTH = 16;
|
|
589
889
|
const DEFAULT_PASSWORD_LENGTH = 32;
|
|
590
890
|
const PASSWORD_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
@@ -833,111 +1133,8 @@ function isServiceToken(value) {
|
|
|
833
1133
|
return value.startsWith(`${TOKEN_PREFIX}_`);
|
|
834
1134
|
}
|
|
835
1135
|
//#endregion
|
|
836
|
-
//#region ../../packages/crypto/src/wrap.ts
|
|
837
|
-
/**
|
|
838
|
-
* ECIES-style key wrapping: an ephemeral P-256 keypair performs ECDH against
|
|
839
|
-
* the recipient's public key; the shared secret is run through HKDF-SHA256 to
|
|
840
|
-
* derive a one-time AES-256-GCM wrapping key. Only the holder of the
|
|
841
|
-
* recipient private key can unwrap.
|
|
842
|
-
*
|
|
843
|
-
* Blob format: `wd1.<ephemeral pub (raw)>.<hkdf salt>.<iv>.<ciphertext>`
|
|
844
|
-
*/
|
|
845
|
-
const WRAP_PREFIX = "wd1";
|
|
846
|
-
const HKDF_INFO = "seekrit/wrap-dek/v1";
|
|
847
|
-
async function deriveWrappingKey(ownPrivateKey, peerPublicKey, salt, usage) {
|
|
848
|
-
const ecdh = {
|
|
849
|
-
name: "ECDH",
|
|
850
|
-
public: peerPublicKey
|
|
851
|
-
};
|
|
852
|
-
const sharedBits = await crypto.subtle.deriveBits(ecdh, ownPrivateKey, 256);
|
|
853
|
-
const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
|
|
854
|
-
return crypto.subtle.deriveKey({
|
|
855
|
-
name: "HKDF",
|
|
856
|
-
hash: "SHA-256",
|
|
857
|
-
salt,
|
|
858
|
-
info: utf8Encode(HKDF_INFO)
|
|
859
|
-
}, hkdfKey, {
|
|
860
|
-
name: "AES-GCM",
|
|
861
|
-
length: 256
|
|
862
|
-
}, false, [usage]);
|
|
863
|
-
}
|
|
864
|
-
/** Wrap an environment DEK to a principal's public key. */
|
|
865
|
-
async function wrapDek(dek, recipientPublicKeyJwk) {
|
|
866
|
-
const recipientKey = await importPublicKey(recipientPublicKeyJwk);
|
|
867
|
-
const ephemeral = await crypto.subtle.generateKey({
|
|
868
|
-
name: "ECDH",
|
|
869
|
-
namedCurve: "P-256"
|
|
870
|
-
}, true, ["deriveBits"]);
|
|
871
|
-
const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
872
|
-
const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, recipientKey, salt, "encrypt");
|
|
873
|
-
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
874
|
-
const ciphertext = await crypto.subtle.encrypt({
|
|
875
|
-
name: "AES-GCM",
|
|
876
|
-
iv
|
|
877
|
-
}, wrappingKey, dek);
|
|
878
|
-
const ephemeralRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
|
879
|
-
return [
|
|
880
|
-
WRAP_PREFIX,
|
|
881
|
-
toBase64Url(ephemeralRaw),
|
|
882
|
-
toBase64Url(salt),
|
|
883
|
-
toBase64Url(iv),
|
|
884
|
-
toBase64Url(new Uint8Array(ciphertext))
|
|
885
|
-
].join(".");
|
|
886
|
-
}
|
|
887
|
-
/** Unwrap an environment DEK with the principal's private ECDH key. */
|
|
888
|
-
async function unwrapDek(wrapped, privateKey) {
|
|
889
|
-
const [ephB64, saltB64, ivB64, ctB64] = splitBlob(wrapped, WRAP_PREFIX, 4);
|
|
890
|
-
const wrappingKey = await deriveWrappingKey(privateKey, await crypto.subtle.importKey("raw", fromBase64Url(ephB64), {
|
|
891
|
-
name: "ECDH",
|
|
892
|
-
namedCurve: "P-256"
|
|
893
|
-
}, false, []), fromBase64Url(saltB64), "decrypt");
|
|
894
|
-
try {
|
|
895
|
-
const dek = await crypto.subtle.decrypt({
|
|
896
|
-
name: "AES-GCM",
|
|
897
|
-
iv: fromBase64Url(ivB64)
|
|
898
|
-
}, wrappingKey, fromBase64Url(ctB64));
|
|
899
|
-
return new Uint8Array(dek);
|
|
900
|
-
} catch {
|
|
901
|
-
throw new SeekritCryptoError("DECRYPT_FAILED", "DEK unwrap failed: wrong private key or tampered grant");
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
//#endregion
|
|
905
1136
|
//#region package.json
|
|
906
|
-
var version = "0.
|
|
907
|
-
const PROJECT_FILE = "seekrit.json";
|
|
908
|
-
function globalConfigPath() {
|
|
909
|
-
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
910
|
-
}
|
|
911
|
-
function readGlobalConfig() {
|
|
912
|
-
const path = globalConfigPath();
|
|
913
|
-
if (!existsSync(path)) return {};
|
|
914
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
915
|
-
}
|
|
916
|
-
function writeGlobalConfig(update) {
|
|
917
|
-
const path = globalConfigPath();
|
|
918
|
-
const merged = {
|
|
919
|
-
...readGlobalConfig(),
|
|
920
|
-
...update
|
|
921
|
-
};
|
|
922
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
923
|
-
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
|
|
924
|
-
}
|
|
925
|
-
/** Walk up from cwd looking for seekrit.json. */
|
|
926
|
-
function findProjectConfig(startDir = process.cwd()) {
|
|
927
|
-
let dir = startDir;
|
|
928
|
-
const { root } = parse(dir);
|
|
929
|
-
while (true) {
|
|
930
|
-
const candidate = join(dir, PROJECT_FILE);
|
|
931
|
-
if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8"));
|
|
932
|
-
if (dir === root) return null;
|
|
933
|
-
dir = dirname(dir);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
function writeProjectConfig(config, dir = process.cwd()) {
|
|
937
|
-
const path = join(dir, PROJECT_FILE);
|
|
938
|
-
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
939
|
-
return path;
|
|
940
|
-
}
|
|
1137
|
+
var version = "0.14.0";
|
|
941
1138
|
//#endregion
|
|
942
1139
|
//#region ../../packages/api-client/src/index.ts
|
|
943
1140
|
var SeekritApiError = class extends Error {
|
|
@@ -1145,6 +1342,40 @@ var SeekritClient = class {
|
|
|
1145
1342
|
return this.request("POST", `/v1/orgs/${orgId}/log-sink/test`);
|
|
1146
1343
|
}
|
|
1147
1344
|
};
|
|
1345
|
+
const PROJECT_FILE = "seekrit.json";
|
|
1346
|
+
function globalConfigPath() {
|
|
1347
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
1348
|
+
}
|
|
1349
|
+
function readGlobalConfig() {
|
|
1350
|
+
const path = globalConfigPath();
|
|
1351
|
+
if (!existsSync(path)) return {};
|
|
1352
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1353
|
+
}
|
|
1354
|
+
function writeGlobalConfig(update) {
|
|
1355
|
+
const path = globalConfigPath();
|
|
1356
|
+
const merged = {
|
|
1357
|
+
...readGlobalConfig(),
|
|
1358
|
+
...update
|
|
1359
|
+
};
|
|
1360
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1361
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
|
|
1362
|
+
}
|
|
1363
|
+
/** Walk up from cwd looking for seekrit.json. */
|
|
1364
|
+
function findProjectConfig(startDir = process.cwd()) {
|
|
1365
|
+
let dir = startDir;
|
|
1366
|
+
const { root } = parse(dir);
|
|
1367
|
+
while (true) {
|
|
1368
|
+
const candidate = join(dir, PROJECT_FILE);
|
|
1369
|
+
if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8"));
|
|
1370
|
+
if (dir === root) return null;
|
|
1371
|
+
dir = dirname(dir);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
function writeProjectConfig(config, dir = process.cwd()) {
|
|
1375
|
+
const path = join(dir, PROJECT_FILE);
|
|
1376
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
1377
|
+
return path;
|
|
1378
|
+
}
|
|
1148
1379
|
//#endregion
|
|
1149
1380
|
//#region src/io.ts
|
|
1150
1381
|
let failThrows = false;
|
|
@@ -1250,6 +1481,235 @@ async function getDek(ctx, orgId, envId) {
|
|
|
1250
1481
|
return unwrapDek(wrappedDek, privateKey);
|
|
1251
1482
|
}
|
|
1252
1483
|
//#endregion
|
|
1484
|
+
//#region src/target.ts
|
|
1485
|
+
/** Resolve the target org from a flag, the committed config, or a lone org. */
|
|
1486
|
+
async function resolveOrg(ctx, orgSlug) {
|
|
1487
|
+
const wanted = orgSlug ?? findProjectConfig()?.org;
|
|
1488
|
+
const { orgs } = await ctx.client.listOrgs();
|
|
1489
|
+
if (wanted) {
|
|
1490
|
+
const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
|
|
1491
|
+
if (!org) fail(`no accessible org "${wanted}"`);
|
|
1492
|
+
return {
|
|
1493
|
+
id: org.id,
|
|
1494
|
+
slug: org.slug
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
const only = orgs[0];
|
|
1498
|
+
if (orgs.length === 1 && only) return {
|
|
1499
|
+
id: only.id,
|
|
1500
|
+
slug: only.slug
|
|
1501
|
+
};
|
|
1502
|
+
fail("specify --org (or run `seekrit init`)");
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
1506
|
+
* or the config's app + `--env`) or a group env (`--group --env`).
|
|
1507
|
+
*/
|
|
1508
|
+
async function resolveEnvTarget(ctx, opts) {
|
|
1509
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
1510
|
+
if (!opts.env) fail("specify --env");
|
|
1511
|
+
if (opts.group) {
|
|
1512
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
1513
|
+
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1514
|
+
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1515
|
+
const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
|
|
1516
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1517
|
+
if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
|
|
1518
|
+
return {
|
|
1519
|
+
orgId: org.id,
|
|
1520
|
+
envId: env.id,
|
|
1521
|
+
label: `${group.slug}@${env.slug}`
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1525
|
+
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
1526
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
1527
|
+
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1528
|
+
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1529
|
+
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1530
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1531
|
+
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1532
|
+
return {
|
|
1533
|
+
orgId: org.id,
|
|
1534
|
+
envId: env.id,
|
|
1535
|
+
label: `${app.slug}/${env.slug}`
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
1539
|
+
async function resolveAppEnv(ctx, opts) {
|
|
1540
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
1541
|
+
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1542
|
+
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
1543
|
+
if (!opts.env) fail("specify --env");
|
|
1544
|
+
const { apps } = await ctx.client.listApps(org.id);
|
|
1545
|
+
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1546
|
+
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1547
|
+
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1548
|
+
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1549
|
+
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1550
|
+
return {
|
|
1551
|
+
orgId: org.id,
|
|
1552
|
+
appId: app.id,
|
|
1553
|
+
appSlug: app.slug,
|
|
1554
|
+
envId: env.id,
|
|
1555
|
+
envSlug: env.slug
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
/** Resolve a group by slug within the target org. */
|
|
1559
|
+
async function resolveGroup(ctx, opts) {
|
|
1560
|
+
const org = await resolveOrg(ctx, opts.org);
|
|
1561
|
+
if (!opts.group) fail("specify --group");
|
|
1562
|
+
const { groups } = await ctx.client.listGroups(org.id);
|
|
1563
|
+
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1564
|
+
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1565
|
+
return {
|
|
1566
|
+
orgId: org.id,
|
|
1567
|
+
id: group.id,
|
|
1568
|
+
slug: group.slug
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
//#endregion
|
|
1572
|
+
//#region src/aws.ts
|
|
1573
|
+
/**
|
|
1574
|
+
* `seekrit aws` — temporary AWS credentials via STS AssumeRole (Vault-style
|
|
1575
|
+
* dynamic secrets, the tier-2 sibling of `seekrit pg`).
|
|
1576
|
+
*
|
|
1577
|
+
* Zero-knowledge for the leased credential: minting generates an ephemeral P-256
|
|
1578
|
+
* keypair on THIS machine and sends only the public key; STS mints the
|
|
1579
|
+
* credential and the broker returns it wrapped to that key, so the control plane
|
|
1580
|
+
* only ever relays ciphertext and only this machine can unwrap it. Registering a
|
|
1581
|
+
* target wraps the base IAM credential to the broker's public key locally, so
|
|
1582
|
+
* the control plane never sees it either — its only needed permission is
|
|
1583
|
+
* `sts:AssumeRole` on the target role.
|
|
1584
|
+
*/
|
|
1585
|
+
/** Parse a duration like `30m`, `1h`, or a bare seconds count. */
|
|
1586
|
+
function parseTtlSeconds$4(input) {
|
|
1587
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1588
|
+
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 12h)`);
|
|
1589
|
+
return Number(m[1]) * ({
|
|
1590
|
+
s: 1,
|
|
1591
|
+
m: 60,
|
|
1592
|
+
h: 3600,
|
|
1593
|
+
d: 86400
|
|
1594
|
+
}[m[2] || "s"] ?? 1);
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* The base IAM credential the broker assumes the role with. From flags or the
|
|
1598
|
+
* standard AWS env vars; JSON-serialized so the executor can parse it. Never the
|
|
1599
|
+
* plaintext leaves this machine unwrapped — it is wrapped to the broker key.
|
|
1600
|
+
*/
|
|
1601
|
+
function resolveBaseCredential(opts) {
|
|
1602
|
+
const accessKeyId = opts.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID;
|
|
1603
|
+
const secretAccessKey = opts.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY;
|
|
1604
|
+
const sessionToken = process.env.AWS_SESSION_TOKEN;
|
|
1605
|
+
if (!accessKeyId || !secretAccessKey) fail("provide the base IAM credential via --access-key-id/--secret-access-key or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (it needs only sts:AssumeRole on the role)");
|
|
1606
|
+
return JSON.stringify({
|
|
1607
|
+
accessKeyId,
|
|
1608
|
+
secretAccessKey,
|
|
1609
|
+
...sessionToken ? { sessionToken } : {}
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
function registerAwsCommands(program) {
|
|
1613
|
+
const aws = program.command("aws").description("temporary AWS credentials (STS AssumeRole, zero-knowledge)");
|
|
1614
|
+
const target = aws.command("target").description("manage AWS role targets");
|
|
1615
|
+
target.command("add").description("register an assumable IAM role to issue temporary credentials from").requiredOption("--name <name>", "display name, e.g. prod-deploy").requiredOption("--role-arn <arn>", "the IAM role to assume, arn:aws:iam::<acct>:role/<name>").requiredOption("--region <region>", "region whose STS endpoint to call, e.g. us-east-1").option("--org <slug>").option("--external-id <id>", "STS ExternalId the role's trust policy requires").option("--session-policy <file>", "path to an inline session policy JSON (further restricts)").option("--max-ttl <duration>", "clamp requested credential lifetime, e.g. 8h").option("--access-key-id <id>", "base IAM access key id (else AWS_ACCESS_KEY_ID)").option("--secret-access-key <secret>", "base IAM secret (else AWS_SECRET_ACCESS_KEY)").action(async (options) => {
|
|
1616
|
+
const ctx = buildContext();
|
|
1617
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1618
|
+
const sessionPolicy = options.sessionPolicy ? readFileSync(options.sessionPolicy, "utf8").trim() : void 0;
|
|
1619
|
+
const config = {
|
|
1620
|
+
provider: "aws",
|
|
1621
|
+
executor: "in_do",
|
|
1622
|
+
roleArn: options.roleArn,
|
|
1623
|
+
region: options.region,
|
|
1624
|
+
...options.externalId ? { externalId: options.externalId } : {},
|
|
1625
|
+
...sessionPolicy ? { sessionPolicy } : {},
|
|
1626
|
+
...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds$4(options.maxTtl) } : {}
|
|
1627
|
+
};
|
|
1628
|
+
const baseCredential = resolveBaseCredential(options);
|
|
1629
|
+
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
1630
|
+
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(baseCredential), publicKeyJwk);
|
|
1631
|
+
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
1632
|
+
name: options.name,
|
|
1633
|
+
config,
|
|
1634
|
+
wrappedAdminSecret
|
|
1635
|
+
});
|
|
1636
|
+
console.error(`registered AWS target ${created.name} (${created.id})`);
|
|
1637
|
+
console.error("\nEnsure the role trusts the admin principal, then `seekrit aws lease`:\n");
|
|
1638
|
+
console.log(awsTrustPolicyInstructions(config));
|
|
1639
|
+
});
|
|
1640
|
+
target.command("list").description("list AWS role targets").option("--org <slug>").action(async (options) => {
|
|
1641
|
+
const ctx = buildContext();
|
|
1642
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1643
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1644
|
+
for (const t of targets) {
|
|
1645
|
+
const cfg = t.config;
|
|
1646
|
+
if (cfg.provider !== "aws") continue;
|
|
1647
|
+
console.log(`${t.id}\t${t.name}\t${cfg.region}\t${cfg.roleArn}`);
|
|
1648
|
+
}
|
|
1649
|
+
});
|
|
1650
|
+
target.command("trust <targetId>").description("reprint the IAM trust-policy setup for an AWS target").option("--org <slug>").action(async (targetId, options) => {
|
|
1651
|
+
const ctx = buildContext();
|
|
1652
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1653
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1654
|
+
const t = targets.find((x) => x.id === targetId || x.name === targetId);
|
|
1655
|
+
if (!t) fail(`no target "${targetId}" in ${org.slug}`);
|
|
1656
|
+
const cfg = t.config;
|
|
1657
|
+
if (cfg.provider !== "aws") fail("not an aws target (see `seekrit pg`/`seekrit ssh`)");
|
|
1658
|
+
console.log(awsTrustPolicyInstructions(cfg));
|
|
1659
|
+
});
|
|
1660
|
+
target.command("rm <targetId>").description("delete an AWS role target").option("--org <slug>").action(async (targetId, options) => {
|
|
1661
|
+
const ctx = buildContext();
|
|
1662
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1663
|
+
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
1664
|
+
console.error(`deleted ${targetId}`);
|
|
1665
|
+
});
|
|
1666
|
+
aws.command("lease <target>").description("mint short-lived AWS credentials; prints ready-to-source export lines").option("--org <slug>").option("--ttl <duration>", "credential lifetime, e.g. 15m, 1h, 12h", "1h").option("--json", "print the full credential as JSON").action(async (targetRef, options) => {
|
|
1667
|
+
const ctx = buildContext();
|
|
1668
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1669
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1670
|
+
const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
|
|
1671
|
+
if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1672
|
+
const cfg = t.config;
|
|
1673
|
+
if (cfg.provider !== "aws") fail(`"${t.name}" is not an aws target (see \`seekrit pg\`)`);
|
|
1674
|
+
const ttlSeconds = parseTtlSeconds$4(options.ttl);
|
|
1675
|
+
if (ttlSeconds < 900) fail(`--ttl must be at least ${900 / 60}m (STS minimum)`);
|
|
1676
|
+
const recipient = await generateAwsRecipientKeyPair();
|
|
1677
|
+
const { aws: leased } = await ctx.client.mintLease(org.id, {
|
|
1678
|
+
provider: "aws",
|
|
1679
|
+
targetId: t.id,
|
|
1680
|
+
recipientPublicKey: recipient.publicKeyJwk,
|
|
1681
|
+
ttlSeconds
|
|
1682
|
+
});
|
|
1683
|
+
const cred = await unwrapAwsCredential(leased.wrappedCredential, recipient.privateKeyJwk);
|
|
1684
|
+
console.error(`leased ${cfg.roleArn} in ${cred.region} — expires ${cred.expiration}`);
|
|
1685
|
+
if (options.json) console.log(JSON.stringify({
|
|
1686
|
+
...cred,
|
|
1687
|
+
roleArn: cfg.roleArn
|
|
1688
|
+
}, null, 2));
|
|
1689
|
+
else {
|
|
1690
|
+
console.log(`export AWS_ACCESS_KEY_ID=${cred.accessKeyId}`);
|
|
1691
|
+
console.log(`export AWS_SECRET_ACCESS_KEY=${cred.secretAccessKey}`);
|
|
1692
|
+
console.log(`export AWS_SESSION_TOKEN=${cred.sessionToken}`);
|
|
1693
|
+
console.log(`export AWS_REGION=${cred.region}`);
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
aws.command("leases").description("list AWS leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
|
|
1697
|
+
const ctx = buildContext();
|
|
1698
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1699
|
+
const { leases } = await ctx.client.listLeases(org.id);
|
|
1700
|
+
for (const l of leases) {
|
|
1701
|
+
if (l.provider !== "aws") continue;
|
|
1702
|
+
console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
aws.command("revoke <leaseId>").description("mark a lease revoked in the ledger (STS credentials stay valid until they expire)").option("--org <slug>").action(async (leaseId, options) => {
|
|
1706
|
+
const ctx = buildContext();
|
|
1707
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1708
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
1709
|
+
console.error(`revoked ${leaseId} (issued credentials remain valid until they expire)`);
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
//#endregion
|
|
1253
1713
|
//#region src/dotenv.ts
|
|
1254
1714
|
/**
|
|
1255
1715
|
* Minimal `.env` parser: `KEY=VALUE`, `#` comments, an optional `export`
|
|
@@ -1344,94 +1804,6 @@ function resolveLeaseAdminSecret(opts) {
|
|
|
1344
1804
|
return adminUrl;
|
|
1345
1805
|
}
|
|
1346
1806
|
//#endregion
|
|
1347
|
-
//#region src/target.ts
|
|
1348
|
-
/** Resolve the target org from a flag, the committed config, or a lone org. */
|
|
1349
|
-
async function resolveOrg(ctx, orgSlug) {
|
|
1350
|
-
const wanted = orgSlug ?? findProjectConfig()?.org;
|
|
1351
|
-
const { orgs } = await ctx.client.listOrgs();
|
|
1352
|
-
if (wanted) {
|
|
1353
|
-
const org = orgs.find((o) => o.slug === wanted || o.id === wanted);
|
|
1354
|
-
if (!org) fail(`no accessible org "${wanted}"`);
|
|
1355
|
-
return {
|
|
1356
|
-
id: org.id,
|
|
1357
|
-
slug: org.slug
|
|
1358
|
-
};
|
|
1359
|
-
}
|
|
1360
|
-
const only = orgs[0];
|
|
1361
|
-
if (orgs.length === 1 && only) return {
|
|
1362
|
-
id: only.id,
|
|
1363
|
-
slug: only.slug
|
|
1364
|
-
};
|
|
1365
|
-
fail("specify --org (or run `seekrit init`)");
|
|
1366
|
-
}
|
|
1367
|
-
/**
|
|
1368
|
-
* Resolve an environment to operate on — an application env (`--app --env`,
|
|
1369
|
-
* or the config's app + `--env`) or a group env (`--group --env`).
|
|
1370
|
-
*/
|
|
1371
|
-
async function resolveEnvTarget(ctx, opts) {
|
|
1372
|
-
const org = await resolveOrg(ctx, opts.org);
|
|
1373
|
-
if (!opts.env) fail("specify --env");
|
|
1374
|
-
if (opts.group) {
|
|
1375
|
-
const { groups } = await ctx.client.listGroups(org.id);
|
|
1376
|
-
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1377
|
-
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1378
|
-
const { environments } = await ctx.client.listGroupEnvs(org.id, group.id);
|
|
1379
|
-
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1380
|
-
if (!env) fail(`no environment "${opts.env}" in group ${group.slug}`);
|
|
1381
|
-
return {
|
|
1382
|
-
orgId: org.id,
|
|
1383
|
-
envId: env.id,
|
|
1384
|
-
label: `${group.slug}@${env.slug}`
|
|
1385
|
-
};
|
|
1386
|
-
}
|
|
1387
|
-
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1388
|
-
if (!appSlug) fail("specify --app or --group (or run `seekrit init`)");
|
|
1389
|
-
const { apps } = await ctx.client.listApps(org.id);
|
|
1390
|
-
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1391
|
-
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1392
|
-
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1393
|
-
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1394
|
-
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1395
|
-
return {
|
|
1396
|
-
orgId: org.id,
|
|
1397
|
-
envId: env.id,
|
|
1398
|
-
label: `${app.slug}/${env.slug}`
|
|
1399
|
-
};
|
|
1400
|
-
}
|
|
1401
|
-
/** Resolve an application environment, keeping ids + slugs (for token binding). */
|
|
1402
|
-
async function resolveAppEnv(ctx, opts) {
|
|
1403
|
-
const org = await resolveOrg(ctx, opts.org);
|
|
1404
|
-
const appSlug = opts.app ?? findProjectConfig()?.app;
|
|
1405
|
-
if (!appSlug) fail("specify --app (or run `seekrit init`)");
|
|
1406
|
-
if (!opts.env) fail("specify --env");
|
|
1407
|
-
const { apps } = await ctx.client.listApps(org.id);
|
|
1408
|
-
const app = apps.find((a) => a.slug === appSlug || a.id === appSlug);
|
|
1409
|
-
if (!app) fail(`no app "${appSlug}" in ${org.slug}`);
|
|
1410
|
-
const { environments } = await ctx.client.listEnvs(org.id, app.id);
|
|
1411
|
-
const env = environments.find((e) => e.slug === opts.env || e.id === opts.env);
|
|
1412
|
-
if (!env) fail(`no environment "${opts.env}" in ${app.slug}`);
|
|
1413
|
-
return {
|
|
1414
|
-
orgId: org.id,
|
|
1415
|
-
appId: app.id,
|
|
1416
|
-
appSlug: app.slug,
|
|
1417
|
-
envId: env.id,
|
|
1418
|
-
envSlug: env.slug
|
|
1419
|
-
};
|
|
1420
|
-
}
|
|
1421
|
-
/** Resolve a group by slug within the target org. */
|
|
1422
|
-
async function resolveGroup(ctx, opts) {
|
|
1423
|
-
const org = await resolveOrg(ctx, opts.org);
|
|
1424
|
-
if (!opts.group) fail("specify --group");
|
|
1425
|
-
const { groups } = await ctx.client.listGroups(org.id);
|
|
1426
|
-
const group = groups.find((g) => g.slug === opts.group || g.id === opts.group);
|
|
1427
|
-
if (!group) fail(`no group "${opts.group}" in ${org.slug}`);
|
|
1428
|
-
return {
|
|
1429
|
-
orgId: org.id,
|
|
1430
|
-
id: group.id,
|
|
1431
|
-
slug: group.slug
|
|
1432
|
-
};
|
|
1433
|
-
}
|
|
1434
|
-
//#endregion
|
|
1435
1807
|
//#region src/mysql.ts
|
|
1436
1808
|
/**
|
|
1437
1809
|
* `seekrit mysql` — temporary MySQL/MariaDB credentials (Vault-style dynamic
|
|
@@ -1444,7 +1816,7 @@ async function resolveGroup(ctx, opts) {
|
|
|
1444
1816
|
* control plane only ever stores ciphertext.
|
|
1445
1817
|
*/
|
|
1446
1818
|
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
1447
|
-
function parseTtlSeconds$
|
|
1819
|
+
function parseTtlSeconds$3(input) {
|
|
1448
1820
|
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1449
1821
|
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
1450
1822
|
return Number(m[1]) * ({
|
|
@@ -1455,7 +1827,7 @@ function parseTtlSeconds$2(input) {
|
|
|
1455
1827
|
}[m[2] || "s"] ?? 1);
|
|
1456
1828
|
}
|
|
1457
1829
|
/** A fresh, valid MySQL user name: `tmp_` + lowercase alphanumerics. */
|
|
1458
|
-
function generateUserName(prefix = "tmp") {
|
|
1830
|
+
function generateUserName$1(prefix = "tmp") {
|
|
1459
1831
|
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
1460
1832
|
let out = "";
|
|
1461
1833
|
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
@@ -1465,7 +1837,7 @@ function generateUserName(prefix = "tmp") {
|
|
|
1465
1837
|
function registerMysqlCommands(program) {
|
|
1466
1838
|
const mysql = program.command("mysql").description("temporary MySQL/MariaDB credentials (short-lived, zero-knowledge)");
|
|
1467
1839
|
const target = mysql.command("target").description("manage provisioning targets");
|
|
1468
|
-
target.command("add").description("register a MySQL/MariaDB server to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "3306").requiredOption("--database <name>", "database to grant access to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--user-host <host>", "host part of created accounts ('name'@'<host>')", "%").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin mysql:// connection string (or set SEEKRIT_MYSQL_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$
|
|
1840
|
+
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) => {
|
|
1469
1841
|
const ctx = buildContext();
|
|
1470
1842
|
const org = await resolveOrg(ctx, options.org);
|
|
1471
1843
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -1530,8 +1902,8 @@ function registerMysqlCommands(program) {
|
|
|
1530
1902
|
const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
|
|
1531
1903
|
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1532
1904
|
if (target.provider !== "mysql") fail(`target "${targetRef}" is not a MySQL target`);
|
|
1533
|
-
const userName = options.user ?? generateUserName();
|
|
1534
|
-
const ttlSeconds = parseTtlSeconds$
|
|
1905
|
+
const userName = options.user ?? generateUserName$1();
|
|
1906
|
+
const ttlSeconds = parseTtlSeconds$3(options.ttl);
|
|
1535
1907
|
const { password, verifier } = await generateMysqlCredential();
|
|
1536
1908
|
const { connection } = await ctx.client.mintLease(org.id, {
|
|
1537
1909
|
provider: "mysql",
|
|
@@ -1566,7 +1938,7 @@ function registerMysqlCommands(program) {
|
|
|
1566
1938
|
});
|
|
1567
1939
|
}
|
|
1568
1940
|
/** Collect a repeatable option into an array. */
|
|
1569
|
-
function collect$
|
|
1941
|
+
function collect$3(value, acc) {
|
|
1570
1942
|
acc.push(value);
|
|
1571
1943
|
return acc;
|
|
1572
1944
|
}
|
|
@@ -1582,7 +1954,7 @@ function collect$2(value, acc) {
|
|
|
1582
1954
|
* ciphertext.
|
|
1583
1955
|
*/
|
|
1584
1956
|
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
1585
|
-
function parseTtlSeconds$
|
|
1957
|
+
function parseTtlSeconds$2(input) {
|
|
1586
1958
|
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1587
1959
|
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
1588
1960
|
return Number(m[1]) * ({
|
|
@@ -1603,7 +1975,7 @@ function generateRoleName(prefix = "tmp") {
|
|
|
1603
1975
|
function registerPgCommands(program) {
|
|
1604
1976
|
const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
|
|
1605
1977
|
const target = pg.command("target").description("manage provisioning targets");
|
|
1606
|
-
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$
|
|
1978
|
+
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) => {
|
|
1607
1979
|
const ctx = buildContext();
|
|
1608
1980
|
const org = await resolveOrg(ctx, options.org);
|
|
1609
1981
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -1685,7 +2057,7 @@ function registerPgCommands(program) {
|
|
|
1685
2057
|
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1686
2058
|
if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
|
|
1687
2059
|
const roleName = options.role ?? generateRoleName();
|
|
1688
|
-
const ttlSeconds = parseTtlSeconds$
|
|
2060
|
+
const ttlSeconds = parseTtlSeconds$2(options.ttl);
|
|
1689
2061
|
const { password, verifier } = await generatePostgresCredential();
|
|
1690
2062
|
const { connection } = await ctx.client.mintLease(org.id, {
|
|
1691
2063
|
provider: "postgres",
|
|
@@ -1717,6 +2089,145 @@ function registerPgCommands(program) {
|
|
|
1717
2089
|
});
|
|
1718
2090
|
}
|
|
1719
2091
|
/** Collect a repeatable option into an array. */
|
|
2092
|
+
function collect$2(value, acc) {
|
|
2093
|
+
acc.push(value);
|
|
2094
|
+
return acc;
|
|
2095
|
+
}
|
|
2096
|
+
//#endregion
|
|
2097
|
+
//#region src/redis.ts
|
|
2098
|
+
/**
|
|
2099
|
+
* `seekrit redis` — temporary Redis (6+) credentials (Vault-style dynamic
|
|
2100
|
+
* secrets).
|
|
2101
|
+
*
|
|
2102
|
+
* Zero-knowledge: minting generates the password and its SHA-256 digest on THIS
|
|
2103
|
+
* machine and sends only the digest; the plaintext password never reaches the
|
|
2104
|
+
* API or gets stored. Registering a target wraps the admin connection string to
|
|
2105
|
+
* the broker's public key locally, so the control plane only ever stores
|
|
2106
|
+
* ciphertext.
|
|
2107
|
+
*/
|
|
2108
|
+
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
2109
|
+
function parseTtlSeconds$1(input) {
|
|
2110
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
2111
|
+
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
2112
|
+
return Number(m[1]) * ({
|
|
2113
|
+
s: 1,
|
|
2114
|
+
m: 60,
|
|
2115
|
+
h: 3600,
|
|
2116
|
+
d: 86400
|
|
2117
|
+
}[m[2] || "s"] ?? 1);
|
|
2118
|
+
}
|
|
2119
|
+
/** A fresh, valid Redis ACL user name: `tmp_` + lowercase alphanumerics. */
|
|
2120
|
+
function generateUserName(prefix = "tmp") {
|
|
2121
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
2122
|
+
let out = "";
|
|
2123
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
2124
|
+
for (const b of bytes) out += alphabet[b % 36];
|
|
2125
|
+
return `${prefix}_${out}`;
|
|
2126
|
+
}
|
|
2127
|
+
function registerRedisCommands(program) {
|
|
2128
|
+
const redis = program.command("redis").description("temporary Redis credentials (short-lived, zero-knowledge)");
|
|
2129
|
+
const target = redis.command("target").description("manage provisioning targets");
|
|
2130
|
+
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) => {
|
|
2131
|
+
const ctx = buildContext();
|
|
2132
|
+
const org = await resolveOrg(ctx, options.org);
|
|
2133
|
+
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
2134
|
+
if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
|
|
2135
|
+
if (![
|
|
2136
|
+
"readonly",
|
|
2137
|
+
"readwrite",
|
|
2138
|
+
"custom"
|
|
2139
|
+
].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
|
|
2140
|
+
const accessLevel = options.access;
|
|
2141
|
+
const adminSecret = resolveLeaseAdminSecret({
|
|
2142
|
+
executor,
|
|
2143
|
+
hmacKey: options.hmacKey,
|
|
2144
|
+
adminUrl: options.adminUrl,
|
|
2145
|
+
adminUrlEnv: "SEEKRIT_REDIS_ADMIN_URL"
|
|
2146
|
+
});
|
|
2147
|
+
const config = {
|
|
2148
|
+
provider: "redis",
|
|
2149
|
+
executor,
|
|
2150
|
+
accessLevel,
|
|
2151
|
+
connection: {
|
|
2152
|
+
host: options.host,
|
|
2153
|
+
port: Number.parseInt(options.port, 10),
|
|
2154
|
+
...options.db !== void 0 ? { db: Number.parseInt(options.db, 10) } : {}
|
|
2155
|
+
},
|
|
2156
|
+
...accessLevel === "custom" ? {
|
|
2157
|
+
...options.createStatement.length ? { createStatements: options.createStatement } : {},
|
|
2158
|
+
...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
|
|
2159
|
+
} : {},
|
|
2160
|
+
...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
|
|
2161
|
+
};
|
|
2162
|
+
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
2163
|
+
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
|
|
2164
|
+
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
2165
|
+
name: options.name,
|
|
2166
|
+
config,
|
|
2167
|
+
wrappedAdminSecret
|
|
2168
|
+
});
|
|
2169
|
+
console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
|
|
2170
|
+
});
|
|
2171
|
+
target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
|
|
2172
|
+
const ctx = buildContext();
|
|
2173
|
+
const org = await resolveOrg(ctx, options.org);
|
|
2174
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
2175
|
+
for (const t of targets) {
|
|
2176
|
+
if (t.provider !== "redis") continue;
|
|
2177
|
+
const cfg = t.config;
|
|
2178
|
+
const db = cfg.connection.db ?? 0;
|
|
2179
|
+
console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${db}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
|
|
2183
|
+
const ctx = buildContext();
|
|
2184
|
+
const org = await resolveOrg(ctx, options.org);
|
|
2185
|
+
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
2186
|
+
console.error(`removed ${targetId}`);
|
|
2187
|
+
});
|
|
2188
|
+
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) => {
|
|
2189
|
+
const ctx = buildContext();
|
|
2190
|
+
const org = await resolveOrg(ctx, options.org);
|
|
2191
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
2192
|
+
const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
|
|
2193
|
+
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
2194
|
+
if (target.provider !== "redis") fail(`target "${targetRef}" is not a Redis target`);
|
|
2195
|
+
const userName = options.user ?? generateUserName();
|
|
2196
|
+
const ttlSeconds = parseTtlSeconds$1(options.ttl);
|
|
2197
|
+
const { password, verifier } = await generateRedisCredential();
|
|
2198
|
+
const { connection } = await ctx.client.mintLease(org.id, {
|
|
2199
|
+
provider: "redis",
|
|
2200
|
+
targetId: target.id,
|
|
2201
|
+
roleName: userName,
|
|
2202
|
+
verifier,
|
|
2203
|
+
ttlSeconds
|
|
2204
|
+
});
|
|
2205
|
+
const url = `redis://${userName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
|
|
2206
|
+
console.error(`leased ${userName} on ${connection.host}:${connection.port} — expires ${connection.expiresAt}`);
|
|
2207
|
+
if (options.json) console.log(JSON.stringify({
|
|
2208
|
+
...connection,
|
|
2209
|
+
password,
|
|
2210
|
+
url
|
|
2211
|
+
}, null, 2));
|
|
2212
|
+
else console.log(url);
|
|
2213
|
+
});
|
|
2214
|
+
redis.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
|
|
2215
|
+
const ctx = buildContext();
|
|
2216
|
+
const org = await resolveOrg(ctx, options.org);
|
|
2217
|
+
const { leases } = await ctx.client.listLeases(org.id);
|
|
2218
|
+
for (const l of leases) {
|
|
2219
|
+
if (l.provider !== "redis") continue;
|
|
2220
|
+
console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
redis.command("revoke <leaseId>").description("revoke a lease now (deletes the ACL user immediately)").option("--org <slug>").action(async (leaseId, options) => {
|
|
2224
|
+
const ctx = buildContext();
|
|
2225
|
+
const org = await resolveOrg(ctx, options.org);
|
|
2226
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
2227
|
+
console.error(`revoked ${leaseId}`);
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
/** Collect a repeatable option into an array. */
|
|
1720
2231
|
function collect$1(value, acc) {
|
|
1721
2232
|
acc.push(value);
|
|
1722
2233
|
return acc;
|
|
@@ -2336,10 +2847,12 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
|
|
|
2336
2847
|
});
|
|
2337
2848
|
registerPgCommands(program);
|
|
2338
2849
|
registerMysqlCommands(program);
|
|
2850
|
+
registerRedisCommands(program);
|
|
2339
2851
|
registerProvisionerCommands(program);
|
|
2340
2852
|
registerSshCommands(program);
|
|
2853
|
+
registerAwsCommands(program);
|
|
2341
2854
|
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|
|
2342
|
-
const { runMcpServer } = await import("./mcp-
|
|
2855
|
+
const { runMcpServer } = await import("./mcp-ARBuneH3.js");
|
|
2343
2856
|
await runMcpServer();
|
|
2344
2857
|
});
|
|
2345
2858
|
program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
|
|
@@ -2353,4 +2866,4 @@ program.parseAsync(argv).catch((err) => {
|
|
|
2353
2866
|
fail(err instanceof Error ? err.message : String(err));
|
|
2354
2867
|
});
|
|
2355
2868
|
//#endregion
|
|
2356
|
-
export {
|
|
2869
|
+
export { generatePostgresCredential as _, resolveEnvTarget as a, generateDek as b, getDek as c, setFailThrows as d, writeProjectConfig as f, parseServiceToken as g, isServiceToken as h, resolveAppEnv as i, isTokenAuth as l, createServiceToken as m, fetchDecryptedSecrets as n, resolveGroup as o, version as p, materializeEnv as r, resolveOrg as s, encryptAndSetSecret as t, tryBuildContext as u, generateMysqlCredential as v, wrapDek as y };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as
|
|
1
|
+
import { _ as generatePostgresCredential, a as resolveEnvTarget, b as generateDek, c as getDek, d as setFailThrows, f as writeProjectConfig, g as parseServiceToken, h as isServiceToken, i as resolveAppEnv, l as isTokenAuth, m as createServiceToken, n as fetchDecryptedSecrets, o as resolveGroup, p as version, r as materializeEnv, s as resolveOrg, t as encryptAndSetSecret, u as tryBuildContext, v as generateMysqlCredential, y as wrapDek } 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";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seekrit/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "End-to-end encrypted secrets manager CLI — inject decrypted secrets into any command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"@types/node": "^26.1.0",
|
|
25
25
|
"tsdown": "^0.22.3",
|
|
26
26
|
"@seekrit/api-client": "0.0.1",
|
|
27
|
-
"@seekrit/
|
|
28
|
-
"@seekrit/
|
|
27
|
+
"@seekrit/core": "0.0.1",
|
|
28
|
+
"@seekrit/crypto": "0.0.1"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsdown",
|