@seekrit/cli 0.44.0 → 0.46.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.
Files changed (2) hide show
  1. package/dist/index.js +2168 -524
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -562,6 +562,247 @@ z.object({
562
562
  z.object({
563
563
  /** The presented token. In the body, never a URL — it is a credential. */
564
564
  token: z.string().trim().min(8).max(512) });
565
+ //#endregion
566
+ //#region ../../packages/core/src/archive.ts
567
+ /**
568
+ * The **break-glass archive** format: one signed JSON file holding everything
569
+ * seekrit stores for an org, in the form seekrit stores it — ciphertext stays
570
+ * ciphertext. Its whole purpose is to be openable on a machine that has never
571
+ * heard of seekrit, so the format is plain JSON, self-describing, and versioned
572
+ * by name (`seekrit-archive/v1`); a breaking change ships a new format string
573
+ * rather than mutating this one, exactly like the `sc1.`/`wd1.` blob prefixes.
574
+ *
575
+ * Three parts:
576
+ * - `manifest` — what this archive is, and a SHA-256 digest per section.
577
+ * - `signature` — Ed25519 over the canonical manifest, or null when the
578
+ * producing deployment has no signing key configured.
579
+ * - `data` — the sections themselves.
580
+ *
581
+ * Integrity fields (`digest`, `signature.value`, `publicKey`, `keyId`) are
582
+ * lowercase hex. Every blob *inside* `data` keeps its native base64url form, so
583
+ * the one encoding rule to remember is "the archive's own bookkeeping is hex,
584
+ * seekrit's blobs are unchanged".
585
+ *
586
+ * See docs/break-glass-export.md for what is deliberately excluded and why.
587
+ */
588
+ const ARCHIVE_FORMAT = "seekrit-archive/v1";
589
+ /**
590
+ * Section order is part of the format: `manifest.digest` covers the section
591
+ * headers in this order, so a verifier that re-serializes must produce the same
592
+ * list. Appending a section is backwards-compatible; reordering is not.
593
+ */
594
+ const ARCHIVE_SECTIONS = [
595
+ "organization",
596
+ "users",
597
+ "memberships",
598
+ "invites",
599
+ "applications",
600
+ "groups",
601
+ "environments",
602
+ "environmentGroups",
603
+ "environmentKeys",
604
+ "secrets",
605
+ "secretVersions",
606
+ "serviceTokens",
607
+ "m2mClients",
608
+ "kmsKeys",
609
+ "kmsKeyVersions",
610
+ "kmsKeyGrants",
611
+ "recoveryConfig",
612
+ "recoveryShares",
613
+ "rotations",
614
+ "syncConnections",
615
+ "syncBindings",
616
+ "leaseTargets",
617
+ "agentIdentities",
618
+ "agentPolicies",
619
+ "auditLog",
620
+ "keyMaterial"
621
+ ];
622
+ /**
623
+ * Deterministic JSON: object keys sorted recursively, no whitespace. A digest
624
+ * has to be reproducible by a verifier that parsed the file and re-serialized
625
+ * it, and parsing loses key order — so key order must not matter.
626
+ *
627
+ * Arrays keep their order (it is data). Every numeric field in the schema is an
628
+ * integer, so JSON's float formatting never comes into it. `undefined` members
629
+ * are dropped, matching `JSON.stringify`.
630
+ */
631
+ function canonicalJson(value) {
632
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
633
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
634
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
635
+ }
636
+ function bytesToHex(bytes) {
637
+ let out = "";
638
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
639
+ return out;
640
+ }
641
+ /** Throws on odd length or a non-hex character — a malformed field, not a mismatch. */
642
+ function hexToBytes(hex) {
643
+ if (hex.length % 2 !== 0) throw new Error("hex string has odd length");
644
+ const out = new Uint8Array(hex.length / 2);
645
+ for (let i = 0; i < out.length; i++) {
646
+ const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
647
+ if (Number.isNaN(byte)) throw new Error("hex string contains a non-hex character");
648
+ out[i] = byte;
649
+ }
650
+ return out;
651
+ }
652
+ /** `sha256:<hex>` over the UTF-8 bytes of `text`. */
653
+ async function sha256Hex(text) {
654
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
655
+ return `sha256:${bytesToHex(new Uint8Array(digest))}`;
656
+ }
657
+ /** Number a section header reports: array length, or 0/1 for the singleton sections. */
658
+ function sectionCount(value) {
659
+ if (value === null || value === void 0) return 0;
660
+ return Array.isArray(value) ? value.length : 1;
661
+ }
662
+ /** Digest one section's value. Absent singletons hash as `null`, not as omitted. */
663
+ function sectionDigest(value) {
664
+ return sha256Hex(canonicalJson(value ?? null));
665
+ }
666
+ /**
667
+ * Digest over the section-header list. Covers each section's digest, count, and
668
+ * truncation flag, so trimming rows *and* fixing up their digest still breaks
669
+ * the manifest.
670
+ */
671
+ function manifestDigest(sections) {
672
+ return sha256Hex(canonicalJson(sections));
673
+ }
674
+ /** The exact bytes the signature covers: the canonical manifest, digest included. */
675
+ function signingPayload(manifest) {
676
+ return new TextEncoder().encode(canonicalJson(manifest));
677
+ }
678
+ /**
679
+ * Verify an archive's digests and signature. Pure and offline — WebCrypto only,
680
+ * no I/O, so it runs identically in the CLI, a browser page, and a test.
681
+ *
682
+ * Reports every problem it finds rather than throwing on the first, because the
683
+ * useful answer to "my archive won't verify" is *which part*.
684
+ */
685
+ async function verifyArchive(archive, options = {}) {
686
+ const declared = new Map(archive.manifest.sections.map((s) => [s.name, s]));
687
+ const badSections = [];
688
+ const missingSections = [];
689
+ const truncatedSections = [];
690
+ const data = archive.data ?? {};
691
+ for (const header of archive.manifest.sections) {
692
+ if (header.truncated) truncatedSections.push(header.name);
693
+ if (!(header.name in data)) {
694
+ missingSections.push(header.name);
695
+ continue;
696
+ }
697
+ const value = data[header.name];
698
+ if (sectionCount(value) !== header.count || await sectionDigest(value) !== header.digest) badSections.push(header.name);
699
+ }
700
+ const undeclaredSections = Object.keys(data).filter((key) => !declared.has(key));
701
+ const manifestDigestOk = await manifestDigest(archive.manifest.sections) === archive.manifest.digest;
702
+ let signature = "unsigned";
703
+ let signatureNote;
704
+ if (options.skipSignature) {
705
+ signature = "unverifiable";
706
+ signatureNote = "signature checking was skipped";
707
+ } else if (archive.signature) if (options.expectKeyId && options.expectKeyId !== archive.signature.keyId) {
708
+ signature = "invalid";
709
+ signatureNote = `signed by key ${archive.signature.keyId}, expected ${options.expectKeyId}`;
710
+ } else try {
711
+ signature = await verifySignature(archive.manifest, archive.signature) ? "valid" : "invalid";
712
+ } catch (err) {
713
+ signature = "unverifiable";
714
+ signatureNote = err instanceof Error ? err.message : String(err);
715
+ }
716
+ return {
717
+ ok: badSections.length === 0 && missingSections.length === 0 && undeclaredSections.length === 0 && manifestDigestOk && signature === "valid",
718
+ badSections,
719
+ undeclaredSections,
720
+ missingSections,
721
+ manifestDigestOk,
722
+ signature,
723
+ signatureNote,
724
+ truncatedSections
725
+ };
726
+ }
727
+ /**
728
+ * Ed25519 verify over the canonical manifest. Plain WebCrypto: raw public-key
729
+ * import plus `verify` is supported in Workers, Node ≥18, and current browsers,
730
+ * so no verifier anywhere needs a dependency. Throws (rather than returning
731
+ * false) when the runtime has no Ed25519 at all, so "old browser" is reported
732
+ * as unverifiable instead of as a bad signature.
733
+ */
734
+ async function verifySignature(manifest, signature) {
735
+ if (signature.algorithm !== "ed25519") throw new Error(`unsupported signature algorithm: ${signature.algorithm}`);
736
+ const publicKey = await crypto.subtle.importKey("raw", hexToBytes(signature.publicKey), { name: "Ed25519" }, false, ["verify"]);
737
+ return crypto.subtle.verify({ name: "Ed25519" }, publicKey, hexToBytes(signature.value), signingPayload(manifest));
738
+ }
739
+ const sectionHeaderSchema = z.object({
740
+ name: z.enum(ARCHIVE_SECTIONS),
741
+ count: z.number().int().min(0),
742
+ truncated: z.boolean(),
743
+ digest: z.string().regex(/^sha256:[0-9a-f]{64}$/)
744
+ });
745
+ const manifestSchema = z.object({
746
+ archiveId: z.string().min(1),
747
+ createdAt: z.string().min(1),
748
+ org: z.object({
749
+ id: z.string(),
750
+ slug: z.string(),
751
+ name: z.string()
752
+ }),
753
+ producer: z.object({
754
+ service: z.string(),
755
+ environment: z.string(),
756
+ formatVersion: z.string()
757
+ }),
758
+ requestedBy: z.object({
759
+ actorType: z.string(),
760
+ actorId: z.string(),
761
+ label: z.string().nullable()
762
+ }),
763
+ options: z.object({
764
+ includeVersions: z.boolean(),
765
+ includeAudit: z.boolean(),
766
+ auditLimit: z.number().int().min(0)
767
+ }),
768
+ sections: z.array(sectionHeaderSchema),
769
+ digest: z.string().regex(/^sha256:[0-9a-f]{64}$/)
770
+ });
771
+ const signatureSchema = z.object({
772
+ algorithm: z.literal("ed25519"),
773
+ publicKey: z.string().regex(/^[0-9a-f]{64}$/),
774
+ keyId: z.string().regex(/^[0-9a-f]{16}$/),
775
+ value: z.string().regex(/^[0-9a-f]{128}$/)
776
+ });
777
+ /**
778
+ * Envelope schema. `data` is validated only as an object: a v1 verifier must
779
+ * still be able to check and decrypt an archive from a later producer that
780
+ * appended a section, and unknown sections are caught by
781
+ * `verifyArchive`'s undeclared/declared cross-check rather than by rejecting
782
+ * the file outright.
783
+ */
784
+ const archiveSchema = z.object({
785
+ format: z.literal(ARCHIVE_FORMAT),
786
+ manifest: manifestSchema,
787
+ signature: signatureSchema.nullable(),
788
+ data: z.record(z.string(), z.unknown())
789
+ });
790
+ /** Parse untrusted JSON text into an archive. Throws with a readable message. */
791
+ function parseArchive(text) {
792
+ let json;
793
+ try {
794
+ json = JSON.parse(text);
795
+ } catch {
796
+ throw new Error("not valid JSON — is this a seekrit archive?");
797
+ }
798
+ const parsed = archiveSchema.safeParse(json);
799
+ if (!parsed.success) {
800
+ const first = parsed.error.issues[0];
801
+ const where = first?.path.join(".") || "archive";
802
+ throw new Error(`not a ${ARCHIVE_FORMAT} archive: ${where}: ${first?.message ?? "invalid"}`);
803
+ }
804
+ return parsed.data;
805
+ }
565
806
  /** All catalog keys as a runtime array (for iteration / zod enums). */
566
807
  const ENTITLEMENT_KEYS = Object.keys({
567
808
  "feature.kms": {
@@ -1663,6 +1904,7 @@ const AUDIT_ACTIONS = [
1663
1904
  "org.member_invited",
1664
1905
  "org.invite_revoked",
1665
1906
  "org.mfa_policy_changed",
1907
+ "org.exported",
1666
1908
  "user.keys_updated",
1667
1909
  "user.notification_prefs_updated",
1668
1910
  "app.created",
@@ -1694,6 +1936,7 @@ const AUDIT_ACTIONS = [
1694
1936
  "secret.rotated",
1695
1937
  "secret.rotation_failed",
1696
1938
  "token.created",
1939
+ "token.updated",
1697
1940
  "token.revoked",
1698
1941
  "token.deleted",
1699
1942
  "honey_token.created",
@@ -1848,6 +2091,13 @@ const inviteRoleSchema = z.enum(["admin", "member"]);
1848
2091
  const principalTypeSchema = z.enum(["user", "service_token"]);
1849
2092
  /** Org-level capability a service token can hold (never `owner`). */
1850
2093
  const serviceTokenRoleSchema = z.enum(["admin", "member"]);
2094
+ z.object({
2095
+ /** Include the full append-only ciphertext history of every secret. */
2096
+ includeVersions: z.boolean().optional(),
2097
+ includeAudit: z.boolean().optional(),
2098
+ /** Newest audit rows to keep. Exceeding the server cap is a 400, not a silent trim. */
2099
+ auditLimit: z.number().int().min(0).optional()
2100
+ });
1851
2101
  z.object({
1852
2102
  name: nameSchema,
1853
2103
  slug: slugSchema
@@ -1859,6 +2109,8 @@ z.object({
1859
2109
  z.object({ name: nameSchema });
1860
2110
  z.object({ name: nameSchema });
1861
2111
  z.object({ name: nameSchema });
2112
+ z.object({ name: nameSchema });
2113
+ z.object({ name: nameSchema });
1862
2114
  z.object({ required: z.boolean() });
1863
2115
  z.object({
1864
2116
  email: emailSchema,
@@ -4624,7 +4876,7 @@ async function createAgentTaskToken() {
4624
4876
  }
4625
4877
  //#endregion
4626
4878
  //#region package.json
4627
- var version = "0.44.0";
4879
+ var version = "0.46.0";
4628
4880
  //#endregion
4629
4881
  //#region ../../packages/api-client/src/index.ts
4630
4882
  var SeekritApiError = class extends Error {
@@ -4776,6 +5028,10 @@ var SeekritClient = class {
4776
5028
  getEnv(orgId, envId) {
4777
5029
  return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}`);
4778
5030
  }
5031
+ /** Rename an environment (display name only — the slug is immutable). */
5032
+ updateEnv(orgId, envId, input) {
5033
+ return this.request("PATCH", `/v1/orgs/${orgId}/envs/${envId}`, input);
5034
+ }
4779
5035
  deleteEnv(orgId, envId) {
4780
5036
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
4781
5037
  }
@@ -4929,6 +5185,10 @@ var SeekritClient = class {
4929
5185
  createToken(orgId, input) {
4930
5186
  return this.request("POST", `/v1/orgs/${orgId}/tokens`, input);
4931
5187
  }
5188
+ /** Rename a token. Role, environment binding, and expiry are immutable. */
5189
+ updateToken(orgId, tokenId, input) {
5190
+ return this.request("PATCH", `/v1/orgs/${orgId}/tokens/${tokenId}`, input);
5191
+ }
4932
5192
  revokeToken(orgId, tokenId) {
4933
5193
  return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
4934
5194
  }
@@ -5207,6 +5467,17 @@ var SeekritClient = class {
5207
5467
  const qs = params.size > 0 ? `?${params}` : "";
5208
5468
  return this.request("GET", `/v1/orgs/${orgId}/audit${qs}`);
5209
5469
  }
5470
+ /**
5471
+ * Export the org as one signed archive: every row seekrit holds for it, with
5472
+ * ciphertext still ciphertext (docs/break-glass-export.md).
5473
+ *
5474
+ * The archive comes back inline rather than as a job handle, and it can be
5475
+ * megabytes — buffer it to a file rather than holding several copies. Requires
5476
+ * admin; deliberately not entitlement-gated.
5477
+ */
5478
+ exportArchive(orgId, input = {}) {
5479
+ return this.request("POST", `/v1/orgs/${orgId}/export`, input);
5480
+ }
5210
5481
  getLogSink(orgId) {
5211
5482
  return this.request("GET", `/v1/orgs/${orgId}/log-sink`);
5212
5483
  }
@@ -6513,7 +6784,7 @@ function registerPolicyCommands(policy) {
6513
6784
  //#endregion
6514
6785
  //#region src/kms.ts
6515
6786
  /** Collect a repeatable option into a list. */
6516
- function collect$6(value, acc = []) {
6787
+ function collect$7(value, acc = []) {
6517
6788
  acc.push(value);
6518
6789
  return acc;
6519
6790
  }
@@ -6581,7 +6852,7 @@ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
6581
6852
  }
6582
6853
  function registerKmsCommands(program) {
6583
6854
  const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
6584
- kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$6, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$6, []).action(async (options) => {
6855
+ kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$7, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$7, []).action(async (options) => {
6585
6856
  if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
6586
6857
  if (options.app && options.group) fail("pass at most one of --app or --group");
6587
6858
  const ctx = buildContext();
@@ -6800,7 +7071,7 @@ function registerKmsCommands(program) {
6800
7071
  //#endregion
6801
7072
  //#region src/recovery.ts
6802
7073
  /** Collect a repeatable option into a list. */
6803
- function collect$5(value, acc = []) {
7074
+ function collect$6(value, acc = []) {
6804
7075
  acc.push(value);
6805
7076
  return acc;
6806
7077
  }
@@ -6897,7 +7168,7 @@ function registerRecoveryCommands(program) {
6897
7168
  for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
6898
7169
  if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
6899
7170
  });
6900
- recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$5, []).option("--org <slug>").action(async (options) => {
7171
+ recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$6, []).option("--org <slug>").action(async (options) => {
6901
7172
  const ctx = buildContext();
6902
7173
  const org = await resolveOrg(ctx, options.org);
6903
7174
  const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
@@ -6915,7 +7186,7 @@ function registerRecoveryCommands(program) {
6915
7186
  const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
6916
7187
  console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
6917
7188
  });
6918
- recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$5, []).option("--org <slug>").action(async (options) => {
7189
+ recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$6, []).option("--org <slug>").action(async (options) => {
6919
7190
  const ctx = buildContext();
6920
7191
  const org = await resolveOrg(ctx, options.org);
6921
7192
  const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
@@ -7195,6 +7466,1179 @@ function registerAppCommands(program) {
7195
7466
  });
7196
7467
  }
7197
7468
  //#endregion
7469
+ //#region src/decryptor.ts
7470
+ /**
7471
+ * The standalone offline decryptor: one self-contained HTML file, written out by
7472
+ * `seekrit archive decryptor`, that opens a break-glass archive in any browser
7473
+ * with no install, no network, and no seekrit.
7474
+ *
7475
+ * Why a duplicate of the decrypt path instead of a bundle of `@seekrit/crypto`:
7476
+ * the artifact has to be a single file a customer can store next to their
7477
+ * archives for years and open from `file://`, which rules out a module graph and
7478
+ * a build step. So it is a second implementation — pinned, like every other
7479
+ * second implementation in this repo (the four SDKs, `crates/seekrit-core`), by a
7480
+ * test that runs *this* script against ciphertext produced by the real library:
7481
+ * `test/decryptor.test.ts`. If you change a blob format, that test fails here
7482
+ * too, which is the point.
7483
+ *
7484
+ * Two rules for editing the embedded script:
7485
+ * - **No backticks and no `${`** anywhere inside it — it lives in a template
7486
+ * literal. Use string concatenation.
7487
+ * - **No network of any kind.** The page declares
7488
+ * `Content-Security-Policy: default-src 'none'`, which is the property that
7489
+ * makes it safe to type a passphrase into. Anything that needs a fetch does
7490
+ * not belong here.
7491
+ */
7492
+ const OFFLINE_DECRYPTOR_HTML = `<!doctype html>
7493
+ <html lang="en">
7494
+ <head>
7495
+ <meta charset="utf-8">
7496
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7497
+ <!--
7498
+ The whole security argument for this page, in one header: with default-src
7499
+ 'none' the browser refuses every outbound request the page could make, so the
7500
+ passphrase you type and the plaintext it produces cannot leave this machine.
7501
+ 'unsafe-inline' covers the page's own inline script and styles; there is no
7502
+ connect-src, no img-src, no form-action.
7503
+ -->
7504
+ <meta http-equiv="Content-Security-Policy"
7505
+ content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
7506
+ <title>seekrit — offline archive decryptor</title>
7507
+ <style>
7508
+ :root {
7509
+ color-scheme: dark light;
7510
+ --bg: #0b0c0e; --fg: #e7e9ea; --dim: #9aa0a6; --line: #23262b;
7511
+ --panel: #101215; --accent: #7dd3a0; --warn: #f0b76b; --bad: #f08a8a;
7512
+ }
7513
+ @media (prefers-color-scheme: light) {
7514
+ :root {
7515
+ --bg: #fbfbfa; --fg: #14161a; --dim: #5f6673; --line: #e3e5e8;
7516
+ --panel: #ffffff; --accent: #1a7f4b; --warn: #9a6413; --bad: #b23b3b;
7517
+ }
7518
+ }
7519
+ * { box-sizing: border-box; }
7520
+ body {
7521
+ margin: 0; padding: 2rem 1.25rem 4rem; background: var(--bg); color: var(--fg);
7522
+ font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
7523
+ }
7524
+ main { max-width: 62rem; margin: 0 auto; }
7525
+ h1 { font-size: 1.1rem; letter-spacing: 0.02em; margin: 0 0 0.25rem; }
7526
+ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.12em;
7527
+ color: var(--dim); margin: 2rem 0 0.75rem; font-weight: 500; }
7528
+ p { margin: 0.4rem 0; color: var(--dim); }
7529
+ .panel { border: 1px solid var(--line); background: var(--panel); border-radius: 6px;
7530
+ padding: 1rem 1.1rem; }
7531
+ .drop { border: 1px dashed var(--line); border-radius: 6px; padding: 2rem 1rem;
7532
+ text-align: center; color: var(--dim); }
7533
+ .drop.over { border-color: var(--accent); color: var(--fg); }
7534
+ label { display: block; color: var(--dim); margin: 0.75rem 0 0.25rem; }
7535
+ input, select, textarea, button {
7536
+ font: inherit; color: var(--fg); background: var(--bg);
7537
+ border: 1px solid var(--line); border-radius: 4px; padding: 0.5rem 0.6rem;
7538
+ }
7539
+ input, select, textarea { width: 100%; }
7540
+ textarea { min-height: 5rem; }
7541
+ button { cursor: pointer; background: var(--panel); }
7542
+ button:hover { border-color: var(--accent); }
7543
+ button.primary { border-color: var(--accent); color: var(--accent); }
7544
+ .row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
7545
+ table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
7546
+ th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--line);
7547
+ vertical-align: top; word-break: break-all; }
7548
+ th { color: var(--dim); font-weight: 500; font-size: 0.85rem; }
7549
+ .kv { display: grid; grid-template-columns: 12rem 1fr; gap: 0.15rem 1rem; }
7550
+ .kv dt { color: var(--dim); }
7551
+ .kv dd { margin: 0; word-break: break-all; }
7552
+ .ok { color: var(--accent); } .warn { color: var(--warn); } .bad { color: var(--bad); }
7553
+ .hidden { display: none; }
7554
+ .env { margin-bottom: 1.5rem; }
7555
+ .muted { color: var(--dim); font-size: 0.85rem; }
7556
+ pre { white-space: pre-wrap; word-break: break-all; margin: 0.5rem 0 0; }
7557
+ .value { font-family: inherit; }
7558
+ ul { margin: 0.3rem 0 0; padding-left: 1.2rem; color: var(--dim); }
7559
+ </style>
7560
+ </head>
7561
+ <body>
7562
+ <main>
7563
+ <h1>seekrit — offline archive decryptor</h1>
7564
+ <p>
7565
+ Opens a <code>seekrit-archive/v1</code> file with your own key. This page has
7566
+ no network access at all (see the CSP in its source) &mdash; your passphrase
7567
+ and your secrets never leave this machine. Nothing is uploaded, and nothing
7568
+ needs to be installed.
7569
+ </p>
7570
+
7571
+ <h2>1 &middot; the archive</h2>
7572
+ <div class="drop panel" id="drop">
7573
+ <input type="file" id="file" accept=".json,application/json" style="width:auto">
7574
+ <p class="muted">or drop the file here</p>
7575
+ </div>
7576
+ <div id="manifest" class="panel hidden" style="margin-top:0.75rem"></div>
7577
+
7578
+ <div id="step2" class="hidden">
7579
+ <h2>2 &middot; your key</h2>
7580
+ <div class="panel">
7581
+ <label for="method">how you hold it</label>
7582
+ <select id="method">
7583
+ <option value="passphrase">passphrase (unlocks the key inside the archive)</option>
7584
+ <option value="token">service token (skt_&hellip;)</option>
7585
+ <option value="jwk">private key (JWK)</option>
7586
+ <option value="shares">custodian shares (recovery quorum)</option>
7587
+ </select>
7588
+
7589
+ <div id="field-passphrase">
7590
+ <label for="passphrase">passphrase</label>
7591
+ <input type="password" id="passphrase" autocomplete="off" spellcheck="false">
7592
+ <p class="muted" id="key-owner"></p>
7593
+ </div>
7594
+ <div id="field-token" class="hidden">
7595
+ <label for="token">service token</label>
7596
+ <input type="password" id="token" autocomplete="off" spellcheck="false"
7597
+ placeholder="skt_...">
7598
+ </div>
7599
+ <div id="field-jwk" class="hidden">
7600
+ <label for="jwk">private key JWK</label>
7601
+ <textarea id="jwk" spellcheck="false" placeholder='{"kty":"EC","crv":"P-256",...}'></textarea>
7602
+ </div>
7603
+ <div id="field-shares" class="hidden">
7604
+ <label for="shares">custodian shares</label>
7605
+ <textarea id="shares" spellcheck="false"
7606
+ placeholder="paste the contents of each share file from 'seekrit archive share', one after another"></textarea>
7607
+ <p class="muted">
7608
+ Reconstructs the org recovery key from a quorum, which opens every
7609
+ environment &mdash; for when the key-holder is gone.
7610
+ </p>
7611
+ </div>
7612
+
7613
+ <div class="row" style="margin-top:1rem">
7614
+ <button class="primary" id="decrypt">decrypt</button>
7615
+ <span id="status" class="muted"></span>
7616
+ </div>
7617
+ </div>
7618
+ </div>
7619
+
7620
+ <div id="results"></div>
7621
+ </main>
7622
+ <script>
7623
+ (function () {
7624
+ "use strict";
7625
+
7626
+ // ── encoding ────────────────────────────────────────────────────────────
7627
+ function b64uToBytes(text) {
7628
+ var base64 = text.replace(/-/g, "+").replace(/_/g, "/");
7629
+ var binary = atob(base64);
7630
+ var out = new Uint8Array(binary.length);
7631
+ for (var i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
7632
+ return out;
7633
+ }
7634
+ function hexToBytes(hex) {
7635
+ if (hex.length % 2 !== 0) throw new Error("malformed hex");
7636
+ var out = new Uint8Array(hex.length / 2);
7637
+ for (var i = 0; i < out.length; i++) {
7638
+ var byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
7639
+ if (Number.isNaN(byte)) throw new Error("malformed hex");
7640
+ out[i] = byte;
7641
+ }
7642
+ return out;
7643
+ }
7644
+ function bytesToHex(bytes) {
7645
+ var out = "";
7646
+ for (var i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
7647
+ return out;
7648
+ }
7649
+ function utf8(text) { return new TextEncoder().encode(text); }
7650
+ function fromUtf8(bytes) { return new TextDecoder().decode(bytes); }
7651
+
7652
+ // ── canonical JSON + digests (must match packages/core/src/archive.ts) ──
7653
+ function canonicalJson(value) {
7654
+ if (value === null || typeof value !== "object") {
7655
+ var scalar = JSON.stringify(value);
7656
+ return scalar === undefined ? "null" : scalar;
7657
+ }
7658
+ if (Array.isArray(value)) {
7659
+ return "[" + value.map(canonicalJson).join(",") + "]";
7660
+ }
7661
+ var keys = Object.keys(value).filter(function (key) { return value[key] !== undefined; });
7662
+ keys.sort();
7663
+ var parts = keys.map(function (key) {
7664
+ return JSON.stringify(key) + ":" + canonicalJson(value[key]);
7665
+ });
7666
+ return "{" + parts.join(",") + "}";
7667
+ }
7668
+ async function sha256Hex(text) {
7669
+ var digest = await crypto.subtle.digest("SHA-256", utf8(text));
7670
+ return "sha256:" + bytesToHex(new Uint8Array(digest));
7671
+ }
7672
+ function sectionCount(value) {
7673
+ if (value === null || value === undefined) return 0;
7674
+ return Array.isArray(value) ? value.length : 1;
7675
+ }
7676
+
7677
+ // ── the three blob formats ──────────────────────────────────────────────
7678
+ function splitBlob(blob, prefix, parts) {
7679
+ var pieces = String(blob).split(".");
7680
+ if (pieces.length !== parts + 1 || pieces[0] !== prefix) {
7681
+ throw new Error("not a " + prefix + ". blob");
7682
+ }
7683
+ return pieces.slice(1);
7684
+ }
7685
+
7686
+ /** pk1.<iterations>.<salt>.<iv>.<ciphertext> — PBKDF2-SHA256 + AES-256-GCM. */
7687
+ async function decryptPrivateKeyBlob(passphrase, blob) {
7688
+ var parts = splitBlob(blob, "pk1", 4);
7689
+ var iterations = parseInt(parts[0], 10);
7690
+ if (!Number.isFinite(iterations) || iterations < 1) throw new Error("bad iteration count");
7691
+ var material = await crypto.subtle.importKey("raw", utf8(passphrase), "PBKDF2", false, [
7692
+ "deriveKey",
7693
+ ]);
7694
+ var kek = await crypto.subtle.deriveKey(
7695
+ { name: "PBKDF2", hash: "SHA-256", salt: b64uToBytes(parts[1]), iterations: iterations },
7696
+ material,
7697
+ { name: "AES-GCM", length: 256 },
7698
+ false,
7699
+ ["decrypt"]
7700
+ );
7701
+ var plaintext = await crypto.subtle.decrypt(
7702
+ { name: "AES-GCM", iv: b64uToBytes(parts[2]) },
7703
+ kek,
7704
+ b64uToBytes(parts[3])
7705
+ );
7706
+ return fromUtf8(new Uint8Array(plaintext));
7707
+ }
7708
+
7709
+ /** wd1.<ephemeral pub>.<hkdf salt>.<iv>.<ciphertext> — ECDH P-256 + HKDF + AES-GCM. */
7710
+ async function unwrap(blob, privateKey) {
7711
+ var parts = splitBlob(blob, "wd1", 4);
7712
+ var ephemeral = await crypto.subtle.importKey(
7713
+ "raw",
7714
+ b64uToBytes(parts[0]),
7715
+ { name: "ECDH", namedCurve: "P-256" },
7716
+ false,
7717
+ []
7718
+ );
7719
+ var shared = await crypto.subtle.deriveBits(
7720
+ { name: "ECDH", public: ephemeral },
7721
+ privateKey,
7722
+ 256
7723
+ );
7724
+ var hkdf = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
7725
+ var wrappingKey = await crypto.subtle.deriveKey(
7726
+ {
7727
+ name: "HKDF",
7728
+ hash: "SHA-256",
7729
+ salt: b64uToBytes(parts[1]),
7730
+ info: utf8("seekrit/wrap-dek/v1"),
7731
+ },
7732
+ hkdf,
7733
+ { name: "AES-GCM", length: 256 },
7734
+ false,
7735
+ ["decrypt"]
7736
+ );
7737
+ var opened = await crypto.subtle.decrypt(
7738
+ { name: "AES-GCM", iv: b64uToBytes(parts[2]) },
7739
+ wrappingKey,
7740
+ b64uToBytes(parts[3])
7741
+ );
7742
+ return new Uint8Array(opened);
7743
+ }
7744
+
7745
+ /** sc1.<iv>.<ciphertext>, authenticated over "<environmentId>/<NAME>". */
7746
+ async function decryptSecret(dek, blob, aad) {
7747
+ var parts = splitBlob(blob, "sc1", 2);
7748
+ var key = await crypto.subtle.importKey("raw", dek, { name: "AES-GCM" }, false, ["decrypt"]);
7749
+ var plaintext = await crypto.subtle.decrypt(
7750
+ { name: "AES-GCM", iv: b64uToBytes(parts[0]), additionalData: utf8(aad) },
7751
+ key,
7752
+ b64uToBytes(parts[1])
7753
+ );
7754
+ return fromUtf8(new Uint8Array(plaintext));
7755
+ }
7756
+
7757
+ function importPrivateJwk(jwkText) {
7758
+ return crypto.subtle.importKey(
7759
+ "jwk",
7760
+ JSON.parse(jwkText),
7761
+ { name: "ECDH", namedCurve: "P-256" },
7762
+ true,
7763
+ ["deriveBits"]
7764
+ );
7765
+ }
7766
+
7767
+ /** skt_<id>_<pkcs8 base64url>: a service token carries its own private key. */
7768
+ async function importToken(token) {
7769
+ var match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(String(token).trim());
7770
+ if (!match) throw new Error("not a valid seekrit service token");
7771
+ var privateKey = await crypto.subtle.importKey(
7772
+ "pkcs8",
7773
+ b64uToBytes(match[2]),
7774
+ { name: "ECDH", namedCurve: "P-256" },
7775
+ true,
7776
+ ["deriveBits"]
7777
+ );
7778
+ return { principalId: match[1], privateKey: privateKey };
7779
+ }
7780
+
7781
+ // ── Shamir over GF(2^8) (must match packages/crypto/src/shamir.ts) ──────
7782
+ function gfMul(a, b) {
7783
+ var result = 0;
7784
+ var x = a;
7785
+ var y = b;
7786
+ for (var i = 0; i < 8; i++) {
7787
+ if (y & 1) result ^= x;
7788
+ var high = x & 0x80;
7789
+ x = (x << 1) & 0xff;
7790
+ if (high) x ^= 0x1b;
7791
+ y >>= 1;
7792
+ }
7793
+ return result & 0xff;
7794
+ }
7795
+ function gfPow(base, exponent) {
7796
+ var result = 1;
7797
+ for (var i = 0; i < exponent; i++) result = gfMul(result, base);
7798
+ return result;
7799
+ }
7800
+ function gfInv(value) {
7801
+ if (value === 0) throw new Error("no inverse for 0");
7802
+ return gfPow(value, 254);
7803
+ }
7804
+ /**
7805
+ * Lagrange interpolation at x=0 over the shares. Each share is
7806
+ * [x, y0, y1, ...]; the secret is the vector of y-intercepts.
7807
+ */
7808
+ function combineShares(shares) {
7809
+ if (shares.length === 0) throw new Error("no shares");
7810
+ var length = shares[0].length - 1;
7811
+ var out = new Uint8Array(length);
7812
+ for (var byte = 0; byte < length; byte++) {
7813
+ var acc = 0;
7814
+ for (var i = 0; i < shares.length; i++) {
7815
+ var xi = shares[i][0];
7816
+ var yi = shares[i][byte + 1];
7817
+ var numerator = 1;
7818
+ var denominator = 1;
7819
+ for (var j = 0; j < shares.length; j++) {
7820
+ if (i === j) continue;
7821
+ var xj = shares[j][0];
7822
+ numerator = gfMul(numerator, xj);
7823
+ denominator = gfMul(denominator, xi ^ xj);
7824
+ }
7825
+ acc ^= gfMul(yi, gfMul(numerator, gfInv(denominator)));
7826
+ }
7827
+ out[byte] = acc;
7828
+ }
7829
+ return out;
7830
+ }
7831
+
7832
+ // ── verification ────────────────────────────────────────────────────────
7833
+ async function verifyArchive(archive) {
7834
+ var problems = [];
7835
+ var truncated = [];
7836
+ var data = archive.data || {};
7837
+ var declared = {};
7838
+ for (var i = 0; i < archive.manifest.sections.length; i++) {
7839
+ var header = archive.manifest.sections[i];
7840
+ declared[header.name] = true;
7841
+ if (header.truncated) truncated.push(header.name);
7842
+ if (!(header.name in data)) {
7843
+ problems.push("section " + header.name + " is missing");
7844
+ continue;
7845
+ }
7846
+ var value = data[header.name] === undefined ? null : data[header.name];
7847
+ var digest = await sha256Hex(canonicalJson(value === undefined ? null : value));
7848
+ if (digest !== header.digest || sectionCount(value) !== header.count) {
7849
+ problems.push("section " + header.name + " does not match its digest");
7850
+ }
7851
+ }
7852
+ Object.keys(data).forEach(function (key) {
7853
+ if (!declared[key]) problems.push("section " + key + " is present but undeclared");
7854
+ });
7855
+ var manifestDigest = await sha256Hex(canonicalJson(archive.manifest.sections));
7856
+ if (manifestDigest !== archive.manifest.digest) {
7857
+ problems.push("the manifest digest does not cover its sections");
7858
+ }
7859
+
7860
+ var signature = "unsigned";
7861
+ var note = "";
7862
+ if (archive.signature) {
7863
+ try {
7864
+ var publicKey = await crypto.subtle.importKey(
7865
+ "raw",
7866
+ hexToBytes(archive.signature.publicKey),
7867
+ { name: "Ed25519" },
7868
+ false,
7869
+ ["verify"]
7870
+ );
7871
+ var valid = await crypto.subtle.verify(
7872
+ { name: "Ed25519" },
7873
+ publicKey,
7874
+ hexToBytes(archive.signature.value),
7875
+ utf8(canonicalJson(archive.manifest))
7876
+ );
7877
+ signature = valid ? "valid" : "invalid";
7878
+ if (!valid) problems.push("the signature does not match the manifest");
7879
+ } catch (err) {
7880
+ signature = "unverifiable";
7881
+ note = "this browser cannot check Ed25519 signatures";
7882
+ }
7883
+ }
7884
+ return {
7885
+ integrityOk: problems.length === 0,
7886
+ problems: problems,
7887
+ truncated: truncated,
7888
+ signature: signature,
7889
+ signatureNote: note,
7890
+ };
7891
+ }
7892
+
7893
+ // ── unlocking + decrypting ──────────────────────────────────────────────
7894
+ async function unlockPassphrase(archive, passphrase) {
7895
+ var keyMaterial = archive.data.keyMaterial;
7896
+ if (!keyMaterial) {
7897
+ throw new Error(
7898
+ "this archive carries no key material - use a service token, a private key, or a custodian quorum"
7899
+ );
7900
+ }
7901
+ var jwk = await decryptPrivateKeyBlob(passphrase, keyMaterial.encryptedPrivateKey);
7902
+ return {
7903
+ principalId: keyMaterial.principalId,
7904
+ privateKey: await importPrivateJwk(jwk),
7905
+ how: keyMaterial.principalType + " " + keyMaterial.principalId,
7906
+ };
7907
+ }
7908
+
7909
+ async function unlockJwk(jwkText) {
7910
+ return {
7911
+ principalId: null,
7912
+ privateKey: await importPrivateJwk(jwkText.trim()),
7913
+ how: "a private key",
7914
+ };
7915
+ }
7916
+
7917
+ async function unlockToken(token) {
7918
+ var opened = await importToken(token);
7919
+ return {
7920
+ principalId: opened.principalId,
7921
+ privateKey: opened.privateKey,
7922
+ how: "service token " + opened.principalId,
7923
+ };
7924
+ }
7925
+
7926
+ /**
7927
+ * Reconstruct the org recovery key from pasted share files. Accepts several
7928
+ * JSON objects one after another, which is what you get from concatenating
7929
+ * the output of "seekrit archive share".
7930
+ */
7931
+ async function unlockShares(archive, text) {
7932
+ var shares = [];
7933
+ var pattern = /"share"\\s*:\\s*"([0-9a-f]+)"/g;
7934
+ var match = pattern.exec(text);
7935
+ while (match !== null) {
7936
+ shares.push(hexToBytes(match[1]));
7937
+ match = pattern.exec(text);
7938
+ }
7939
+ if (shares.length === 0) throw new Error("no shares found in that text");
7940
+ var jwk = fromUtf8(combineShares(shares));
7941
+ return {
7942
+ principalId: archive.manifest.org.id,
7943
+ privateKey: await importPrivateJwk(jwk),
7944
+ how: shares.length + " custodian shares",
7945
+ };
7946
+ }
7947
+
7948
+ function envLabel(archive, env) {
7949
+ var i;
7950
+ if (env.groupId) {
7951
+ for (i = 0; i < archive.data.groups.length; i++) {
7952
+ if (archive.data.groups[i].id === env.groupId) {
7953
+ return archive.data.groups[i].slug + "@" + env.slug;
7954
+ }
7955
+ }
7956
+ return env.groupId + "@" + env.slug;
7957
+ }
7958
+ for (i = 0; i < archive.data.applications.length; i++) {
7959
+ if (archive.data.applications[i].id === env.applicationId) {
7960
+ return archive.data.applications[i].slug + "/" + env.slug;
7961
+ }
7962
+ }
7963
+ return env.applicationId + "/" + env.slug;
7964
+ }
7965
+
7966
+ async function decryptAll(archive, key) {
7967
+ var environments = [];
7968
+ var skipped = [];
7969
+ for (var e = 0; e < archive.data.environments.length; e++) {
7970
+ var env = archive.data.environments[e];
7971
+ var dek = null;
7972
+ for (var g = 0; g < archive.data.environmentKeys.length; g++) {
7973
+ var grant = archive.data.environmentKeys[g];
7974
+ if (grant.environmentId !== env.id) continue;
7975
+ if (key.principalId !== null && grant.principalId !== key.principalId) continue;
7976
+ try {
7977
+ dek = await unwrap(grant.wrappedDek, key.privateKey);
7978
+ break;
7979
+ } catch (err) {
7980
+ // Not this key's grant. A principal with no grant here is normal.
7981
+ }
7982
+ }
7983
+ if (dek === null) {
7984
+ skipped.push(envLabel(archive, env));
7985
+ continue;
7986
+ }
7987
+ var values = [];
7988
+ var failures = [];
7989
+ for (var s = 0; s < archive.data.secrets.length; s++) {
7990
+ var secret = archive.data.secrets[s];
7991
+ if (secret.environmentId !== env.id) continue;
7992
+ try {
7993
+ values.push({
7994
+ name: secret.name,
7995
+ value: await decryptSecret(
7996
+ dek,
7997
+ secret.ciphertext,
7998
+ secret.environmentId + "/" + secret.name
7999
+ ),
8000
+ });
8001
+ } catch (err) {
8002
+ failures.push(secret.name);
8003
+ }
8004
+ }
8005
+ values.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
8006
+ environments.push({ label: envLabel(archive, env), values: values, failures: failures });
8007
+ }
8008
+ return { environments: environments, skipped: skipped };
8009
+ }
8010
+
8011
+ /**
8012
+ * dotenv quoting, mirroring packages/core/src/dotenv.ts — single quotes when
8013
+ * they are safe (literal, so a JSON credential survives untouched), double
8014
+ * quotes with written-out escapes otherwise.
8015
+ */
8016
+ function needsQuoting(value) {
8017
+ return /[\\s"'\`$\\\\#]/.test(value) || value === "";
8018
+ }
8019
+ function dotenvQuote(value) {
8020
+ if (!needsQuoting(value)) return value;
8021
+ if (value.indexOf("'") === -1 && !/[\\n\\r]/.test(value)) return "'" + value + "'";
8022
+ return (
8023
+ '"' +
8024
+ value
8025
+ .replace(/\\\\/g, "\\\\\\\\")
8026
+ .replace(/"/g, '\\\\"')
8027
+ .replace(/\\n/g, "\\\\n")
8028
+ .replace(/\\r/g, "\\\\r") +
8029
+ '"'
8030
+ );
8031
+ }
8032
+ function toDotenv(values) {
8033
+ return values
8034
+ .map(function (entry) { return entry.name + "=" + dotenvQuote(entry.value); })
8035
+ .join("\\n");
8036
+ }
8037
+
8038
+ var api = {
8039
+ canonicalJson: canonicalJson,
8040
+ verifyArchive: verifyArchive,
8041
+ unlockPassphrase: unlockPassphrase,
8042
+ unlockToken: unlockToken,
8043
+ unlockJwk: unlockJwk,
8044
+ unlockShares: unlockShares,
8045
+ decryptAll: decryptAll,
8046
+ toDotenv: toDotenv,
8047
+ };
8048
+ globalThis.seekritOffline = api;
8049
+
8050
+ // Everything above is pure and runs headless; the test suite drives it through
8051
+ // globalThis.seekritOffline. Only what follows needs a document.
8052
+ if (typeof document === "undefined") return;
8053
+
8054
+ var archive = null;
8055
+ var el = function (id) { return document.getElementById(id); };
8056
+ var text = function (value) { return document.createTextNode(String(value)); };
8057
+
8058
+ function node(tag, className, content) {
8059
+ var element = document.createElement(tag);
8060
+ if (className) element.className = className;
8061
+ if (content !== undefined) element.appendChild(text(content));
8062
+ return element;
8063
+ }
8064
+
8065
+ function setStatus(message, kind) {
8066
+ var status = el("status");
8067
+ status.className = kind ? kind : "muted";
8068
+ status.textContent = message;
8069
+ }
8070
+
8071
+ async function loadArchive(fileText) {
8072
+ try {
8073
+ archive = JSON.parse(fileText);
8074
+ } catch (err) {
8075
+ archive = null;
8076
+ setStatus("that file is not JSON", "bad");
8077
+ return;
8078
+ }
8079
+ if (!archive || archive.format !== "seekrit-archive/v1" || !archive.manifest) {
8080
+ archive = null;
8081
+ el("manifest").className = "panel bad";
8082
+ el("manifest").textContent = "not a seekrit-archive/v1 file";
8083
+ return;
8084
+ }
8085
+ var check = await verifyArchive(archive);
8086
+ var panel = el("manifest");
8087
+ panel.className = "panel";
8088
+ panel.textContent = "";
8089
+
8090
+ var list = document.createElement("dl");
8091
+ list.className = "kv";
8092
+ var rows = [
8093
+ ["organization", archive.manifest.org.name + " (" + archive.manifest.org.slug + ")"],
8094
+ ["created", archive.manifest.createdAt],
8095
+ ["exported by", archive.manifest.requestedBy.label || archive.manifest.requestedBy.actorId],
8096
+ ["producer", archive.manifest.producer.service + " / " + archive.manifest.producer.environment],
8097
+ ["environments", String(sectionCount(archive.data.environments))],
8098
+ ["secrets", String(sectionCount(archive.data.secrets))],
8099
+ ["key grants", String(sectionCount(archive.data.environmentKeys))],
8100
+ ["integrity", check.integrityOk ? "every section matches its digest" : "FAILED"],
8101
+ [
8102
+ "signature",
8103
+ check.signature === "valid"
8104
+ ? "valid, key " + archive.signature.keyId
8105
+ : check.signature + (check.signatureNote ? " (" + check.signatureNote + ")" : ""),
8106
+ ],
8107
+ ];
8108
+ rows.forEach(function (row) {
8109
+ list.appendChild(node("dt", null, row[0]));
8110
+ var value = node("dd", null, row[1]);
8111
+ if (row[0] === "integrity") value.className = check.integrityOk ? "ok" : "bad";
8112
+ if (row[0] === "signature") {
8113
+ value.className =
8114
+ check.signature === "valid" ? "ok" : check.signature === "invalid" ? "bad" : "warn";
8115
+ }
8116
+ list.appendChild(value);
8117
+ });
8118
+ panel.appendChild(list);
8119
+
8120
+ if (check.problems.length > 0) {
8121
+ var problems = document.createElement("ul");
8122
+ check.problems.forEach(function (problem) {
8123
+ problems.appendChild(node("li", "bad", problem));
8124
+ });
8125
+ panel.appendChild(problems);
8126
+ }
8127
+ if (check.truncated.length > 0) {
8128
+ panel.appendChild(
8129
+ node("p", "warn", "truncated sections: " + check.truncated.join(", "))
8130
+ );
8131
+ }
8132
+ panel.classList.remove("hidden");
8133
+ el("step2").classList.remove("hidden");
8134
+ el("key-owner").textContent = archive.data.keyMaterial
8135
+ ? "unlocks " + archive.data.keyMaterial.principalType + " " + archive.data.keyMaterial.principalId
8136
+ : "this archive carries no key material - use another method";
8137
+ setStatus("");
8138
+ }
8139
+
8140
+ function showResults(result, key) {
8141
+ var container = el("results");
8142
+ container.textContent = "";
8143
+ container.appendChild(node("h2", null, "3 - plaintext"));
8144
+ container.appendChild(
8145
+ node("p", "muted", "unlocked with " + key.how + ". Values are as stored: a \${REF} reference is expanded when an app reads it, not here.")
8146
+ );
8147
+
8148
+ result.environments.forEach(function (entry) {
8149
+ var panel = node("div", "panel env");
8150
+ panel.appendChild(node("strong", null, entry.label));
8151
+ panel.appendChild(
8152
+ node("span", "muted", " " + entry.values.length + (entry.values.length === 1 ? " secret" : " secrets"))
8153
+ );
8154
+
8155
+ var table = document.createElement("table");
8156
+ var head = document.createElement("tr");
8157
+ head.appendChild(node("th", null, "name"));
8158
+ head.appendChild(node("th", null, "value"));
8159
+ table.appendChild(head);
8160
+ entry.values.forEach(function (item) {
8161
+ var row = document.createElement("tr");
8162
+ row.appendChild(node("td", null, item.name));
8163
+ var cell = node("td", "value");
8164
+ var masked = node("span", null, "•".repeat(Math.min(24, Math.max(6, item.value.length))));
8165
+ var reveal = node("button", null, "reveal");
8166
+ reveal.style.marginLeft = "0.5rem";
8167
+ reveal.addEventListener("click", function () {
8168
+ if (reveal.textContent === "reveal") {
8169
+ masked.textContent = item.value;
8170
+ reveal.textContent = "hide";
8171
+ } else {
8172
+ masked.textContent = "•".repeat(Math.min(24, Math.max(6, item.value.length)));
8173
+ reveal.textContent = "reveal";
8174
+ }
8175
+ });
8176
+ cell.appendChild(masked);
8177
+ cell.appendChild(reveal);
8178
+ row.appendChild(cell);
8179
+ table.appendChild(row);
8180
+ });
8181
+ panel.appendChild(table);
8182
+
8183
+ if (entry.failures.length > 0) {
8184
+ panel.appendChild(node("p", "bad", "failed to decrypt: " + entry.failures.join(", ")));
8185
+ }
8186
+
8187
+ var area = document.createElement("textarea");
8188
+ area.readOnly = true;
8189
+ area.spellcheck = false;
8190
+ area.className = "hidden";
8191
+ area.value = toDotenv(entry.values);
8192
+
8193
+ var show = node("button", null, "show as .env");
8194
+ show.addEventListener("click", function () {
8195
+ area.classList.toggle("hidden");
8196
+ show.textContent = area.classList.contains("hidden") ? "show as .env" : "hide .env";
8197
+ });
8198
+ var copy = node("button", null, "copy .env");
8199
+ copy.addEventListener("click", function () {
8200
+ area.classList.remove("hidden");
8201
+ area.select();
8202
+ try {
8203
+ document.execCommand("copy");
8204
+ copy.textContent = "copied";
8205
+ } catch (err) {
8206
+ copy.textContent = "select and copy";
8207
+ }
8208
+ });
8209
+ var actions = node("div", "row");
8210
+ actions.style.marginTop = "0.75rem";
8211
+ actions.appendChild(show);
8212
+ actions.appendChild(copy);
8213
+ panel.appendChild(actions);
8214
+ panel.appendChild(area);
8215
+ container.appendChild(panel);
8216
+ });
8217
+
8218
+ if (result.skipped.length > 0) {
8219
+ container.appendChild(
8220
+ node(
8221
+ "p",
8222
+ "muted",
8223
+ "skipped " + result.skipped.length +
8224
+ (result.skipped.length === 1 ? " environment" : " environments") +
8225
+ " this key holds no grant on: " + result.skipped.join(", ")
8226
+ )
8227
+ );
8228
+ }
8229
+ if (result.environments.length === 0) {
8230
+ container.appendChild(
8231
+ node("p", "bad", "this key opens none of the environments in the archive")
8232
+ );
8233
+ }
8234
+ }
8235
+
8236
+ el("file").addEventListener("change", function (event) {
8237
+ var file = event.target.files && event.target.files[0];
8238
+ if (!file) return;
8239
+ var reader = new FileReader();
8240
+ reader.onload = function () { loadArchive(String(reader.result)); };
8241
+ reader.readAsText(file);
8242
+ });
8243
+
8244
+ var drop = el("drop");
8245
+ ["dragenter", "dragover"].forEach(function (name) {
8246
+ drop.addEventListener(name, function (event) {
8247
+ event.preventDefault();
8248
+ drop.classList.add("over");
8249
+ });
8250
+ });
8251
+ ["dragleave", "drop"].forEach(function (name) {
8252
+ drop.addEventListener(name, function (event) {
8253
+ event.preventDefault();
8254
+ drop.classList.remove("over");
8255
+ });
8256
+ });
8257
+ drop.addEventListener("drop", function (event) {
8258
+ var file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
8259
+ if (!file) return;
8260
+ var reader = new FileReader();
8261
+ reader.onload = function () { loadArchive(String(reader.result)); };
8262
+ reader.readAsText(file);
8263
+ });
8264
+
8265
+ el("method").addEventListener("change", function () {
8266
+ var method = el("method").value;
8267
+ ["passphrase", "token", "jwk", "shares"].forEach(function (name) {
8268
+ el("field-" + name).classList.toggle("hidden", name !== method);
8269
+ });
8270
+ });
8271
+
8272
+ el("decrypt").addEventListener("click", async function () {
8273
+ if (!archive) {
8274
+ setStatus("load an archive first", "warn");
8275
+ return;
8276
+ }
8277
+ setStatus("working...");
8278
+ try {
8279
+ var method = el("method").value;
8280
+ var key;
8281
+ if (method === "passphrase") key = await unlockPassphrase(archive, el("passphrase").value);
8282
+ else if (method === "token") key = await unlockToken(el("token").value);
8283
+ else if (method === "jwk") key = await unlockJwk(el("jwk").value);
8284
+ else key = await unlockShares(archive, el("shares").value);
8285
+ var result = await decryptAll(archive, key);
8286
+ showResults(result, key);
8287
+ setStatus(
8288
+ "decrypted " + result.environments.length +
8289
+ (result.environments.length === 1 ? " environment" : " environments"),
8290
+ "ok"
8291
+ );
8292
+ } catch (err) {
8293
+ setStatus(err && err.message ? err.message : "could not decrypt", "bad");
8294
+ }
8295
+ });
8296
+ })();
8297
+ <\/script>
8298
+ </body>
8299
+ </html>
8300
+ `;
8301
+ //#endregion
8302
+ //#region src/format.ts
8303
+ function shellQuote(value) {
8304
+ return `'${value.replaceAll("'", `'\\''`)}'`;
8305
+ }
8306
+ function formatSecrets(values, format) {
8307
+ const names = Object.keys(values).sort();
8308
+ switch (format) {
8309
+ case "json": return JSON.stringify(values, names, 2);
8310
+ case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
8311
+ case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
8312
+ }
8313
+ }
8314
+ //#endregion
8315
+ //#region src/archive.ts
8316
+ /**
8317
+ * Break-glass archives (docs/break-glass-export.md).
8318
+ *
8319
+ * `create` is the only subcommand that talks to the API. `info`, `verify`,
8320
+ * `share`, `decrypt`, and `decryptor` are **strictly offline**: they never build
8321
+ * a client, never read credentials, and never touch the network, because the day
8322
+ * you need them is the day seekrit may not be there. Keep it that way — a
8323
+ * `buildContext()` in any of them silently breaks the promise the feature makes.
8324
+ */
8325
+ /** Collect a repeatable option into a list. */
8326
+ function collect$5(value, acc = []) {
8327
+ acc.push(value);
8328
+ return acc;
8329
+ }
8330
+ /** "1 secret" / "2 secrets" — these lines are read by people, not parsed. */
8331
+ function count(n, noun) {
8332
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
8333
+ }
8334
+ function readArchiveFile(path) {
8335
+ let text;
8336
+ try {
8337
+ text = readFileSync(path, "utf8");
8338
+ } catch (err) {
8339
+ return fail(`cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
8340
+ }
8341
+ try {
8342
+ return parseArchive(text);
8343
+ } catch (err) {
8344
+ return fail(err instanceof Error ? err.message : String(err));
8345
+ }
8346
+ }
8347
+ function writeOut(path, contents) {
8348
+ mkdirSync(dirname(path), { recursive: true });
8349
+ writeFileSync(path, contents, { mode: 384 });
8350
+ }
8351
+ /** One line per problem, for a verification that failed. */
8352
+ function verificationProblems(result) {
8353
+ const problems = [];
8354
+ if (!result.manifestDigestOk) problems.push("the manifest's digest does not cover its sections");
8355
+ for (const name of result.badSections) problems.push(`section "${name}" does not match its digest`);
8356
+ for (const name of result.missingSections) problems.push(`section "${name}" is missing`);
8357
+ for (const name of result.undeclaredSections) problems.push(`section "${name}" is present but undeclared`);
8358
+ if (result.signature === "invalid") problems.push(`signature is invalid${result.signatureNote ? ` (${result.signatureNote})` : ""}`);
8359
+ if (result.signature === "unsigned") problems.push("archive is unsigned — integrity is checkable, provenance is not");
8360
+ if (result.signature === "unverifiable") problems.push(`signature could not be checked: ${result.signatureNote ?? "unknown reason"}`);
8361
+ return problems;
8362
+ }
8363
+ function printVerification(result) {
8364
+ printFields([
8365
+ ["integrity", result.badSections.length === 0 && result.manifestDigestOk ? "ok" : "FAILED"],
8366
+ ["signature", result.signature],
8367
+ ["truncated", result.truncatedSections.length > 0 ? result.truncatedSections.join(", ") : "no"]
8368
+ ]);
8369
+ const problems = verificationProblems(result);
8370
+ if (problems.length > 0) {
8371
+ section("problems");
8372
+ for (const problem of problems) console.log(`- ${problem}`);
8373
+ }
8374
+ }
8375
+ /** `app/env`, or `group@env` for a group-owned environment. */
8376
+ function envLabel(archive, env) {
8377
+ if (env.groupId) return `${archive.data.groups.find((g) => g.id === env.groupId)?.slug ?? env.groupId}@${env.slug}`;
8378
+ return `${archive.data.applications.find((a) => a.id === env.applicationId)?.slug ?? env.applicationId}/${env.slug}`;
8379
+ }
8380
+ /** Filesystem path for an environment's output file, mirroring its label. */
8381
+ function envPath(archive, env, extension) {
8382
+ if (env.groupId) return join("groups", archive.data.groups.find((g) => g.id === env.groupId)?.slug ?? env.groupId, `${env.slug}.${extension}`);
8383
+ return join("apps", archive.data.applications.find((a) => a.id === env.applicationId)?.slug ?? env.applicationId ?? "unknown", `${env.slug}.${extension}`);
8384
+ }
8385
+ /**
8386
+ * Recover a private key to open the archive with, from (in order) a service
8387
+ * token, a private-key JWK file, a custodian quorum, or the archive's own
8388
+ * `keyMaterial` plus a passphrase.
8389
+ */
8390
+ async function resolveDecryptKey(archive, options) {
8391
+ if (options.token) {
8392
+ if (!isServiceToken(options.token)) fail("--token expects a `skt_…` service token");
8393
+ const { tokenId, privateKey } = await parseServiceToken(options.token);
8394
+ return {
8395
+ privateKey,
8396
+ principalId: tokenId,
8397
+ how: `service token ${tokenId}`
8398
+ };
8399
+ }
8400
+ if (options.keyFile) {
8401
+ let jwk;
8402
+ try {
8403
+ jwk = readFileSync(options.keyFile, "utf8").trim();
8404
+ } catch (err) {
8405
+ fail(`cannot read ${options.keyFile}: ${err instanceof Error ? err.message : String(err)}`);
8406
+ }
8407
+ return {
8408
+ privateKey: await importPrivateKey(jwk).catch(() => fail(`${options.keyFile} is not a P-256 private key JWK`)),
8409
+ principalId: null,
8410
+ how: `private key from ${options.keyFile}`
8411
+ };
8412
+ }
8413
+ if (options.share && options.share.length > 0) {
8414
+ const shares = options.share.map((path) => readShareFile(path));
8415
+ return {
8416
+ privateKey: await combineRecoveryShares(shares).catch((err) => fail(`could not reconstruct the recovery key from ${count(shares.length, "share")}: ${err instanceof Error ? err.message : String(err)}`)),
8417
+ principalId: archive.manifest.org.id,
8418
+ how: count(shares.length, "custodian share")
8419
+ };
8420
+ }
8421
+ const keyMaterial = archive.data.keyMaterial;
8422
+ if (!keyMaterial) fail("this archive carries no key material — decrypt it with --token, --key-file, or a custodian quorum (--share)");
8423
+ return {
8424
+ privateKey: await importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden(`Passphrase for ${keyMaterial.principalId}: `), keyMaterial.encryptedPrivateKey).catch((err) => fail(err instanceof Error ? err.message : String(err)))),
8425
+ principalId: keyMaterial.principalId,
8426
+ how: `${keyMaterial.principalType} ${keyMaterial.principalId} (passphrase)`
8427
+ };
8428
+ }
8429
+ const SHARE_FORMAT = "seekrit-recovery-share/v1";
8430
+ function readShareFile(path) {
8431
+ let parsed;
8432
+ try {
8433
+ parsed = JSON.parse(readFileSync(path, "utf8"));
8434
+ } catch (err) {
8435
+ return fail(`cannot read share ${path}: ${err instanceof Error ? err.message : String(err)}`);
8436
+ }
8437
+ if (parsed.format !== SHARE_FORMAT || typeof parsed.share !== "string") return fail(`${path} is not a ${SHARE_FORMAT} file`);
8438
+ try {
8439
+ return hexToBytes(parsed.share);
8440
+ } catch {
8441
+ return fail(`${path} holds a malformed share`);
8442
+ }
8443
+ }
8444
+ /**
8445
+ * Decrypt what one key can reach. Environments the key holds no grant on are
8446
+ * *skipped*, not failed: an archive spans the whole org and no single principal
8447
+ * is expected to open all of it.
8448
+ */
8449
+ async function decryptArchive(archive, key, filter) {
8450
+ const environments = [];
8451
+ const skipped = [];
8452
+ const failures = [];
8453
+ for (const env of archive.data.environments) {
8454
+ const label = envLabel(archive, env);
8455
+ if (filter && label !== filter && env.slug !== filter && env.id !== filter) continue;
8456
+ const grants = archive.data.environmentKeys.filter((grant) => grant.environmentId === env.id && (key.principalId === null || grant.principalId === key.principalId));
8457
+ let dek = null;
8458
+ for (const grant of grants) try {
8459
+ dek = await unwrapDek(grant.wrappedDek, key.privateKey);
8460
+ break;
8461
+ } catch {}
8462
+ if (!dek) {
8463
+ skipped.push(label);
8464
+ continue;
8465
+ }
8466
+ const values = {};
8467
+ for (const secret of archive.data.secrets.filter((s) => s.environmentId === env.id)) try {
8468
+ values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(secret.environmentId, secret.name));
8469
+ } catch (err) {
8470
+ failures.push(`${label} ${secret.name}: ${err instanceof Error ? err.message : "failed"}`);
8471
+ }
8472
+ environments.push({
8473
+ env,
8474
+ label,
8475
+ values
8476
+ });
8477
+ }
8478
+ return {
8479
+ environments,
8480
+ skipped,
8481
+ failures
8482
+ };
8483
+ }
8484
+ function registerArchiveCommands(program) {
8485
+ const archive = program.command("archive").description("export the whole org as one signed file, and open it offline");
8486
+ archive.command("create").description("download a signed archive of everything seekrit stores for the org").option("--org <slug>").option("-o, --out <file>", "write the archive here (default: ./seekrit-<org>-<date>.json)").option("--no-versions", "omit each secret's ciphertext history").option("--no-audit", "omit the audit trail").option("--audit-limit <n>", "keep at most this many of the newest audit rows", Number).option("--json", "print the archive to stdout instead of writing a file").action(async (options) => {
8487
+ const ctx = buildContext();
8488
+ const ref = await resolveOrg(ctx, options.org);
8489
+ const input = {
8490
+ includeVersions: options.versions,
8491
+ includeAudit: options.audit
8492
+ };
8493
+ if (options.auditLimit !== void 0) input.auditLimit = options.auditLimit;
8494
+ const result = await ctx.client.exportArchive(ref.id, input);
8495
+ if (options.json) {
8496
+ console.log(JSON.stringify(result, null, 2));
8497
+ return;
8498
+ }
8499
+ const stamp = result.manifest.createdAt.slice(0, 10);
8500
+ const path = options.out ?? `seekrit-${result.manifest.org.slug}-${stamp}.json`;
8501
+ writeOut(path, JSON.stringify(result, null, 2));
8502
+ const check = await verifyArchive(result);
8503
+ console.error(`wrote ${path}`);
8504
+ printTable(result.manifest.sections.filter((s) => s.count > 0), [
8505
+ {
8506
+ header: "section",
8507
+ value: (s) => s.name
8508
+ },
8509
+ {
8510
+ header: "rows",
8511
+ value: (s) => String(s.count)
8512
+ },
8513
+ {
8514
+ header: "truncated",
8515
+ value: (s) => s.truncated ? "yes" : ""
8516
+ }
8517
+ ], "the archive is empty");
8518
+ console.error(check.ok ? `verified: digests ok, signed by key ${result.signature?.keyId}` : `WARNING: ${verificationProblems(check).join("; ")}`);
8519
+ console.error("keep it with `seekrit archive decryptor` — together they open without seekrit, offline.");
8520
+ });
8521
+ archive.command("info <file>").description("summarize an archive (offline)").option("--json", "print the manifest as JSON").action(async (file, options) => {
8522
+ const parsed = readArchiveFile(file);
8523
+ const check = await verifyArchive(parsed);
8524
+ emit(options, {
8525
+ manifest: parsed.manifest,
8526
+ verification: check
8527
+ }, () => {
8528
+ printFields([
8529
+ ["archive", parsed.manifest.archiveId],
8530
+ ["created", parsed.manifest.createdAt],
8531
+ ["org", `${parsed.manifest.org.name} (${parsed.manifest.org.slug})`],
8532
+ ["producer", `${parsed.manifest.producer.service} / ${parsed.manifest.producer.environment}`],
8533
+ ["format", parsed.manifest.producer.formatVersion],
8534
+ ["requested by", parsed.manifest.requestedBy.label ?? parsed.manifest.requestedBy.actorId],
8535
+ ["key material", parsed.data.keyMaterial ? parsed.data.keyMaterial.principalId : "none"],
8536
+ ["signature", parsed.signature ? `${parsed.signature.algorithm} / ${parsed.signature.keyId}` : "unsigned"]
8537
+ ]);
8538
+ section("sections");
8539
+ printTable(parsed.manifest.sections, [
8540
+ {
8541
+ header: "section",
8542
+ value: (s) => s.name
8543
+ },
8544
+ {
8545
+ header: "rows",
8546
+ value: (s) => String(s.count)
8547
+ },
8548
+ {
8549
+ header: "truncated",
8550
+ value: (s) => s.truncated ? "yes" : ""
8551
+ }
8552
+ ], "none");
8553
+ section("verification");
8554
+ printVerification(check);
8555
+ });
8556
+ });
8557
+ archive.command("verify <file>").description("check an archive's digests and signature (offline); exits non-zero if it fails").option("--key-id <id>", "require this signing key id (see /.well-known/seekrit-export-signing-key)").option("--skip-signature", "check digests only").option("--json", "print the verification result as JSON").action(async (file, options) => {
8558
+ const check = await verifyArchive(readArchiveFile(file), {
8559
+ expectKeyId: options.keyId,
8560
+ skipSignature: options.skipSignature
8561
+ });
8562
+ const integrityOk = check.badSections.length === 0 && check.missingSections.length === 0 && check.undeclaredSections.length === 0 && check.manifestDigestOk;
8563
+ const passed = options.skipSignature ? integrityOk : check.ok;
8564
+ emit(options, check, () => printVerification(check));
8565
+ if (!passed) process.exit(1);
8566
+ });
8567
+ archive.command("share <file>").description("unwrap your own recovery share from an archive, for an offline quorum (offline)").option("-o, --out <file>", "write the share here (default: stdout)").option("--token <skt_…>", "unwrap with a service token instead of a passphrase").option("--key-file <path>", "unwrap with a private key JWK file").action(async (file, options) => {
8568
+ const parsed = readArchiveFile(file);
8569
+ if (parsed.data.recoveryShares.length === 0) fail("this archive holds no recovery shares — customer-controlled recovery is not set up");
8570
+ const key = await resolveDecryptKey(parsed, {
8571
+ token: options.token,
8572
+ keyFile: options.keyFile
8573
+ });
8574
+ const candidates = parsed.data.recoveryShares.filter((share) => key.principalId === null || share.custodianId === key.principalId);
8575
+ for (const candidate of candidates) try {
8576
+ const bytes = await unwrapRecoveryShare(candidate.wrappedShare, key.privateKey);
8577
+ const payload = {
8578
+ format: SHARE_FORMAT,
8579
+ org: {
8580
+ id: parsed.manifest.org.id,
8581
+ slug: parsed.manifest.org.slug
8582
+ },
8583
+ custodianType: candidate.custodianType,
8584
+ custodianId: candidate.custodianId,
8585
+ shareIndex: candidate.shareIndex,
8586
+ share: bytesToHex(bytes),
8587
+ note: "Sensitive: a threshold of these shares reconstructs the org recovery key, which opens every environment. Destroy this file after the ceremony."
8588
+ };
8589
+ const text = JSON.stringify(payload, null, 2);
8590
+ if (options.out) {
8591
+ writeOut(options.out, text);
8592
+ console.error(`wrote share ${candidate.shareIndex} to ${options.out}`);
8593
+ } else console.log(text);
8594
+ console.error(`combine ${parsed.data.recoveryConfig?.threshold ?? "M"} shares with: seekrit archive decrypt ${file} --share … --share …`);
8595
+ return;
8596
+ } catch {}
8597
+ fail("none of the recovery shares in this archive unwrap with that key");
8598
+ });
8599
+ archive.command("decrypt <file>").description("decrypt an archive's secrets with your own key (offline)").option("-o, --out <dir>", "write one file per environment into this directory").option("--stdout", "print plaintext to stdout instead of writing files").option("--env <label>", "only this environment (app/env, group@env, slug, or id)").option("--format <format>", "dotenv | json | shell", "dotenv").option("--token <skt_…>", "decrypt as a service token instead of a passphrase").option("--key-file <path>", "decrypt with a private key JWK file").option("--share <file>", "custodian share for an offline quorum (repeatable)", collect$5).option("--yes", "skip the confirmation when printing plaintext to stdout").action(async (file, options) => {
8600
+ if (!options.out && !options.stdout) fail("choose a destination: --out <dir> to write files, or --stdout to print plaintext");
8601
+ if (![
8602
+ "dotenv",
8603
+ "json",
8604
+ "shell"
8605
+ ].includes(options.format)) fail(`unknown --format "${options.format}" (dotenv | json | shell)`);
8606
+ const parsed = readArchiveFile(file);
8607
+ const check = await verifyArchive(parsed);
8608
+ if (check.badSections.length > 0 || !check.manifestDigestOk) fail(`refusing to decrypt: ${verificationProblems(check).join("; ")} — run \`seekrit archive verify ${file}\``);
8609
+ if (check.signature !== "valid") console.error(`warning: ${verificationProblems(check).join("; ")}`);
8610
+ if (options.stdout) await confirmDestructive(options.yes, "This prints decrypted secret values to stdout, where they may land in scrollback or CI logs. Continue?");
8611
+ const key = await resolveDecryptKey(parsed, options);
8612
+ const result = await decryptArchive(parsed, key, options.env);
8613
+ console.error(`unlocked with ${key.how}`);
8614
+ const extension = options.format === "json" ? "json" : options.format === "shell" ? "sh" : "env";
8615
+ let written = 0;
8616
+ for (const entry of result.environments) {
8617
+ const body = formatSecrets(entry.values, options.format);
8618
+ if (options.out) {
8619
+ const path = join(options.out, envPath(parsed, entry.env, extension));
8620
+ writeOut(path, `${body}\n`);
8621
+ written += 1;
8622
+ console.error(`${path} (${count(Object.keys(entry.values).length, "secret")})`);
8623
+ } else {
8624
+ console.log(`# ${entry.label}`);
8625
+ console.log(body);
8626
+ console.log("");
8627
+ }
8628
+ }
8629
+ if (result.skipped.length > 0) console.error(`skipped ${count(result.skipped.length, "environment")} this key holds no grant on: ${result.skipped.join(", ")}`);
8630
+ for (const failure of result.failures) console.error(`failed: ${failure}`);
8631
+ if (options.out) console.error(`wrote ${count(written, "file")} under ${options.out}`);
8632
+ if (result.environments.length === 0) fail("nothing decrypted — this key opens none of the environments in the archive");
8633
+ console.error(`note: values are as stored — \`\${REF}\` references are expanded at read time, not here.`);
8634
+ });
8635
+ archive.command("decryptor").description("write the standalone offline decryptor (a single HTML file, no install, no network)").option("-o, --out <file>", "where to write it", "seekrit-decrypt.html").action((options) => {
8636
+ writeOut(options.out, OFFLINE_DECRYPTOR_HTML);
8637
+ console.error(`wrote ${options.out}`);
8638
+ console.error("open it in any browser — it declares a Content-Security-Policy of `default-src 'none'`, so it cannot reach the network.");
8639
+ });
8640
+ }
8641
+ //#endregion
7198
8642
  //#region src/audit.ts
7199
8643
  /** The API's per-page ceiling (`auditQuerySchema.limit`). */
7200
8644
  const MAX_PAGE = 200;
@@ -7721,19 +9165,6 @@ function message(err) {
7721
9165
  return err instanceof Error ? err.message : String(err);
7722
9166
  }
7723
9167
  //#endregion
7724
- //#region src/format.ts
7725
- function shellQuote(value) {
7726
- return `'${value.replaceAll("'", `'\\''`)}'`;
7727
- }
7728
- function formatSecrets(values, format) {
7729
- const names = Object.keys(values).sort();
7730
- switch (format) {
7731
- case "json": return JSON.stringify(values, names, 2);
7732
- case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
7733
- case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
7734
- }
7735
- }
7736
- //#endregion
7737
9168
  //#region src/gcp.ts
7738
9169
  /**
7739
9170
  * `seekrit gcp` — temporary GCP credentials via IAM Credentials
@@ -8645,348 +10076,15 @@ function registerOrgCommands(program) {
8645
10076
  });
8646
10077
  }
8647
10078
  //#endregion
8648
- //#region src/pg.ts
10079
+ //#region src/proxy-presets.ts
10080
+ /** `Authorization: Bearer {{seekrit:NAME}}` — the shape most providers take. */
10081
+ function placeholder(secret) {
10082
+ return `{{seekrit:${secret}}}`;
10083
+ }
8649
10084
  /**
8650
- * `seekrit pg` temporary Postgres credentials (Vault-style dynamic secrets).
8651
- *
8652
- * Zero-knowledge: minting generates the password and its SCRAM verifier on THIS
8653
- * machine and sends only the verifier; the plaintext password never reaches the
8654
- * API or gets stored. Registering a target wraps the admin connection string to
8655
- * the broker's public key locally, so the control plane only ever stores
8656
- * ciphertext.
8657
- */
8658
- /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
8659
- function parseTtlSeconds$2(input) {
8660
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
8661
- if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
8662
- return Number(m[1]) * ({
8663
- s: 1,
8664
- m: 60,
8665
- h: 3600,
8666
- d: 86400
8667
- }[m[2] || "s"] ?? 1);
8668
- }
8669
- /** A fresh, valid Postgres role name: `tmp_` + lowercase alphanumerics. */
8670
- function generateRoleName(prefix = "tmp") {
8671
- const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
8672
- let out = "";
8673
- const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
8674
- for (const b of bytes) out += alphabet[b % 36];
8675
- return `${prefix}_${out}`;
8676
- }
8677
- function registerPgCommands(program) {
8678
- const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
8679
- const target = pg.command("target").description("manage provisioning targets");
8680
- 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) => {
8681
- const ctx = buildContext();
8682
- const org = await resolveOrg(ctx, options.org);
8683
- const executor = options.executor === "remote" ? "remote" : "in_do";
8684
- if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
8685
- if (![
8686
- "readonly",
8687
- "readwrite",
8688
- "custom"
8689
- ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
8690
- const accessLevel = options.access;
8691
- const adminSecret = resolveLeaseAdminSecret({
8692
- executor,
8693
- hmacKey: options.hmacKey,
8694
- adminUrl: options.adminUrl,
8695
- adminUrlEnv: "SEEKRIT_PG_ADMIN_URL"
8696
- });
8697
- const config = {
8698
- provider: "postgres",
8699
- executor,
8700
- accessLevel,
8701
- connection: {
8702
- host: options.host,
8703
- port: Number.parseInt(options.port, 10),
8704
- database: options.database
8705
- },
8706
- ...accessLevel === "custom" ? {
8707
- ...options.createStatement.length ? { createStatements: options.createStatement } : {},
8708
- ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
8709
- } : { schema: options.schema },
8710
- ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
8711
- };
8712
- const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
8713
- const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
8714
- const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
8715
- name: options.name,
8716
- config,
8717
- wrappedAdminSecret
8718
- });
8719
- console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
8720
- const bootstrap = postgresGroupBootstrapSql(config);
8721
- if (bootstrap) {
8722
- console.error("\nRun this once in your database as an admin (safe to re-run):\n");
8723
- console.log(bootstrap);
8724
- }
8725
- });
8726
- target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
8727
- const ctx = buildContext();
8728
- const org = await resolveOrg(ctx, options.org);
8729
- const { targets } = await ctx.client.listLeaseTargets(org.id);
8730
- for (const t of targets) {
8731
- const cfg = t.config;
8732
- if (cfg.provider !== "postgres") continue;
8733
- console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
8734
- }
8735
- });
8736
- target.command("setup-sql <targetId>").description("print the one-time group-role setup SQL for a preset target").option("--org <slug>").action(async (targetId, options) => {
8737
- const ctx = buildContext();
8738
- const org = await resolveOrg(ctx, options.org);
8739
- const { targets } = await ctx.client.listLeaseTargets(org.id);
8740
- const t = targets.find((x) => x.id === targetId || x.name === targetId);
8741
- if (!t) fail(`no target "${targetId}" in ${org.slug}`);
8742
- const cfg = t.config;
8743
- if (cfg.provider !== "postgres") fail("not a postgres target (see `seekrit ssh`)");
8744
- const bootstrap = postgresGroupBootstrapSql(cfg);
8745
- if (!bootstrap) fail("this is a custom target — it has no generated setup SQL");
8746
- console.log(bootstrap);
8747
- });
8748
- target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
8749
- const ctx = buildContext();
8750
- const org = await resolveOrg(ctx, options.org);
8751
- await ctx.client.deleteLeaseTarget(org.id, targetId);
8752
- console.error(`removed ${targetId}`);
8753
- });
8754
- pg.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--role <name>", "role 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) => {
8755
- const ctx = buildContext();
8756
- const org = await resolveOrg(ctx, options.org);
8757
- const { targets } = await ctx.client.listLeaseTargets(org.id);
8758
- const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
8759
- if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
8760
- if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
8761
- const roleName = options.role ?? generateRoleName();
8762
- const ttlSeconds = parseTtlSeconds$2(options.ttl);
8763
- const { password, verifier } = await generatePostgresCredential();
8764
- const { connection } = await ctx.client.mintLease(org.id, {
8765
- provider: "postgres",
8766
- targetId: target.id,
8767
- roleName,
8768
- verifier,
8769
- ttlSeconds
8770
- });
8771
- const url = `postgres://${roleName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
8772
- console.error(`leased ${roleName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
8773
- if (options.json) console.log(JSON.stringify({
8774
- ...connection,
8775
- password,
8776
- url
8777
- }, null, 2));
8778
- else console.log(url);
8779
- });
8780
- pg.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
8781
- const ctx = buildContext();
8782
- const org = await resolveOrg(ctx, options.org);
8783
- const { leases } = await ctx.client.listLeases(org.id);
8784
- for (const l of leases) console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
8785
- });
8786
- pg.command("revoke <leaseId>").description("revoke a lease now (drops the role immediately)").option("--org <slug>").action(async (leaseId, options) => {
8787
- const ctx = buildContext();
8788
- const org = await resolveOrg(ctx, options.org);
8789
- await ctx.client.revokeLease(org.id, leaseId);
8790
- console.error(`revoked ${leaseId}`);
8791
- });
8792
- }
8793
- /** Collect a repeatable option into an array. */
8794
- function collect$2(value, acc) {
8795
- acc.push(value);
8796
- return acc;
8797
- }
8798
- //#endregion
8799
- //#region src/proxy-binary.ts
8800
- /**
8801
- * Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
8802
- *
8803
- * The proxy is the strongest answer seekrit has for an untrusted workload — the
8804
- * agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
8805
- * thing here to *try*, because trying it meant `cargo` and a TOML file. This
8806
- * module removes the first half: it resolves a prebuilt, checksum-verified
8807
- * binary for the host platform and execs it, so `npx @seekrit/proxy` and
8808
- * `seekrit proxy run` behave like the proxy was already installed.
8809
- *
8810
- * The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
8811
- * for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
8812
- * entrypoint over it, and the two must not drift.
8813
- *
8814
- * Three properties worth stating, since this downloads and executes code:
8815
- *
8816
- * - **The checksum is verified before anything is executed**, against a
8817
- * `.sha256` fetched from the same release. That is integrity, not provenance —
8818
- * it proves the bytes match what the release published, which is exactly the
8819
- * guarantee `install.sh` gives and no more.
8820
- * - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
8821
- * short-circuits entirely, and a cached download for the same version+target is
8822
- * reused, so this is a one-time cost per version.
8823
- * - **Version is pinned, not floating.** A default of `latest` would make two
8824
- * machines run different proxies from the same command; the pinned constant is
8825
- * what this CLI was built against, overridable when you want otherwise.
8826
- */
8827
- /**
8828
- * The proxy version this CLI was built against.
8829
- *
8830
- * Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
8831
- * release-please-config.json), so the pin follows the crate without anyone
8832
- * remembering to move it.
8833
- */
8834
- const PROXY_VERSION = "0.10.0";
8835
- const BIN = "seekrit-proxy";
8836
- /**
8837
- * Host → Rust target triple.
8838
- *
8839
- * Linux always resolves to **musl**: that build is statically linked, so one
8840
- * artifact covers glibc, musl, alpine, and distroless, and there is no libc
8841
- * detection to get wrong on a machine where `ldd` says something unexpected.
8842
- */
8843
- function detectTarget(os = platform(), cpu = arch()) {
8844
- const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
8845
- if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
8846
- switch (os) {
8847
- case "linux": return {
8848
- target: `${machine}-unknown-linux-musl`,
8849
- exe: ""
8850
- };
8851
- case "darwin": return {
8852
- target: `${machine}-apple-darwin`,
8853
- exe: ""
8854
- };
8855
- case "win32":
8856
- if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
8857
- return {
8858
- target: "x86_64-pc-windows-msvc",
8859
- exe: ".exe"
8860
- };
8861
- default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
8862
- }
8863
- }
8864
- /** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
8865
- function versionPrefix(version) {
8866
- if (version === "latest") return "latest";
8867
- return version.startsWith("v") ? version : `v${version}`;
8868
- }
8869
- function resolveVersion(explicit) {
8870
- return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
8871
- }
8872
- function resolveBaseUrl(explicit) {
8873
- return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
8874
- }
8875
- /** Where a resolved binary is kept, keyed so versions and targets never collide. */
8876
- function proxyBinaryPath(version, target, exe) {
8877
- return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
8878
- }
8879
- async function fetchBytes(url) {
8880
- const res = await fetch(url);
8881
- if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
8882
- return new Uint8Array(await res.arrayBuffer());
8883
- }
8884
- /**
8885
- * Ensure a `seekrit-proxy` binary exists locally and return its path.
8886
- *
8887
- * Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
8888
- * version+target, then a fresh download. A binary already on `PATH` is
8889
- * deliberately *not* used — silently running a different version than the one
8890
- * this CLI pins is the kind of surprise that costs an afternoon.
8891
- */
8892
- async function resolveProxyBinary(options = {}) {
8893
- const override = process.env.SEEKRIT_PROXY_BIN;
8894
- if (override) {
8895
- if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
8896
- return override;
8897
- }
8898
- const version = resolveVersion(options.version);
8899
- const { target, exe } = detectTarget();
8900
- const dest = proxyBinaryPath(version, target, exe);
8901
- if (!options.force && version !== "latest" && existsSync(dest)) return dest;
8902
- const baseUrl = resolveBaseUrl(options.baseUrl);
8903
- const prefix = versionPrefix(version);
8904
- const name = `${BIN}-${target}${exe}`;
8905
- const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
8906
- const sumUrl = `${binUrl}.sha256`;
8907
- if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
8908
- let bytes;
8909
- let expected;
8910
- try {
8911
- [bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
8912
- } catch (err) {
8913
- const message = err instanceof Error ? err.message : String(err);
8914
- throw new Error(`could not download ${BIN} ${prefix} for ${target}: ${message}\n Set SEEKRIT_PROXY_BIN to a binary you already have, or build it from apps/proxy.`);
8915
- }
8916
- const actual = createHash("sha256").update(bytes).digest("hex");
8917
- if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
8918
- const dir = dirname(dest);
8919
- mkdirSync(dir, { recursive: true });
8920
- const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
8921
- try {
8922
- writeFileSync(staging, bytes, { mode: 493 });
8923
- renameSync(staging, dest);
8924
- } catch (err) {
8925
- rmSync(staging, { force: true });
8926
- throw err;
8927
- }
8928
- chmodSync(dest, 493);
8929
- return dest;
8930
- }
8931
- /**
8932
- * Run the proxy, forwarding stdio, signals, and its exit status.
8933
- *
8934
- * The proxy is a long-lived foreground process, so this wrapper has to be
8935
- * transparent: Node cannot exec-replace itself, and without relaying signals
8936
- * Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
8937
- * the proxy running, holding decrypted secrets, with the shell prompt back.
8938
- */
8939
- async function runProxyBinary(argv, options = {}) {
8940
- const bin = await resolveProxyBinary(options);
8941
- const child = spawn(bin, argv, {
8942
- stdio: "inherit",
8943
- env: {
8944
- ...process.env,
8945
- ...options.env
8946
- }
8947
- });
8948
- const signals = [
8949
- "SIGINT",
8950
- "SIGTERM",
8951
- "SIGHUP",
8952
- "SIGQUIT"
8953
- ];
8954
- const forward = (signal) => {
8955
- if (child.exitCode !== null || child.signalCode !== null) return;
8956
- child.kill(signal);
8957
- };
8958
- for (const signal of signals) process.on(signal, forward);
8959
- return new Promise((resolve, reject) => {
8960
- child.on("error", (err) => {
8961
- for (const s of signals) process.off(s, forward);
8962
- reject(/* @__PURE__ */ new Error(`could not start ${bin}: ${err.message}\n If this is a fresh download, the platform may not match — set SEEKRIT_PROXY_BIN.`));
8963
- });
8964
- child.on("exit", (code, signal) => {
8965
- for (const s of signals) process.off(s, forward);
8966
- resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
8967
- });
8968
- });
8969
- }
8970
- /** Signal name → number, for the 128+n exit convention. */
8971
- function signalNumber(signal) {
8972
- return {
8973
- SIGHUP: 1,
8974
- SIGINT: 2,
8975
- SIGQUIT: 3,
8976
- SIGKILL: 9,
8977
- SIGTERM: 15
8978
- }[signal] ?? 0;
8979
- }
8980
- //#endregion
8981
- //#region src/proxy-presets.ts
8982
- /** `Authorization: Bearer {{seekrit:NAME}}` — the shape most providers take. */
8983
- function placeholder(secret) {
8984
- return `{{seekrit:${secret}}}`;
8985
- }
8986
- /**
8987
- * The catalogue. Ordered as `seekrit proxy presets` prints it: the two model
8988
- * APIs an agent almost certainly calls, then the aggregators, then the generic
8989
- * escape hatches.
10085
+ * The catalogue. Ordered as `seekrit proxy presets` prints it: the three model
10086
+ * APIs an agent almost certainly calls, then the aggregators, then the generic
10087
+ * escape hatches.
8990
10088
  */
8991
10089
  const PROXY_PRESETS = [
8992
10090
  {
@@ -9029,6 +10127,28 @@ const PROXY_PRESETS = [
9029
10127
  value: placeholder("ANTHROPIC_API_KEY")
9030
10128
  }]
9031
10129
  },
10130
+ {
10131
+ id: "gemini",
10132
+ label: "Gemini API (generativelanguage.googleapis.com)",
10133
+ host: "generativelanguage.googleapis.com",
10134
+ prefix: "/gemini",
10135
+ secret: "GEMINI_API_KEY",
10136
+ methods: ["GET", "POST"],
10137
+ paths: ["/v1beta/**", "/v1/**"],
10138
+ baseUrlSuffix: "",
10139
+ env: (mode) => mode === "reverse" ? [{
10140
+ name: "GOOGLE_GEMINI_BASE_URL",
10141
+ value: "{{base}}",
10142
+ note: "gemini-cli reads GEMINI_BASE_URL instead — set both if you run either."
10143
+ }, {
10144
+ name: "GEMINI_API_KEY",
10145
+ value: placeholder("GEMINI_API_KEY")
10146
+ }] : [{
10147
+ name: "GEMINI_API_KEY",
10148
+ value: placeholder("GEMINI_API_KEY")
10149
+ }],
10150
+ note: "Gemini authenticates with an x-goog-api-key header; a ?key= query string is substituted too, but prefer the header."
10151
+ },
9032
10152
  {
9033
10153
  id: "openrouter",
9034
10154
  label: "OpenRouter (openrouter.ai) — OpenAI-compatible",
@@ -9388,186 +10508,708 @@ function planFromPresets(presets, options) {
9388
10508
  return {
9389
10509
  mode: options.mode,
9390
10510
  listen: options.listen,
9391
- forwardListen: options.forwardListen,
9392
- routes,
9393
- unmatched: options.unmatched,
9394
- caCert: options.caCert,
9395
- caKey: options.caKey,
9396
- ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
9397
- ...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
9398
- ...options.control ? { control: options.control } : {},
9399
- ...options.tasks ? { tasks: options.tasks } : {},
9400
- ...options.activity ? { activity: options.activity } : {},
9401
- envHints,
9402
- notes
9403
- };
10511
+ forwardListen: options.forwardListen,
10512
+ routes,
10513
+ unmatched: options.unmatched,
10514
+ caCert: options.caCert,
10515
+ caKey: options.caKey,
10516
+ ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
10517
+ ...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
10518
+ ...options.control ? { control: options.control } : {},
10519
+ ...options.tasks ? { tasks: options.tasks } : {},
10520
+ ...options.activity ? { activity: options.activity } : {},
10521
+ envHints,
10522
+ notes
10523
+ };
10524
+ }
10525
+ /**
10526
+ * Build a server-policy plan from an agent's published rules.
10527
+ *
10528
+ * The rules are used for **routing only** — one `[[route]]` per distinct host,
10529
+ * so the workload has a base URL to point at — and never copied into the file as
10530
+ * authorization. That is the whole trade of server mode: adding an upstream
10531
+ * becomes a dashboard change, and a rule this file also stated would be a
10532
+ * startup error rather than a belt-and-braces duplicate.
10533
+ */
10534
+ function planFromPolicy(args, options) {
10535
+ const taken = /* @__PURE__ */ new Set();
10536
+ const routes = [];
10537
+ const envHints = [];
10538
+ const notes = [];
10539
+ const hintMode = options.mode === "forward" ? "forward" : "reverse";
10540
+ const seen = /* @__PURE__ */ new Set();
10541
+ for (const rule of args.rules) {
10542
+ if (!rule.host || seen.has(rule.host)) continue;
10543
+ seen.add(rule.host);
10544
+ const preset = PRESET_BY_HOST.get(rule.host);
10545
+ const prefix = claimPrefix(preset?.prefix, rule.host, taken);
10546
+ const baseUrl = baseUrlFor(options.listen, prefix, preset?.baseUrlSuffix ?? "");
10547
+ routes.push({
10548
+ prefix,
10549
+ upstream: `https://${rule.host}`,
10550
+ host: rule.host,
10551
+ allow: [],
10552
+ methods: [],
10553
+ paths: [],
10554
+ baseUrl
10555
+ });
10556
+ if (preset) for (const hint of preset.env(hintMode)) envHints.push({
10557
+ ...hint,
10558
+ value: hint.value.replace("{{base}}", baseUrl)
10559
+ });
10560
+ }
10561
+ if (hintMode === "forward") envHints.unshift({
10562
+ name: "HTTPS_PROXY",
10563
+ value: `http://${options.forwardListen}`
10564
+ }, {
10565
+ name: "NODE_EXTRA_CA_CERTS",
10566
+ value: `$PWD/${options.caCert}`,
10567
+ note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
10568
+ });
10569
+ if (args.rules.length === 0) notes.push("The published policy has no rules yet, so this proxy permits nothing until one is published.");
10570
+ return {
10571
+ mode: options.mode,
10572
+ listen: options.listen,
10573
+ forwardListen: options.forwardListen,
10574
+ routes,
10575
+ policy: {
10576
+ agent: args.agent,
10577
+ agents: args.agents,
10578
+ refreshInterval: args.refreshInterval,
10579
+ signers: args.signers
10580
+ },
10581
+ unmatched: options.unmatched,
10582
+ caCert: options.caCert,
10583
+ caKey: options.caKey,
10584
+ ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
10585
+ ...options.control ? { control: options.control } : {},
10586
+ ...options.tasks ? { tasks: options.tasks } : {},
10587
+ ...options.activity ? { activity: options.activity } : {},
10588
+ envHints,
10589
+ notes
10590
+ };
10591
+ }
10592
+ /** Host → preset, for naming routes generated from published policy. */
10593
+ const PRESET_BY_HOST = /* @__PURE__ */ new Map();
10594
+ for (const id of [
10595
+ "openai",
10596
+ "anthropic",
10597
+ "openrouter",
10598
+ "github"
10599
+ ]) {
10600
+ const preset = findPreset(id);
10601
+ if (preset?.host) PRESET_BY_HOST.set(preset.host, preset);
10602
+ }
10603
+ /** YAML double-quoted scalar. Compose values here are hostnames and URLs. */
10604
+ function yamlString(value) {
10605
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
10606
+ }
10607
+ const COMPOSE_DEFAULTS = {
10608
+ service: "seekrit-proxy",
10609
+ workload: "agent",
10610
+ publish: false
10611
+ };
10612
+ /**
10613
+ * A `docker compose` sidecar snippet for a generated config.
10614
+ *
10615
+ * The container case differs from the local one in exactly the ways that break a
10616
+ * copied-from-the-docs compose file: the proxy has to bind `0.0.0.0` to be
10617
+ * reachable from a sibling container, the workload dials it by *service name*
10618
+ * rather than loopback, and in forward mode the CA has to live on a shared
10619
+ * volume or the workload trusts a certificate the proxy no longer has.
10620
+ */
10621
+ function renderComposeSnippet(plan, options) {
10622
+ const reverse = plan.mode === "reverse" || plan.mode === "both";
10623
+ const forward = plan.mode === "forward" || plan.mode === "both";
10624
+ const [, listenPort = "8080"] = splitHostPort(plan.listen);
10625
+ const [, forwardPort = "8081"] = splitHostPort(plan.forwardListen);
10626
+ const host = options.service;
10627
+ const out = [
10628
+ "# seekrit-proxy sidecar — generated by `seekrit proxy compose`.",
10629
+ "#",
10630
+ "# The proxy holds the decrypted secrets; the workload holds only placeholders.",
10631
+ "# Keeping them in separate containers is what makes that boundary real: the",
10632
+ "# service token is in the proxy's environment, where the workload cannot read it.",
10633
+ "services:",
10634
+ ` ${host}:`,
10635
+ ` image: ${options.image}`
10636
+ ];
10637
+ const command = [];
10638
+ if (reverse) command.push("--listen", `0.0.0.0:${listenPort}`);
10639
+ if (command.length > 0) out.push(` command: [${command.map(yamlString).join(", ")}]`);
10640
+ if (forward) {
10641
+ out.push(` # Forward mode: set \`[forward] listen = "0.0.0.0:${forwardPort}"\` in the`);
10642
+ out.push(" # config too — there is no flag for the forward plane's address.");
10643
+ }
10644
+ out.push(" environment:");
10645
+ out.push(" # Never inline the token. Compose reads it from your shell or a .env file.");
10646
+ out.push(" SEEKRIT_TOKEN: ${SEEKRIT_TOKEN:?SEEKRIT_TOKEN is required}");
10647
+ out.push(" volumes:");
10648
+ out.push(" - ./seekrit-proxy.toml:/seekrit-proxy.toml:ro");
10649
+ if (forward) {
10650
+ out.push(" # The interception CA must survive restarts, or the certificate the");
10651
+ out.push(" # workload trusts stops matching the one the proxy mints leaves from.");
10652
+ out.push(" - seekrit-proxy-ca:/ca");
10653
+ }
10654
+ if (options.publish) {
10655
+ out.push(" ports:");
10656
+ if (reverse) out.push(` - ${yamlString(`127.0.0.1:${listenPort}:${listenPort}`)}`);
10657
+ if (forward) out.push(` - ${yamlString(`127.0.0.1:${forwardPort}:${forwardPort}`)}`);
10658
+ } else {
10659
+ out.push(" # No `ports`: reachable on the compose network only, which is what you");
10660
+ out.push(" # want — nothing outside this project can ask the proxy to inject a key.");
10661
+ }
10662
+ out.push(" restart: unless-stopped");
10663
+ out.push("");
10664
+ out.push(` ${options.workload}:`);
10665
+ out.push(" # ← your workload. It never holds a real credential.");
10666
+ out.push(" image: your-agent:latest");
10667
+ out.push(" depends_on:");
10668
+ out.push(` - ${host}`);
10669
+ out.push(" environment:");
10670
+ if (forward) {
10671
+ out.push(` HTTPS_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
10672
+ out.push(` HTTP_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
10673
+ out.push(" NODE_EXTRA_CA_CERTS: \"/ca/seekrit-proxy-ca.pem\"");
10674
+ out.push(" # …or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime.");
10675
+ }
10676
+ for (const hint of plan.envHints) {
10677
+ if (hint.name === "HTTPS_PROXY" || hint.name === "NODE_EXTRA_CA_CERTS") continue;
10678
+ const value = hint.value.replace(/http:\/\/[^/]+/, `http://${host}:${listenPort}`);
10679
+ out.push(` ${hint.name}: ${yamlString(value)}`);
10680
+ }
10681
+ out.push("");
10682
+ if (forward) {
10683
+ out.push("volumes:");
10684
+ out.push(" seekrit-proxy-ca:");
10685
+ out.push("");
10686
+ }
10687
+ out.push(forward ? "# The workload can unset HTTPS_PROXY, so in a threat model where the workload" : "# The workload can ignore the base URL above, so in a threat model where the");
10688
+ out.push(forward ? "# is the adversary, make the proxy the only route out: put the workload on an" : "# workload is the adversary, make the proxy the only route out: put the workload");
10689
+ out.push(forward ? "# `internal: true` network with the proxy as its only peer." : "# on an `internal: true` network with the proxy as its only peer.");
10690
+ return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
10691
+ }
10692
+ //#endregion
10693
+ //#region src/paperclip.ts
10694
+ /**
10695
+ * `seekrit paperclip` — wire seekrit into a [Paperclip](https://docs.paperclip.ing)
10696
+ * agent.
10697
+ *
10698
+ * Paperclip is a control plane: it decides which agent runs, and its *adapter*
10699
+ * launches the runtime that does the work. Two consequences shape this command.
10700
+ *
10701
+ * First, **MCP config is per-runtime, not per-Paperclip-agent.** There is no
10702
+ * field in Paperclip's database that means "give this agent the seekrit tools" —
10703
+ * the adapter's runtime (Claude Code, Codex, Gemini CLI, OpenCode) reads its own
10704
+ * MCP config from the working directory Paperclip points it at. So attaching the
10705
+ * seekrit servers means writing a `.mcp.json` *there*, which is what this does.
10706
+ *
10707
+ * Second, **a seekrit secret cannot be a Paperclip `secret_ref`.** Paperclip's
10708
+ * provider list is closed (`local_encrypted`, `aws_secrets_manager`,
10709
+ * `gcp_secret_manager`, `vault`), so there is nothing to select. Values reach a
10710
+ * run through `seekrit run` / the `run_command` tool, or — better, for a run
10711
+ * whose output you cannot predict — through the egress proxy, where the adapter
10712
+ * env holds `{{seekrit:NAME}}` placeholders and never a key. The env block this
10713
+ * command prints is that second shape, ready to paste into the agent's
10714
+ * Configuration tab.
10715
+ *
10716
+ * The proxy's own config file stays with `seekrit proxy init`. That command
10717
+ * already owns the reviewable-security-artifact warnings, and a second
10718
+ * generator behind a different name is how the two drift apart.
10719
+ */
10720
+ /** Claude Code and friends read this name from the runtime's working directory. */
10721
+ const MCP_FILE = ".mcp.json";
10722
+ /**
10723
+ * The two seekrit MCP servers, in the shape a *runtime's* `.mcp.json` wants.
10724
+ *
10725
+ * These are the same two servers `agent-plugin/mcp.json` declares, and a test
10726
+ * pins them to that file so a rename cannot land in one place only. The one
10727
+ * field that is deliberately **not** copied is the remote transport's spelling:
10728
+ * the Agent Plugins manifest says `streamable-http`, while Claude Code's own
10729
+ * `.mcp.json` says `http`. Writing the manifest's spelling into a runtime config
10730
+ * produces a server the runtime silently declines to load, which reads as "the
10731
+ * hosted server is down".
10732
+ */
10733
+ const PAPERCLIP_MCP_SERVERS = {
10734
+ seekrit: {
10735
+ type: "stdio",
10736
+ command: "npx",
10737
+ args: ["-y", "@seekrit/mcp"]
10738
+ },
10739
+ "seekrit-cloud": {
10740
+ type: "http",
10741
+ url: "https://mcp.seekrit.dev/mcp"
10742
+ }
10743
+ };
10744
+ /**
10745
+ * Merge the seekrit servers into an existing `.mcp.json` without disturbing it.
10746
+ *
10747
+ * An agent's working directory is usually a real repository, so this file may
10748
+ * already carry servers someone else depends on — replacing it wholesale is a
10749
+ * silent regression in whatever they were doing. Unknown top-level keys are
10750
+ * preserved for the same reason: runtimes keep growing new ones.
10751
+ *
10752
+ * A `seekrit` entry that already exists and *differs* is left alone unless
10753
+ * forced. Someone pinning a version or adding an `env` did it on purpose.
10754
+ */
10755
+ function mergeMcpServers(existing, force) {
10756
+ const servers = { ...existing.mcpServers ?? {} };
10757
+ const added = [];
10758
+ const kept = [];
10759
+ for (const [name, entry] of Object.entries(PAPERCLIP_MCP_SERVERS)) {
10760
+ const current = servers[name];
10761
+ if (current && !force) {
10762
+ if (JSON.stringify(current) === JSON.stringify(entry)) continue;
10763
+ kept.push(name);
10764
+ continue;
10765
+ }
10766
+ servers[name] = entry;
10767
+ added.push(name);
10768
+ }
10769
+ return {
10770
+ merged: {
10771
+ ...existing,
10772
+ mcpServers: servers
10773
+ },
10774
+ added,
10775
+ kept
10776
+ };
10777
+ }
10778
+ function readMcpFile(path) {
10779
+ if (!existsSync(path)) return {};
10780
+ let raw;
10781
+ try {
10782
+ raw = readFileSync(path, "utf8");
10783
+ } catch (err) {
10784
+ fail(`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
10785
+ }
10786
+ try {
10787
+ const parsed = JSON.parse(raw);
10788
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) fail(`${path} is not a JSON object — fix or move it before writing MCP servers there`);
10789
+ return parsed;
10790
+ } catch (err) {
10791
+ if (err instanceof SyntaxError) fail(`${path} is not valid JSON (${err.message}) — fix or move it first`);
10792
+ throw err;
10793
+ }
10794
+ }
10795
+ /**
10796
+ * The env a Paperclip agent needs, given the upstreams it calls.
10797
+ *
10798
+ * Every value here is a plain string: a placeholder is not a secret, so none of
10799
+ * it needs a `secret_ref` and none of it trips
10800
+ * `PAPERCLIP_SECRETS_STRICT_MODE` — which is exactly why this is the path to
10801
+ * recommend. The env is derived from the same preset catalogue and the same
10802
+ * plan builder `seekrit proxy init` uses, so what gets pasted into Paperclip and
10803
+ * what the proxy enforces cannot disagree.
10804
+ *
10805
+ * One hint has to be rewritten on the way through. The shared generator writes
10806
+ * the CA path as `$PWD/seekrit-proxy-ca.pem`, which is correct for the `export`
10807
+ * lines it normally produces — a shell expands it. **Paperclip's env map is not a
10808
+ * shell.** Pasted verbatim it becomes a literal `$PWD/…`, the runtime cannot find
10809
+ * the CA, and every HTTPS call fails with a certificate error that looks like a
10810
+ * proxy bug. So it is replaced with a marker that cannot be mistaken for a
10811
+ * working value.
10812
+ */
10813
+ const ABSOLUTE_PATH_MARKER = "<absolute path to>";
10814
+ function adapterEnv(presets, options) {
10815
+ if (presets.length === 0) return [];
10816
+ return planFromPresets(presets, {
10817
+ ...PLAN_DEFAULTS,
10818
+ mode: options.mode,
10819
+ listen: options.listen,
10820
+ forwardListen: options.forwardListen
10821
+ }).envHints.map((hint) => {
10822
+ if (!hint.value.includes("$PWD/")) return { ...hint };
10823
+ return {
10824
+ ...hint,
10825
+ value: hint.value.replace("$PWD/", `${ABSOLUTE_PATH_MARKER} `),
10826
+ note: `${hint.note ? `${hint.note} ` : ""}Paperclip does not expand shell variables — paste the real path the proxy wrote this to.`
10827
+ };
10828
+ });
10829
+ }
10830
+ function registerPaperclipCommands(program) {
10831
+ program.command("paperclip").description("wire seekrit into a Paperclip agent (`seekrit paperclip --help`)").command("init").description("attach the seekrit MCP servers to a Paperclip agent's working directory").option("-d, --dir <path>", "the agent's working directory", ".").option("--preset <id...>", `upstreams the agent calls, for the printed adapter env (${presetIds().join(", ")})`).option("--mode <mode>", "proxy mode the printed env assumes: forward or reverse", "forward").option("--listen <addr>", "reverse-mode proxy address", PLAN_DEFAULTS.listen).option("--forward-listen <addr>", "forward-mode proxy address", PLAN_DEFAULTS.forwardListen).option("--no-mcp", "print the adapter env only, without writing .mcp.json").option("--force", "overwrite a seekrit entry that already differs").option("--json", "machine-readable output").action((options) => {
10832
+ if (options.mode !== "forward" && options.mode !== "reverse") fail(`--mode must be forward or reverse (got "${options.mode}")`);
10833
+ const presets = [];
10834
+ for (const id of options.preset ?? []) {
10835
+ const preset = findPreset(id);
10836
+ if (!preset) fail(`unknown preset "${id}" — try one of: ${presetIds().join(", ")}`);
10837
+ if (preset.requiresBaseUrl) fail(`preset "${id}" needs a base URL, which this command does not take —\n generate its env with: seekrit proxy init --preset ${id} --base-url https://…`);
10838
+ presets.push(preset);
10839
+ }
10840
+ const dir = resolve(options.dir);
10841
+ const mcpPath = join(dir, MCP_FILE);
10842
+ let added = [];
10843
+ let kept = [];
10844
+ if (options.mcp) {
10845
+ if (!existsSync(dir)) fail(`no such directory: ${options.dir}\n Pass --dir with the agent's working directory (its Configuration tab shows it).`);
10846
+ const result = mergeMcpServers(readMcpFile(mcpPath), Boolean(options.force));
10847
+ added = result.added;
10848
+ kept = result.kept;
10849
+ writeFileSync(mcpPath, `${JSON.stringify(result.merged, null, 2)}\n`, { mode: 420 });
10850
+ }
10851
+ const env = adapterEnv(presets, {
10852
+ mode: options.mode,
10853
+ listen: options.listen,
10854
+ forwardListen: options.forwardListen
10855
+ });
10856
+ emit(options, {
10857
+ mcpFile: options.mcp ? mcpPath : null,
10858
+ added,
10859
+ kept,
10860
+ env
10861
+ }, () => {
10862
+ if (options.mcp) {
10863
+ process.stderr.write(added.length > 0 ? `Wrote ${mcpPath} (${added.join(", ")})\n` : `${mcpPath} already had both seekrit servers\n`);
10864
+ for (const name of kept) process.stderr.write(`seekrit: left the existing "${name}" entry alone — pass --force to replace it\n`);
10865
+ }
10866
+ if (env.length > 0) {
10867
+ process.stderr.write("\nAdapter environment variables (Agent → Configuration → Environment variables).\nEvery value is a plain string — a placeholder is not a secret, so none of\nthese needs a Paperclip secret_ref:\n\n");
10868
+ printTable(env, [col("VARIABLE", (h) => h.name), col("VALUE", (h) => h.value)], "no env for these presets");
10869
+ const notes = env.filter((h) => h.note);
10870
+ if (notes.length > 0) {
10871
+ process.stderr.write("\n");
10872
+ for (const hint of notes) process.stderr.write(` ${hint.name}: ${hint.note}\n`);
10873
+ }
10874
+ const presetFlags = presets.map((p) => `--preset ${p.id}`).join(" ");
10875
+ process.stderr.write(`\nThose values only resolve behind a running proxy. Write its config with:\n seekrit proxy init --mode ${options.mode} ${presetFlags}\n`);
10876
+ } else process.stderr.write("\nNo --preset given, so no adapter env was printed. Pass the upstreams this\nagent calls to get the placeholder env for them, e.g.\n seekrit paperclip init --preset anthropic --preset openai\n");
10877
+ process.stderr.write("\nAlso worth doing once per company:\n npx paperclipai plugin install @seekrit/paperclip-plugin # tools, skills, panel\n Skills page → https://github.com/seekritdev/agent-plugin # skills alone\n");
10878
+ });
10879
+ });
9404
10880
  }
10881
+ //#endregion
10882
+ //#region src/pg.ts
9405
10883
  /**
9406
- * Build a server-policy plan from an agent's published rules.
10884
+ * `seekrit pg` temporary Postgres credentials (Vault-style dynamic secrets).
9407
10885
  *
9408
- * The rules are used for **routing only** one `[[route]]` per distinct host,
9409
- * so the workload has a base URL to point at — and never copied into the file as
9410
- * authorization. That is the whole trade of server mode: adding an upstream
9411
- * becomes a dashboard change, and a rule this file also stated would be a
9412
- * startup error rather than a belt-and-braces duplicate.
10886
+ * Zero-knowledge: minting generates the password and its SCRAM verifier on THIS
10887
+ * machine and sends only the verifier; the plaintext password never reaches the
10888
+ * API or gets stored. Registering a target wraps the admin connection string to
10889
+ * the broker's public key locally, so the control plane only ever stores
10890
+ * ciphertext.
9413
10891
  */
9414
- function planFromPolicy(args, options) {
9415
- const taken = /* @__PURE__ */ new Set();
9416
- const routes = [];
9417
- const envHints = [];
9418
- const notes = [];
9419
- const hintMode = options.mode === "forward" ? "forward" : "reverse";
9420
- const seen = /* @__PURE__ */ new Set();
9421
- for (const rule of args.rules) {
9422
- if (!rule.host || seen.has(rule.host)) continue;
9423
- seen.add(rule.host);
9424
- const preset = PRESET_BY_HOST.get(rule.host);
9425
- const prefix = claimPrefix(preset?.prefix, rule.host, taken);
9426
- const baseUrl = baseUrlFor(options.listen, prefix, preset?.baseUrlSuffix ?? "");
9427
- routes.push({
9428
- prefix,
9429
- upstream: `https://${rule.host}`,
9430
- host: rule.host,
9431
- allow: [],
9432
- methods: [],
9433
- paths: [],
9434
- baseUrl
10892
+ /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
10893
+ function parseTtlSeconds$2(input) {
10894
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
10895
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
10896
+ return Number(m[1]) * ({
10897
+ s: 1,
10898
+ m: 60,
10899
+ h: 3600,
10900
+ d: 86400
10901
+ }[m[2] || "s"] ?? 1);
10902
+ }
10903
+ /** A fresh, valid Postgres role name: `tmp_` + lowercase alphanumerics. */
10904
+ function generateRoleName(prefix = "tmp") {
10905
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
10906
+ let out = "";
10907
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
10908
+ for (const b of bytes) out += alphabet[b % 36];
10909
+ return `${prefix}_${out}`;
10910
+ }
10911
+ function registerPgCommands(program) {
10912
+ const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
10913
+ const target = pg.command("target").description("manage provisioning targets");
10914
+ 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) => {
10915
+ const ctx = buildContext();
10916
+ const org = await resolveOrg(ctx, options.org);
10917
+ const executor = options.executor === "remote" ? "remote" : "in_do";
10918
+ if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
10919
+ if (![
10920
+ "readonly",
10921
+ "readwrite",
10922
+ "custom"
10923
+ ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
10924
+ const accessLevel = options.access;
10925
+ const adminSecret = resolveLeaseAdminSecret({
10926
+ executor,
10927
+ hmacKey: options.hmacKey,
10928
+ adminUrl: options.adminUrl,
10929
+ adminUrlEnv: "SEEKRIT_PG_ADMIN_URL"
9435
10930
  });
9436
- if (preset) for (const hint of preset.env(hintMode)) envHints.push({
9437
- ...hint,
9438
- value: hint.value.replace("{{base}}", baseUrl)
10931
+ const config = {
10932
+ provider: "postgres",
10933
+ executor,
10934
+ accessLevel,
10935
+ connection: {
10936
+ host: options.host,
10937
+ port: Number.parseInt(options.port, 10),
10938
+ database: options.database
10939
+ },
10940
+ ...accessLevel === "custom" ? {
10941
+ ...options.createStatement.length ? { createStatements: options.createStatement } : {},
10942
+ ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
10943
+ } : { schema: options.schema },
10944
+ ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
10945
+ };
10946
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
10947
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
10948
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
10949
+ name: options.name,
10950
+ config,
10951
+ wrappedAdminSecret
10952
+ });
10953
+ console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
10954
+ const bootstrap = postgresGroupBootstrapSql(config);
10955
+ if (bootstrap) {
10956
+ console.error("\nRun this once in your database as an admin (safe to re-run):\n");
10957
+ console.log(bootstrap);
10958
+ }
10959
+ });
10960
+ target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
10961
+ const ctx = buildContext();
10962
+ const org = await resolveOrg(ctx, options.org);
10963
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
10964
+ for (const t of targets) {
10965
+ const cfg = t.config;
10966
+ if (cfg.provider !== "postgres") continue;
10967
+ console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
10968
+ }
10969
+ });
10970
+ target.command("setup-sql <targetId>").description("print the one-time group-role setup SQL for a preset target").option("--org <slug>").action(async (targetId, options) => {
10971
+ const ctx = buildContext();
10972
+ const org = await resolveOrg(ctx, options.org);
10973
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
10974
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
10975
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
10976
+ const cfg = t.config;
10977
+ if (cfg.provider !== "postgres") fail("not a postgres target (see `seekrit ssh`)");
10978
+ const bootstrap = postgresGroupBootstrapSql(cfg);
10979
+ if (!bootstrap) fail("this is a custom target — it has no generated setup SQL");
10980
+ console.log(bootstrap);
10981
+ });
10982
+ target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
10983
+ const ctx = buildContext();
10984
+ const org = await resolveOrg(ctx, options.org);
10985
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
10986
+ console.error(`removed ${targetId}`);
10987
+ });
10988
+ pg.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--role <name>", "role 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) => {
10989
+ const ctx = buildContext();
10990
+ const org = await resolveOrg(ctx, options.org);
10991
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
10992
+ const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
10993
+ if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
10994
+ if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
10995
+ const roleName = options.role ?? generateRoleName();
10996
+ const ttlSeconds = parseTtlSeconds$2(options.ttl);
10997
+ const { password, verifier } = await generatePostgresCredential();
10998
+ const { connection } = await ctx.client.mintLease(org.id, {
10999
+ provider: "postgres",
11000
+ targetId: target.id,
11001
+ roleName,
11002
+ verifier,
11003
+ ttlSeconds
9439
11004
  });
11005
+ const url = `postgres://${roleName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
11006
+ console.error(`leased ${roleName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
11007
+ if (options.json) console.log(JSON.stringify({
11008
+ ...connection,
11009
+ password,
11010
+ url
11011
+ }, null, 2));
11012
+ else console.log(url);
11013
+ });
11014
+ pg.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
11015
+ const ctx = buildContext();
11016
+ const org = await resolveOrg(ctx, options.org);
11017
+ const { leases } = await ctx.client.listLeases(org.id);
11018
+ for (const l of leases) console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
11019
+ });
11020
+ pg.command("revoke <leaseId>").description("revoke a lease now (drops the role immediately)").option("--org <slug>").action(async (leaseId, options) => {
11021
+ const ctx = buildContext();
11022
+ const org = await resolveOrg(ctx, options.org);
11023
+ await ctx.client.revokeLease(org.id, leaseId);
11024
+ console.error(`revoked ${leaseId}`);
11025
+ });
11026
+ }
11027
+ /** Collect a repeatable option into an array. */
11028
+ function collect$2(value, acc) {
11029
+ acc.push(value);
11030
+ return acc;
11031
+ }
11032
+ //#endregion
11033
+ //#region src/proxy-binary.ts
11034
+ /**
11035
+ * Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
11036
+ *
11037
+ * The proxy is the strongest answer seekrit has for an untrusted workload — the
11038
+ * agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
11039
+ * thing here to *try*, because trying it meant `cargo` and a TOML file. This
11040
+ * module removes the first half: it resolves a prebuilt, checksum-verified
11041
+ * binary for the host platform and execs it, so `npx @seekrit/proxy` and
11042
+ * `seekrit proxy run` behave like the proxy was already installed.
11043
+ *
11044
+ * The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
11045
+ * for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
11046
+ * entrypoint over it, and the two must not drift.
11047
+ *
11048
+ * Three properties worth stating, since this downloads and executes code:
11049
+ *
11050
+ * - **The checksum is verified before anything is executed**, against a
11051
+ * `.sha256` fetched from the same release. That is integrity, not provenance —
11052
+ * it proves the bytes match what the release published, which is exactly the
11053
+ * guarantee `install.sh` gives and no more.
11054
+ * - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
11055
+ * short-circuits entirely, and a cached download for the same version+target is
11056
+ * reused, so this is a one-time cost per version.
11057
+ * - **Version is pinned, not floating.** A default of `latest` would make two
11058
+ * machines run different proxies from the same command; the pinned constant is
11059
+ * what this CLI was built against, overridable when you want otherwise.
11060
+ */
11061
+ /**
11062
+ * The proxy version this CLI was built against.
11063
+ *
11064
+ * Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
11065
+ * release-please-config.json), so the pin follows the crate without anyone
11066
+ * remembering to move it.
11067
+ */
11068
+ const PROXY_VERSION = "0.10.0";
11069
+ const BIN = "seekrit-proxy";
11070
+ /**
11071
+ * Host → Rust target triple.
11072
+ *
11073
+ * Linux always resolves to **musl**: that build is statically linked, so one
11074
+ * artifact covers glibc, musl, alpine, and distroless, and there is no libc
11075
+ * detection to get wrong on a machine where `ldd` says something unexpected.
11076
+ */
11077
+ function detectTarget(os = platform(), cpu = arch()) {
11078
+ const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
11079
+ if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
11080
+ switch (os) {
11081
+ case "linux": return {
11082
+ target: `${machine}-unknown-linux-musl`,
11083
+ exe: ""
11084
+ };
11085
+ case "darwin": return {
11086
+ target: `${machine}-apple-darwin`,
11087
+ exe: ""
11088
+ };
11089
+ case "win32":
11090
+ if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
11091
+ return {
11092
+ target: "x86_64-pc-windows-msvc",
11093
+ exe: ".exe"
11094
+ };
11095
+ default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
9440
11096
  }
9441
- if (hintMode === "forward") envHints.unshift({
9442
- name: "HTTPS_PROXY",
9443
- value: `http://${options.forwardListen}`
9444
- }, {
9445
- name: "NODE_EXTRA_CA_CERTS",
9446
- value: `$PWD/${options.caCert}`,
9447
- note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
9448
- });
9449
- if (args.rules.length === 0) notes.push("The published policy has no rules yet, so this proxy permits nothing until one is published.");
9450
- return {
9451
- mode: options.mode,
9452
- listen: options.listen,
9453
- forwardListen: options.forwardListen,
9454
- routes,
9455
- policy: {
9456
- agent: args.agent,
9457
- agents: args.agents,
9458
- refreshInterval: args.refreshInterval,
9459
- signers: args.signers
9460
- },
9461
- unmatched: options.unmatched,
9462
- caCert: options.caCert,
9463
- caKey: options.caKey,
9464
- ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
9465
- ...options.control ? { control: options.control } : {},
9466
- ...options.tasks ? { tasks: options.tasks } : {},
9467
- ...options.activity ? { activity: options.activity } : {},
9468
- envHints,
9469
- notes
9470
- };
9471
11097
  }
9472
- /** Host preset, for naming routes generated from published policy. */
9473
- const PRESET_BY_HOST = /* @__PURE__ */ new Map();
9474
- for (const id of [
9475
- "openai",
9476
- "anthropic",
9477
- "openrouter",
9478
- "github"
9479
- ]) {
9480
- const preset = findPreset(id);
9481
- if (preset?.host) PRESET_BY_HOST.set(preset.host, preset);
11098
+ /** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
11099
+ function versionPrefix(version) {
11100
+ if (version === "latest") return "latest";
11101
+ return version.startsWith("v") ? version : `v${version}`;
9482
11102
  }
9483
- /** YAML double-quoted scalar. Compose values here are hostnames and URLs. */
9484
- function yamlString(value) {
9485
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
11103
+ function resolveVersion(explicit) {
11104
+ return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
11105
+ }
11106
+ function resolveBaseUrl(explicit) {
11107
+ return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
11108
+ }
11109
+ /** Where a resolved binary is kept, keyed so versions and targets never collide. */
11110
+ function proxyBinaryPath(version, target, exe) {
11111
+ return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
11112
+ }
11113
+ async function fetchBytes(url) {
11114
+ const res = await fetch(url);
11115
+ if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
11116
+ return new Uint8Array(await res.arrayBuffer());
9486
11117
  }
9487
- const COMPOSE_DEFAULTS = {
9488
- service: "seekrit-proxy",
9489
- workload: "agent",
9490
- publish: false
9491
- };
9492
11118
  /**
9493
- * A `docker compose` sidecar snippet for a generated config.
11119
+ * Ensure a `seekrit-proxy` binary exists locally and return its path.
9494
11120
  *
9495
- * The container case differs from the local one in exactly the ways that break a
9496
- * copied-from-the-docs compose file: the proxy has to bind `0.0.0.0` to be
9497
- * reachable from a sibling container, the workload dials it by *service name*
9498
- * rather than loopback, and in forward mode the CA has to live on a shared
9499
- * volume or the workload trusts a certificate the proxy no longer has.
11121
+ * Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
11122
+ * version+target, then a fresh download. A binary already on `PATH` is
11123
+ * deliberately *not* used silently running a different version than the one
11124
+ * this CLI pins is the kind of surprise that costs an afternoon.
9500
11125
  */
9501
- function renderComposeSnippet(plan, options) {
9502
- const reverse = plan.mode === "reverse" || plan.mode === "both";
9503
- const forward = plan.mode === "forward" || plan.mode === "both";
9504
- const [, listenPort = "8080"] = splitHostPort(plan.listen);
9505
- const [, forwardPort = "8081"] = splitHostPort(plan.forwardListen);
9506
- const host = options.service;
9507
- const out = [
9508
- "# seekrit-proxy sidecar — generated by `seekrit proxy compose`.",
9509
- "#",
9510
- "# The proxy holds the decrypted secrets; the workload holds only placeholders.",
9511
- "# Keeping them in separate containers is what makes that boundary real: the",
9512
- "# service token is in the proxy's environment, where the workload cannot read it.",
9513
- "services:",
9514
- ` ${host}:`,
9515
- ` image: ${options.image}`
9516
- ];
9517
- const command = [];
9518
- if (reverse) command.push("--listen", `0.0.0.0:${listenPort}`);
9519
- if (command.length > 0) out.push(` command: [${command.map(yamlString).join(", ")}]`);
9520
- if (forward) {
9521
- out.push(` # Forward mode: set \`[forward] listen = "0.0.0.0:${forwardPort}"\` in the`);
9522
- out.push(" # config too — there is no flag for the forward plane's address.");
9523
- }
9524
- out.push(" environment:");
9525
- out.push(" # Never inline the token. Compose reads it from your shell or a .env file.");
9526
- out.push(" SEEKRIT_TOKEN: ${SEEKRIT_TOKEN:?SEEKRIT_TOKEN is required}");
9527
- out.push(" volumes:");
9528
- out.push(" - ./seekrit-proxy.toml:/seekrit-proxy.toml:ro");
9529
- if (forward) {
9530
- out.push(" # The interception CA must survive restarts, or the certificate the");
9531
- out.push(" # workload trusts stops matching the one the proxy mints leaves from.");
9532
- out.push(" - seekrit-proxy-ca:/ca");
9533
- }
9534
- if (options.publish) {
9535
- out.push(" ports:");
9536
- if (reverse) out.push(` - ${yamlString(`127.0.0.1:${listenPort}:${listenPort}`)}`);
9537
- if (forward) out.push(` - ${yamlString(`127.0.0.1:${forwardPort}:${forwardPort}`)}`);
9538
- } else {
9539
- out.push(" # No `ports`: reachable on the compose network only, which is what you");
9540
- out.push(" # want — nothing outside this project can ask the proxy to inject a key.");
9541
- }
9542
- out.push(" restart: unless-stopped");
9543
- out.push("");
9544
- out.push(` ${options.workload}:`);
9545
- out.push(" # ← your workload. It never holds a real credential.");
9546
- out.push(" image: your-agent:latest");
9547
- out.push(" depends_on:");
9548
- out.push(` - ${host}`);
9549
- out.push(" environment:");
9550
- if (forward) {
9551
- out.push(` HTTPS_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
9552
- out.push(` HTTP_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
9553
- out.push(" NODE_EXTRA_CA_CERTS: \"/ca/seekrit-proxy-ca.pem\"");
9554
- out.push(" # …or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime.");
11126
+ async function resolveProxyBinary(options = {}) {
11127
+ const override = process.env.SEEKRIT_PROXY_BIN;
11128
+ if (override) {
11129
+ if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
11130
+ return override;
9555
11131
  }
9556
- for (const hint of plan.envHints) {
9557
- if (hint.name === "HTTPS_PROXY" || hint.name === "NODE_EXTRA_CA_CERTS") continue;
9558
- const value = hint.value.replace(/http:\/\/[^/]+/, `http://${host}:${listenPort}`);
9559
- out.push(` ${hint.name}: ${yamlString(value)}`);
11132
+ const version = resolveVersion(options.version);
11133
+ const { target, exe } = detectTarget();
11134
+ const dest = proxyBinaryPath(version, target, exe);
11135
+ if (!options.force && version !== "latest" && existsSync(dest)) return dest;
11136
+ const baseUrl = resolveBaseUrl(options.baseUrl);
11137
+ const prefix = versionPrefix(version);
11138
+ const name = `${BIN}-${target}${exe}`;
11139
+ const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
11140
+ const sumUrl = `${binUrl}.sha256`;
11141
+ if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
11142
+ let bytes;
11143
+ let expected;
11144
+ try {
11145
+ [bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
11146
+ } catch (err) {
11147
+ const message = err instanceof Error ? err.message : String(err);
11148
+ throw new Error(`could not download ${BIN} ${prefix} for ${target}: ${message}\n Set SEEKRIT_PROXY_BIN to a binary you already have, or build it from apps/proxy.`);
9560
11149
  }
9561
- out.push("");
9562
- if (forward) {
9563
- out.push("volumes:");
9564
- out.push(" seekrit-proxy-ca:");
9565
- out.push("");
11150
+ const actual = createHash("sha256").update(bytes).digest("hex");
11151
+ if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
11152
+ const dir = dirname(dest);
11153
+ mkdirSync(dir, { recursive: true });
11154
+ const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
11155
+ try {
11156
+ writeFileSync(staging, bytes, { mode: 493 });
11157
+ renameSync(staging, dest);
11158
+ } catch (err) {
11159
+ rmSync(staging, { force: true });
11160
+ throw err;
9566
11161
  }
9567
- out.push(forward ? "# The workload can unset HTTPS_PROXY, so in a threat model where the workload" : "# The workload can ignore the base URL above, so in a threat model where the");
9568
- out.push(forward ? "# is the adversary, make the proxy the only route out: put the workload on an" : "# workload is the adversary, make the proxy the only route out: put the workload");
9569
- out.push(forward ? "# `internal: true` network with the proxy as its only peer." : "# on an `internal: true` network with the proxy as its only peer.");
9570
- return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
11162
+ chmodSync(dest, 493);
11163
+ return dest;
11164
+ }
11165
+ /**
11166
+ * Run the proxy, forwarding stdio, signals, and its exit status.
11167
+ *
11168
+ * The proxy is a long-lived foreground process, so this wrapper has to be
11169
+ * transparent: Node cannot exec-replace itself, and without relaying signals
11170
+ * Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
11171
+ * the proxy running, holding decrypted secrets, with the shell prompt back.
11172
+ */
11173
+ async function runProxyBinary(argv, options = {}) {
11174
+ const bin = await resolveProxyBinary(options);
11175
+ const child = spawn(bin, argv, {
11176
+ stdio: "inherit",
11177
+ env: {
11178
+ ...process.env,
11179
+ ...options.env
11180
+ }
11181
+ });
11182
+ const signals = [
11183
+ "SIGINT",
11184
+ "SIGTERM",
11185
+ "SIGHUP",
11186
+ "SIGQUIT"
11187
+ ];
11188
+ const forward = (signal) => {
11189
+ if (child.exitCode !== null || child.signalCode !== null) return;
11190
+ child.kill(signal);
11191
+ };
11192
+ for (const signal of signals) process.on(signal, forward);
11193
+ return new Promise((resolve, reject) => {
11194
+ child.on("error", (err) => {
11195
+ for (const s of signals) process.off(s, forward);
11196
+ reject(/* @__PURE__ */ new Error(`could not start ${bin}: ${err.message}\n If this is a fresh download, the platform may not match — set SEEKRIT_PROXY_BIN.`));
11197
+ });
11198
+ child.on("exit", (code, signal) => {
11199
+ for (const s of signals) process.off(s, forward);
11200
+ resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
11201
+ });
11202
+ });
11203
+ }
11204
+ /** Signal name → number, for the 128+n exit convention. */
11205
+ function signalNumber(signal) {
11206
+ return {
11207
+ SIGHUP: 1,
11208
+ SIGINT: 2,
11209
+ SIGQUIT: 3,
11210
+ SIGKILL: 9,
11211
+ SIGTERM: 15
11212
+ }[signal] ?? 0;
9571
11213
  }
9572
11214
  //#endregion
9573
11215
  //#region src/proxy.ts
@@ -11827,6 +13469,7 @@ registerRedisCommands(program);
11827
13469
  registerProvisionerCommands(program);
11828
13470
  registerProxyCommands(program);
11829
13471
  registerAgentCommands(program);
13472
+ registerPaperclipCommands(program);
11830
13473
  registerSshCommands(program);
11831
13474
  registerAwsCommands(program);
11832
13475
  registerGcpCommands(program);
@@ -11840,6 +13483,7 @@ program.command("mcp").description("run an MCP server over stdio so AI agents ca
11840
13483
  });
11841
13484
  registerAuditCommands(program);
11842
13485
  registerAccountCommands(program);
13486
+ registerArchiveCommands(program);
11843
13487
  registerLogSinkCommands(program);
11844
13488
  registerSyncCommands(program);
11845
13489
  registerBillingCommands(program);