@seekrit/cli 0.7.0 → 0.8.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 +357 -14
- package/dist/{mcp-Dw3hVhbC.js → mcp-DNUBSbcd.js} +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { homedir } from "node:os";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { dirname, join, parse } from "node:path";
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
8
|
import { Writable } from "node:stream";
|
|
@@ -259,6 +259,138 @@ async function generatePostgresCredential(options = {}) {
|
|
|
259
259
|
};
|
|
260
260
|
}
|
|
261
261
|
//#endregion
|
|
262
|
+
//#region ../../packages/crypto/src/ssh.ts
|
|
263
|
+
/**
|
|
264
|
+
* Client-side SSH certificate authority for minting *temporary SSH access*
|
|
265
|
+
* without any private key ever reaching seekrit's control plane.
|
|
266
|
+
*
|
|
267
|
+
* The model (Vault-style, tier-1 verifier injection — see the `ZkTier` doc in
|
|
268
|
+
* @seekrit/core leases.ts):
|
|
269
|
+
*
|
|
270
|
+
* 1. the machine that will connect generates an ephemeral Ed25519 keypair
|
|
271
|
+
* locally ({@link generateSshKeyPair}),
|
|
272
|
+
* 2. it sends only the *public* key to the broker,
|
|
273
|
+
* 3. the broker signs a short-lived OpenSSH user *certificate* over that
|
|
274
|
+
* public key ({@link signSshUserCertificate}) using the CA private key,
|
|
275
|
+
* 4. the consumer connects with `ssh -i <key> -o CertificateFile=<cert>`; the
|
|
276
|
+
* host trusts the CA (`TrustedUserCAKeys`) and never needs the key on disk.
|
|
277
|
+
*
|
|
278
|
+
* Zero-knowledge end to end: the user private key exists only on the consumer,
|
|
279
|
+
* and a certificate is a public artifact (it authorizes but cannot authenticate
|
|
280
|
+
* without the matching private key). The only secret seekrit stores is the CA
|
|
281
|
+
* private key — wrapped to the broker DO's public key, decrypted transiently in
|
|
282
|
+
* the DO to sign (the same in-DO trade the Postgres admin credential makes).
|
|
283
|
+
*
|
|
284
|
+
* Everything here is WebCrypto Ed25519 + hand-rolled SSH wire encoding, so it
|
|
285
|
+
* runs unchanged in the browser, the CLI, and Workers. No Node-specific crypto.
|
|
286
|
+
*/
|
|
287
|
+
const ED25519 = { name: "Ed25519" };
|
|
288
|
+
/**
|
|
289
|
+
* Serializer for the SSH binary wire types. `string` is a uint32 length prefix
|
|
290
|
+
* followed by that many bytes (a length-delimited byte blob, not text); ints
|
|
291
|
+
* are big-endian. This is the encoding used by public keys, certificates, and
|
|
292
|
+
* the OpenSSH private key container alike.
|
|
293
|
+
*/
|
|
294
|
+
var SshWriter = class {
|
|
295
|
+
chunks = [];
|
|
296
|
+
len = 0;
|
|
297
|
+
bytes(b) {
|
|
298
|
+
this.chunks.push(b);
|
|
299
|
+
this.len += b.length;
|
|
300
|
+
return this;
|
|
301
|
+
}
|
|
302
|
+
byte(n) {
|
|
303
|
+
return this.bytes(new Uint8Array([n & 255]));
|
|
304
|
+
}
|
|
305
|
+
uint32(n) {
|
|
306
|
+
const b = /* @__PURE__ */ new Uint8Array(4);
|
|
307
|
+
new DataView(b.buffer).setUint32(0, n >>> 0, false);
|
|
308
|
+
return this.bytes(b);
|
|
309
|
+
}
|
|
310
|
+
uint64(n) {
|
|
311
|
+
const b = /* @__PURE__ */ new Uint8Array(8);
|
|
312
|
+
new DataView(b.buffer).setBigUint64(0, BigInt(n), false);
|
|
313
|
+
return this.bytes(b);
|
|
314
|
+
}
|
|
315
|
+
string(s) {
|
|
316
|
+
const b = typeof s === "string" ? utf8Encode(s) : s;
|
|
317
|
+
return this.uint32(b.length).bytes(b);
|
|
318
|
+
}
|
|
319
|
+
build() {
|
|
320
|
+
const out = new Uint8Array(this.len);
|
|
321
|
+
let o = 0;
|
|
322
|
+
for (const c of this.chunks) {
|
|
323
|
+
out.set(c, o);
|
|
324
|
+
o += c.length;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
function concat(...arrays) {
|
|
330
|
+
const total = arrays.reduce((n, a) => n + a.length, 0);
|
|
331
|
+
const out = new Uint8Array(total);
|
|
332
|
+
let o = 0;
|
|
333
|
+
for (const a of arrays) {
|
|
334
|
+
out.set(a, o);
|
|
335
|
+
o += a.length;
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
/** The `ssh-ed25519` public-key blob: string "ssh-ed25519" ‖ string <32 bytes>. */
|
|
340
|
+
function ed25519PublicKeyBlob(pub) {
|
|
341
|
+
return new SshWriter().string("ssh-ed25519").string(pub).build();
|
|
342
|
+
}
|
|
343
|
+
function encodeSshPublicKey(pub, comment) {
|
|
344
|
+
const b64 = toBase64(ed25519PublicKeyBlob(pub));
|
|
345
|
+
return comment ? `ssh-ed25519 ${b64} ${comment}` : `ssh-ed25519 ${b64}`;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Generate an ephemeral client keypair. The private key is serialized in the
|
|
349
|
+
* `openssh-key-v1` format so `ssh -i` accepts it directly; the public key is
|
|
350
|
+
* what gets certified. Nothing here is ever sent to the control plane except
|
|
351
|
+
* the public key.
|
|
352
|
+
*/
|
|
353
|
+
async function generateSshKeyPair(comment = "seekrit") {
|
|
354
|
+
const pair = await crypto.subtle.generateKey(ED25519, true, ["sign", "verify"]);
|
|
355
|
+
const pub = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
|
356
|
+
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey));
|
|
357
|
+
const seed = pkcs8.subarray(pkcs8.length - 32);
|
|
358
|
+
return {
|
|
359
|
+
publicKeyOpenssh: encodeSshPublicKey(pub, comment),
|
|
360
|
+
privateKeyOpenssh: encodeOpensshPrivateKey(seed, pub, comment)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Generate a certificate-authority keypair. The private half is exported as a
|
|
365
|
+
* JWK (so the broker can re-import it to sign); the public half is printed for
|
|
366
|
+
* admins to install on their hosts.
|
|
367
|
+
*/
|
|
368
|
+
async function generateSshCaKeyPair(comment = "seekrit-ca") {
|
|
369
|
+
const pair = await crypto.subtle.generateKey(ED25519, true, ["sign", "verify"]);
|
|
370
|
+
const pub = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
|
371
|
+
const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
|
|
372
|
+
return {
|
|
373
|
+
privateKeyJwk: JSON.stringify(jwk),
|
|
374
|
+
publicKeyOpenssh: encodeSshPublicKey(pub, comment)
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Serialize an Ed25519 keypair as an unencrypted `openssh-key-v1` private key.
|
|
379
|
+
* Format (PROTOCOL.key): magic ‖ ciphername "none" ‖ kdfname "none" ‖ empty
|
|
380
|
+
* kdfoptions ‖ nkeys=1 ‖ public-key blob ‖ private section (wrapped as a
|
|
381
|
+
* string). The private section is two equal check-ints, then the key, then the
|
|
382
|
+
* comment, padded with 1,2,3,… to the "none" block size (8).
|
|
383
|
+
*/
|
|
384
|
+
function encodeOpensshPrivateKey(seed, pub, comment) {
|
|
385
|
+
const check = new DataView(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(4)).buffer).getUint32(0, false);
|
|
386
|
+
const privSection = new SshWriter().uint32(check).uint32(check).string("ssh-ed25519").string(pub).string(concat(seed, pub)).string(comment).build();
|
|
387
|
+
const padLen = (8 - privSection.length % 8) % 8;
|
|
388
|
+
const pad = new Uint8Array(padLen);
|
|
389
|
+
for (let i = 0; i < padLen; i++) pad[i] = i + 1;
|
|
390
|
+
const b64 = toBase64(new SshWriter().bytes(utf8Encode("openssh-key-v1")).byte(0).string("none").string("none").string(/* @__PURE__ */ new Uint8Array(0)).uint32(1).string(ed25519PublicKeyBlob(pub)).string(concat(privSection, pad)).build());
|
|
391
|
+
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64.match(/.{1,70}/g)?.join("\n") ?? b64}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
262
394
|
//#region ../../packages/crypto/src/token.ts
|
|
263
395
|
/**
|
|
264
396
|
* Service tokens (CI, docker builds, agent proxies, k8s, …) are self-contained
|
|
@@ -385,7 +517,7 @@ async function unwrapDek(wrapped, privateKey) {
|
|
|
385
517
|
}
|
|
386
518
|
//#endregion
|
|
387
519
|
//#region package.json
|
|
388
|
-
var version = "0.
|
|
520
|
+
var version = "0.8.0";
|
|
389
521
|
const PROJECT_FILE = "seekrit.json";
|
|
390
522
|
function globalConfigPath() {
|
|
391
523
|
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "seekrit", "config.json");
|
|
@@ -592,7 +724,6 @@ var SeekritClient = class {
|
|
|
592
724
|
listLeases(orgId) {
|
|
593
725
|
return this.request("GET", `/v1/orgs/${orgId}/leases`);
|
|
594
726
|
}
|
|
595
|
-
/** Mint a temporary credential. Send only the client-computed verifier. */
|
|
596
727
|
mintLease(orgId, input) {
|
|
597
728
|
return this.request("POST", `/v1/orgs/${orgId}/leases`, input);
|
|
598
729
|
}
|
|
@@ -733,7 +864,7 @@ function formatSecrets(values, format) {
|
|
|
733
864
|
case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
|
|
734
865
|
}
|
|
735
866
|
}
|
|
736
|
-
z.enum(["postgres"]);
|
|
867
|
+
z.enum(["postgres", "ssh"]);
|
|
737
868
|
const executorModeSchema = z.enum(["in_do", "remote"]);
|
|
738
869
|
/**
|
|
739
870
|
* A Postgres role name we are willing to create. Deliberately strict — this
|
|
@@ -747,6 +878,16 @@ const postgresRoleNameSchema = z.string().regex(/^[a-z_][a-z0-9_]{2,62}$/, "must
|
|
|
747
878
|
* base64 + the fixed structural characters, none of which is a single quote).
|
|
748
879
|
*/
|
|
749
880
|
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");
|
|
881
|
+
/**
|
|
882
|
+
* An SSH login principal (a Unix-style username the certificate authorizes).
|
|
883
|
+
* Bounded and restricted to a safe charset — principals are SSH-wire-encoded,
|
|
884
|
+
* not shell-interpolated, so this is sanity/DoS hardening, not an injection gate.
|
|
885
|
+
*/
|
|
886
|
+
const sshPrincipalSchema = z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must be 1–64 chars of letters, digits, dot, dash, underscore");
|
|
887
|
+
/** An `ssh-ed25519 <base64> [comment]` public key line (deep-validated on sign). */
|
|
888
|
+
const sshPublicKeySchema = z.string().max(2048).regex(/^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$/, "must be an ssh-ed25519 public key");
|
|
889
|
+
/** An SSH certificate extension name, e.g. `permit-pty`. */
|
|
890
|
+
const sshExtensionSchema = z.string().regex(/^[a-z0-9-]{1,64}$/);
|
|
750
891
|
const postgresAccessLevelSchema = z.enum([
|
|
751
892
|
"readonly",
|
|
752
893
|
"readwrite",
|
|
@@ -766,7 +907,7 @@ const connectionSchema = z.object({
|
|
|
766
907
|
const statementSchema = z.string().min(1).max(4e3);
|
|
767
908
|
/** A bare SQL identifier (schema name) — no quotes/whitespace/semicolons. */
|
|
768
909
|
const identifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, "must be an identifier");
|
|
769
|
-
const
|
|
910
|
+
const postgresTargetConfigSchema = z.object({
|
|
770
911
|
provider: z.literal("postgres"),
|
|
771
912
|
executor: executorModeSchema,
|
|
772
913
|
accessLevel: postgresAccessLevelSchema.optional(),
|
|
@@ -776,6 +917,19 @@ const leaseTargetConfigSchema = z.object({
|
|
|
776
917
|
createStatements: z.array(statementSchema).max(16).optional(),
|
|
777
918
|
revokeStatements: z.array(statementSchema).max(16).optional()
|
|
778
919
|
});
|
|
920
|
+
const sshTargetConfigSchema = z.object({
|
|
921
|
+
provider: z.literal("ssh"),
|
|
922
|
+
executor: z.literal("in_do"),
|
|
923
|
+
caPublicKey: sshPublicKeySchema,
|
|
924
|
+
allowedPrincipals: z.array(sshPrincipalSchema).max(64).optional(),
|
|
925
|
+
extensions: z.array(sshExtensionSchema).max(16).optional(),
|
|
926
|
+
maxTtlSeconds: z.number().int().min(60).max(3600 * 24 * 7).optional(),
|
|
927
|
+
connection: z.object({
|
|
928
|
+
host: z.string().min(1).optional(),
|
|
929
|
+
user: sshPrincipalSchema.optional()
|
|
930
|
+
}).optional()
|
|
931
|
+
});
|
|
932
|
+
const leaseTargetConfigSchema = z.discriminatedUnion("provider", [postgresTargetConfigSchema, sshTargetConfigSchema]);
|
|
779
933
|
z.object({
|
|
780
934
|
name: z.string().trim().min(1).max(128),
|
|
781
935
|
config: leaseTargetConfigSchema,
|
|
@@ -786,12 +940,33 @@ z.object({
|
|
|
786
940
|
*/
|
|
787
941
|
wrappedAdminSecret: z.string().min(1)
|
|
788
942
|
});
|
|
789
|
-
|
|
943
|
+
/** Requested lease lifetime, shared by all providers. */
|
|
944
|
+
const ttlSecondsSchema = z.number().int().min(60).max(3600 * 24 * 7);
|
|
945
|
+
/**
|
|
946
|
+
* Client → API: mint a Postgres lease. The client generates the password and
|
|
947
|
+
* its SCRAM verifier locally and sends only the verifier — the plaintext
|
|
948
|
+
* password never leaves the requesting machine.
|
|
949
|
+
*/
|
|
950
|
+
const mintPostgresLeaseSchema = z.object({
|
|
951
|
+
provider: z.literal("postgres"),
|
|
790
952
|
targetId: z.string().min(1),
|
|
791
953
|
roleName: postgresRoleNameSchema,
|
|
792
954
|
verifier: scramVerifierSchema,
|
|
793
|
-
ttlSeconds:
|
|
955
|
+
ttlSeconds: ttlSecondsSchema
|
|
794
956
|
});
|
|
957
|
+
/**
|
|
958
|
+
* Client → API: mint an SSH lease. The client generates an ephemeral keypair
|
|
959
|
+
* locally and sends only the public key; the signed certificate comes back in
|
|
960
|
+
* the response. The private key never leaves the requesting machine.
|
|
961
|
+
*/
|
|
962
|
+
const mintSshLeaseSchema = z.object({
|
|
963
|
+
provider: z.literal("ssh"),
|
|
964
|
+
targetId: z.string().min(1),
|
|
965
|
+
publicKey: sshPublicKeySchema,
|
|
966
|
+
principals: z.array(sshPrincipalSchema).min(1).max(32),
|
|
967
|
+
ttlSeconds: ttlSecondsSchema
|
|
968
|
+
});
|
|
969
|
+
z.discriminatedUnion("provider", [mintPostgresLeaseSchema, mintSshLeaseSchema]);
|
|
795
970
|
//#endregion
|
|
796
971
|
//#region ../../packages/core/src/providers/postgres.ts
|
|
797
972
|
/**
|
|
@@ -825,6 +1000,27 @@ function postgresGroupBootstrapSql(config) {
|
|
|
825
1000
|
return lines.join("\n");
|
|
826
1001
|
}
|
|
827
1002
|
//#endregion
|
|
1003
|
+
//#region ../../packages/core/src/providers/ssh.ts
|
|
1004
|
+
/**
|
|
1005
|
+
* The one-time host setup an admin runs so a target's certificates are accepted.
|
|
1006
|
+
* Analogue of `postgresGroupBootstrapSql` — displayed for the admin to run, not
|
|
1007
|
+
* executed by seekrit. Embeds the CA public key so it's copy-paste runnable.
|
|
1008
|
+
*/
|
|
1009
|
+
function sshHostSetupInstructions(config) {
|
|
1010
|
+
const principals = config.allowedPrincipals?.length ? config.allowedPrincipals : ["<login-user>"];
|
|
1011
|
+
return [
|
|
1012
|
+
"# Run once on each target host so it trusts seekrit-issued certificates.",
|
|
1013
|
+
"# 1. Install the CA public key and trust it for user authentication:",
|
|
1014
|
+
`echo '${config.caPublicKey}' | sudo tee /etc/ssh/seekrit_ca.pub`,
|
|
1015
|
+
"sudo sh -c 'echo \"TrustedUserCAKeys /etc/ssh/seekrit_ca.pub\" >> /etc/ssh/sshd_config'",
|
|
1016
|
+
"# 2. (optional) Restrict which cert principals may log in as which users via",
|
|
1017
|
+
"# AuthorizedPrincipalsFile, e.g. /etc/ssh/auth_principals/<user> listing:",
|
|
1018
|
+
...principals.map((p) => `# ${p}`),
|
|
1019
|
+
"# 3. Reload sshd:",
|
|
1020
|
+
"sudo systemctl reload sshd"
|
|
1021
|
+
].join("\n");
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
828
1024
|
//#region ../../packages/core/src/schemas.ts
|
|
829
1025
|
/** URL-safe identifier segment: `my-app`, `production`, … */
|
|
830
1026
|
const slugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lowercase alphanumeric with hyphens");
|
|
@@ -1003,7 +1199,7 @@ async function resolveGroup(ctx, opts) {
|
|
|
1003
1199
|
* ciphertext.
|
|
1004
1200
|
*/
|
|
1005
1201
|
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
1006
|
-
function parseTtlSeconds(input) {
|
|
1202
|
+
function parseTtlSeconds$1(input) {
|
|
1007
1203
|
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1008
1204
|
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
1009
1205
|
return Number(m[1]) * ({
|
|
@@ -1024,7 +1220,7 @@ function generateRoleName(prefix = "tmp") {
|
|
|
1024
1220
|
function registerPgCommands(program) {
|
|
1025
1221
|
const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
|
|
1026
1222
|
const target = pg.command("target").description("manage provisioning targets");
|
|
1027
|
-
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("--admin-url <url>", "admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect, []).action(async (options) => {
|
|
1223
|
+
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("--admin-url <url>", "admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$1, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$1, []).action(async (options) => {
|
|
1028
1224
|
const ctx = buildContext();
|
|
1029
1225
|
const org = await resolveOrg(ctx, options.org);
|
|
1030
1226
|
const executor = options.executor === "remote" ? "remote" : "in_do";
|
|
@@ -1072,6 +1268,7 @@ function registerPgCommands(program) {
|
|
|
1072
1268
|
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1073
1269
|
for (const t of targets) {
|
|
1074
1270
|
const cfg = t.config;
|
|
1271
|
+
if (cfg.provider !== "postgres") continue;
|
|
1075
1272
|
console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
|
|
1076
1273
|
}
|
|
1077
1274
|
});
|
|
@@ -1081,7 +1278,9 @@ function registerPgCommands(program) {
|
|
|
1081
1278
|
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1082
1279
|
const t = targets.find((x) => x.id === targetId || x.name === targetId);
|
|
1083
1280
|
if (!t) fail(`no target "${targetId}" in ${org.slug}`);
|
|
1084
|
-
const
|
|
1281
|
+
const cfg = t.config;
|
|
1282
|
+
if (cfg.provider !== "postgres") fail("not a postgres target (see `seekrit ssh`)");
|
|
1283
|
+
const bootstrap = postgresGroupBootstrapSql(cfg);
|
|
1085
1284
|
if (!bootstrap) fail("this is a custom target — it has no generated setup SQL");
|
|
1086
1285
|
console.log(bootstrap);
|
|
1087
1286
|
});
|
|
@@ -1097,10 +1296,12 @@ function registerPgCommands(program) {
|
|
|
1097
1296
|
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1098
1297
|
const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
|
|
1099
1298
|
if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1299
|
+
if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
|
|
1100
1300
|
const roleName = options.role ?? generateRoleName();
|
|
1101
|
-
const ttlSeconds = parseTtlSeconds(options.ttl);
|
|
1301
|
+
const ttlSeconds = parseTtlSeconds$1(options.ttl);
|
|
1102
1302
|
const { password, verifier } = await generatePostgresCredential();
|
|
1103
1303
|
const { connection } = await ctx.client.mintLease(org.id, {
|
|
1304
|
+
provider: "postgres",
|
|
1104
1305
|
targetId: target.id,
|
|
1105
1306
|
roleName,
|
|
1106
1307
|
verifier,
|
|
@@ -1129,7 +1330,7 @@ function registerPgCommands(program) {
|
|
|
1129
1330
|
});
|
|
1130
1331
|
}
|
|
1131
1332
|
/** Collect a repeatable option into an array. */
|
|
1132
|
-
function collect(value, acc) {
|
|
1333
|
+
function collect$1(value, acc) {
|
|
1133
1334
|
acc.push(value);
|
|
1134
1335
|
return acc;
|
|
1135
1336
|
}
|
|
@@ -1234,6 +1435,147 @@ function printExplain(provenance) {
|
|
|
1234
1435
|
for (const name of names) process.stderr.write(`${name.padEnd(width)} ${provenance[name]}\n`);
|
|
1235
1436
|
}
|
|
1236
1437
|
//#endregion
|
|
1438
|
+
//#region src/ssh.ts
|
|
1439
|
+
/**
|
|
1440
|
+
* `seekrit ssh` — temporary SSH access via short-lived certificates (Vault-style
|
|
1441
|
+
* dynamic secrets, the SSH sibling of `seekrit pg`).
|
|
1442
|
+
*
|
|
1443
|
+
* Zero-knowledge: minting generates an ephemeral keypair on THIS machine and
|
|
1444
|
+
* sends only the public key; the broker signs a certificate and returns it (a
|
|
1445
|
+
* public artifact). The private key never leaves this machine. Registering a
|
|
1446
|
+
* target generates a CA keypair locally and wraps the CA *private* key to the
|
|
1447
|
+
* broker's public key, so the control plane only ever stores ciphertext; the CA
|
|
1448
|
+
* *public* key is printed for you to install on your hosts.
|
|
1449
|
+
*/
|
|
1450
|
+
/** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
|
|
1451
|
+
function parseTtlSeconds(input) {
|
|
1452
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
1453
|
+
if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
|
|
1454
|
+
return Number(m[1]) * ({
|
|
1455
|
+
s: 1,
|
|
1456
|
+
m: 60,
|
|
1457
|
+
h: 3600,
|
|
1458
|
+
d: 86400
|
|
1459
|
+
}[m[2] || "s"] ?? 1);
|
|
1460
|
+
}
|
|
1461
|
+
function registerSshCommands(program) {
|
|
1462
|
+
const ssh = program.command("ssh").description("temporary SSH access (short-lived certificates, zero-knowledge)");
|
|
1463
|
+
const target = ssh.command("target").description("manage SSH CA targets");
|
|
1464
|
+
target.command("add").description("create an SSH certificate authority to issue certs from").requiredOption("--name <name>", "display name, e.g. prod-fleet").option("--org <slug>").option("--host <host>", "default host the printed ssh command connects to").option("--user <login>", "default login user (a cert principal)").option("--principal <name>", "allow-list a principal certs may request (repeatable)", collect, []).option("--extension <name>", "cert extension to grant, e.g. permit-pty (repeatable)", collect, []).option("--max-ttl <duration>", "clamp requested cert lifetime, e.g. 8h").action(async (options) => {
|
|
1465
|
+
const ctx = buildContext();
|
|
1466
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1467
|
+
const ca = await generateSshCaKeyPair(`seekrit-ca:${options.name}`);
|
|
1468
|
+
const config = {
|
|
1469
|
+
provider: "ssh",
|
|
1470
|
+
executor: "in_do",
|
|
1471
|
+
caPublicKey: ca.publicKeyOpenssh,
|
|
1472
|
+
...options.principal.length ? { allowedPrincipals: options.principal } : {},
|
|
1473
|
+
...options.extension.length ? { extensions: options.extension } : {},
|
|
1474
|
+
...options.maxTtl ? { maxTtlSeconds: parseTtlSeconds(options.maxTtl) } : {},
|
|
1475
|
+
...options.host || options.user ? { connection: {
|
|
1476
|
+
...options.host ? { host: options.host } : {},
|
|
1477
|
+
...options.user ? { user: options.user } : {}
|
|
1478
|
+
} } : {}
|
|
1479
|
+
};
|
|
1480
|
+
const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
|
|
1481
|
+
const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(ca.privateKeyJwk), publicKeyJwk);
|
|
1482
|
+
const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
|
|
1483
|
+
name: options.name,
|
|
1484
|
+
config,
|
|
1485
|
+
wrappedAdminSecret
|
|
1486
|
+
});
|
|
1487
|
+
console.error(`registered SSH target ${created.name} (${created.id})`);
|
|
1488
|
+
console.error("\nInstall the CA on your hosts, then issue certs with `seekrit ssh lease`:\n");
|
|
1489
|
+
console.log(sshHostSetupInstructions(config));
|
|
1490
|
+
});
|
|
1491
|
+
target.command("list").description("list SSH CA targets").option("--org <slug>").action(async (options) => {
|
|
1492
|
+
const ctx = buildContext();
|
|
1493
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1494
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1495
|
+
for (const t of targets) {
|
|
1496
|
+
const cfg = t.config;
|
|
1497
|
+
if (cfg.provider !== "ssh") continue;
|
|
1498
|
+
const where = cfg.connection?.host ?? "-";
|
|
1499
|
+
const principals = cfg.allowedPrincipals?.join(",") ?? "any";
|
|
1500
|
+
console.log(`${t.id}\t${t.name}\t${where}\tprincipals=${principals}`);
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
target.command("setup <targetId>").description("reprint the one-time host setup for an SSH target").option("--org <slug>").action(async (targetId, options) => {
|
|
1504
|
+
const ctx = buildContext();
|
|
1505
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1506
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1507
|
+
const t = targets.find((x) => x.id === targetId || x.name === targetId);
|
|
1508
|
+
if (!t) fail(`no target "${targetId}" in ${org.slug}`);
|
|
1509
|
+
const cfg = t.config;
|
|
1510
|
+
if (cfg.provider !== "ssh") fail("not an ssh target (see `seekrit pg`)");
|
|
1511
|
+
console.log(sshHostSetupInstructions(cfg));
|
|
1512
|
+
});
|
|
1513
|
+
target.command("rm <targetId>").description("delete an SSH CA target").option("--org <slug>").action(async (targetId, options) => {
|
|
1514
|
+
const ctx = buildContext();
|
|
1515
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1516
|
+
await ctx.client.deleteLeaseTarget(org.id, targetId);
|
|
1517
|
+
console.error(`deleted ${targetId}`);
|
|
1518
|
+
});
|
|
1519
|
+
ssh.command("lease <target>").description("mint a short-lived SSH certificate; prints a ready-to-run ssh command").option("--org <slug>").option("--principal <name>", "login user to request (repeatable; default from target)", collect, []).option("--ttl <duration>", "certificate lifetime, e.g. 30m, 1h, 8h", "1h").option("--out <dir>", "directory to write the key + cert (default: a temp dir)").option("--json", "print key/cert paths and the certificate as JSON").action(async (targetRef, options) => {
|
|
1520
|
+
const ctx = buildContext();
|
|
1521
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1522
|
+
const { targets } = await ctx.client.listLeaseTargets(org.id);
|
|
1523
|
+
const t = targets.find((x) => x.id === targetRef || x.name === targetRef);
|
|
1524
|
+
if (!t) fail(`no target "${targetRef}" in ${org.slug}`);
|
|
1525
|
+
const cfg = t.config;
|
|
1526
|
+
if (cfg.provider !== "ssh") fail(`"${t.name}" is not an ssh target (see \`seekrit pg\`)`);
|
|
1527
|
+
const principals = options.principal.length ? options.principal : cfg.connection?.user ? [cfg.connection.user] : cfg.allowedPrincipals ?? [];
|
|
1528
|
+
if (principals.length === 0) fail("specify at least one --principal");
|
|
1529
|
+
const ttlSeconds = parseTtlSeconds(options.ttl);
|
|
1530
|
+
const keypair = await generateSshKeyPair(`seekrit:${t.name}`);
|
|
1531
|
+
const { ssh: cert } = await ctx.client.mintLease(org.id, {
|
|
1532
|
+
provider: "ssh",
|
|
1533
|
+
targetId: t.id,
|
|
1534
|
+
publicKey: keypair.publicKeyOpenssh,
|
|
1535
|
+
principals,
|
|
1536
|
+
ttlSeconds
|
|
1537
|
+
});
|
|
1538
|
+
const dir = options.out ?? mkdtempSync(join(tmpdir(), "seekrit-ssh-"));
|
|
1539
|
+
if (options.out) mkdirSync(dir, { recursive: true });
|
|
1540
|
+
const keyPath = join(dir, "id_ed25519");
|
|
1541
|
+
const certPath = join(dir, "id_ed25519-cert.pub");
|
|
1542
|
+
writeFileSync(keyPath, keypair.privateKeyOpenssh, { mode: 384 });
|
|
1543
|
+
writeFileSync(certPath, `${cert.certificate}\n`, { mode: 420 });
|
|
1544
|
+
const loginUser = principals[0];
|
|
1545
|
+
const command = `ssh -i ${keyPath} -o CertificateFile=${certPath}${cert.host ? ` ${loginUser}@${cert.host}` : ""}`;
|
|
1546
|
+
console.error(`issued cert for ${principals.join(",")} — expires ${cert.expiresAt}`);
|
|
1547
|
+
if (options.json) console.log(JSON.stringify({
|
|
1548
|
+
keyPath,
|
|
1549
|
+
certPath,
|
|
1550
|
+
certificate: cert.certificate,
|
|
1551
|
+
principals,
|
|
1552
|
+
command,
|
|
1553
|
+
expiresAt: cert.expiresAt
|
|
1554
|
+
}, null, 2));
|
|
1555
|
+
else console.log(command);
|
|
1556
|
+
});
|
|
1557
|
+
ssh.command("leases").description("list SSH leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
|
|
1558
|
+
const ctx = buildContext();
|
|
1559
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1560
|
+
const { leases } = await ctx.client.listLeases(org.id);
|
|
1561
|
+
for (const l of leases) {
|
|
1562
|
+
if (l.provider !== "ssh") continue;
|
|
1563
|
+
console.log(`${l.id}\t${l.status}\texpires ${l.expiresAt}`);
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
ssh.command("revoke <leaseId>").description("mark a lease revoked in the ledger (the cert stays valid until it expires)").option("--org <slug>").action(async (leaseId, options) => {
|
|
1567
|
+
const ctx = buildContext();
|
|
1568
|
+
const org = await resolveOrg(ctx, options.org);
|
|
1569
|
+
await ctx.client.revokeLease(org.id, leaseId);
|
|
1570
|
+
console.error(`revoked ${leaseId} (issued certs remain valid until they expire)`);
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
/** Collect a repeatable option into an array. */
|
|
1574
|
+
function collect(value, acc) {
|
|
1575
|
+
acc.push(value);
|
|
1576
|
+
return acc;
|
|
1577
|
+
}
|
|
1578
|
+
//#endregion
|
|
1237
1579
|
//#region src/index.ts
|
|
1238
1580
|
/** Collect repeated `--with group=env` flags into a map. */
|
|
1239
1581
|
function collectKv(value, acc = {}) {
|
|
@@ -1591,8 +1933,9 @@ token.command("revoke <tokenId>").description("revoke a service token").option("
|
|
|
1591
1933
|
console.error(`${tokenId} revoked`);
|
|
1592
1934
|
});
|
|
1593
1935
|
registerPgCommands(program);
|
|
1936
|
+
registerSshCommands(program);
|
|
1594
1937
|
program.command("mcp").description("run an MCP server over stdio so AI agents can drive seekrit").action(async () => {
|
|
1595
|
-
const { runMcpServer } = await import("./mcp-
|
|
1938
|
+
const { runMcpServer } = await import("./mcp-DNUBSbcd.js");
|
|
1596
1939
|
await runMcpServer();
|
|
1597
1940
|
});
|
|
1598
1941
|
program.command("audit").description("show the org audit trail").option("--org <slug>").option("--limit <n>", "entries to fetch", "50").action(async (options) => {
|
|
@@ -560,6 +560,7 @@ async function runMcpServer() {
|
|
|
560
560
|
const roleName = role ?? `tmp_${randomLower(12)}`;
|
|
561
561
|
const { password, verifier } = await generatePostgresCredential();
|
|
562
562
|
const { lease, connection } = await ctx.client.mintLease(orgRef.id, {
|
|
563
|
+
provider: "postgres",
|
|
563
564
|
targetId: t.id,
|
|
564
565
|
roleName,
|
|
565
566
|
verifier,
|