@seekrit/cli 0.44.0 → 0.45.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 +1440 -19
  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",
@@ -1848,6 +2090,13 @@ const inviteRoleSchema = z.enum(["admin", "member"]);
1848
2090
  const principalTypeSchema = z.enum(["user", "service_token"]);
1849
2091
  /** Org-level capability a service token can hold (never `owner`). */
1850
2092
  const serviceTokenRoleSchema = z.enum(["admin", "member"]);
2093
+ z.object({
2094
+ /** Include the full append-only ciphertext history of every secret. */
2095
+ includeVersions: z.boolean().optional(),
2096
+ includeAudit: z.boolean().optional(),
2097
+ /** Newest audit rows to keep. Exceeding the server cap is a 400, not a silent trim. */
2098
+ auditLimit: z.number().int().min(0).optional()
2099
+ });
1851
2100
  z.object({
1852
2101
  name: nameSchema,
1853
2102
  slug: slugSchema
@@ -4624,7 +4873,7 @@ async function createAgentTaskToken() {
4624
4873
  }
4625
4874
  //#endregion
4626
4875
  //#region package.json
4627
- var version = "0.44.0";
4876
+ var version = "0.45.0";
4628
4877
  //#endregion
4629
4878
  //#region ../../packages/api-client/src/index.ts
4630
4879
  var SeekritApiError = class extends Error {
@@ -5207,6 +5456,17 @@ var SeekritClient = class {
5207
5456
  const qs = params.size > 0 ? `?${params}` : "";
5208
5457
  return this.request("GET", `/v1/orgs/${orgId}/audit${qs}`);
5209
5458
  }
5459
+ /**
5460
+ * Export the org as one signed archive: every row seekrit holds for it, with
5461
+ * ciphertext still ciphertext (docs/break-glass-export.md).
5462
+ *
5463
+ * The archive comes back inline rather than as a job handle, and it can be
5464
+ * megabytes — buffer it to a file rather than holding several copies. Requires
5465
+ * admin; deliberately not entitlement-gated.
5466
+ */
5467
+ exportArchive(orgId, input = {}) {
5468
+ return this.request("POST", `/v1/orgs/${orgId}/export`, input);
5469
+ }
5210
5470
  getLogSink(orgId) {
5211
5471
  return this.request("GET", `/v1/orgs/${orgId}/log-sink`);
5212
5472
  }
@@ -6513,7 +6773,7 @@ function registerPolicyCommands(policy) {
6513
6773
  //#endregion
6514
6774
  //#region src/kms.ts
6515
6775
  /** Collect a repeatable option into a list. */
6516
- function collect$6(value, acc = []) {
6776
+ function collect$7(value, acc = []) {
6517
6777
  acc.push(value);
6518
6778
  return acc;
6519
6779
  }
@@ -6581,7 +6841,7 @@ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
6581
6841
  }
6582
6842
  function registerKmsCommands(program) {
6583
6843
  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) => {
6844
+ 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
6845
  if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
6586
6846
  if (options.app && options.group) fail("pass at most one of --app or --group");
6587
6847
  const ctx = buildContext();
@@ -6800,7 +7060,7 @@ function registerKmsCommands(program) {
6800
7060
  //#endregion
6801
7061
  //#region src/recovery.ts
6802
7062
  /** Collect a repeatable option into a list. */
6803
- function collect$5(value, acc = []) {
7063
+ function collect$6(value, acc = []) {
6804
7064
  acc.push(value);
6805
7065
  return acc;
6806
7066
  }
@@ -6897,7 +7157,7 @@ function registerRecoveryCommands(program) {
6897
7157
  for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
6898
7158
  if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
6899
7159
  });
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) => {
7160
+ 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
7161
  const ctx = buildContext();
6902
7162
  const org = await resolveOrg(ctx, options.org);
6903
7163
  const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
@@ -6915,7 +7175,7 @@ function registerRecoveryCommands(program) {
6915
7175
  const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
6916
7176
  console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
6917
7177
  });
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) => {
7178
+ 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
7179
  const ctx = buildContext();
6920
7180
  const org = await resolveOrg(ctx, options.org);
6921
7181
  const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
@@ -7195,6 +7455,1179 @@ function registerAppCommands(program) {
7195
7455
  });
7196
7456
  }
7197
7457
  //#endregion
7458
+ //#region src/decryptor.ts
7459
+ /**
7460
+ * The standalone offline decryptor: one self-contained HTML file, written out by
7461
+ * `seekrit archive decryptor`, that opens a break-glass archive in any browser
7462
+ * with no install, no network, and no seekrit.
7463
+ *
7464
+ * Why a duplicate of the decrypt path instead of a bundle of `@seekrit/crypto`:
7465
+ * the artifact has to be a single file a customer can store next to their
7466
+ * archives for years and open from `file://`, which rules out a module graph and
7467
+ * a build step. So it is a second implementation — pinned, like every other
7468
+ * second implementation in this repo (the four SDKs, `crates/seekrit-core`), by a
7469
+ * test that runs *this* script against ciphertext produced by the real library:
7470
+ * `test/decryptor.test.ts`. If you change a blob format, that test fails here
7471
+ * too, which is the point.
7472
+ *
7473
+ * Two rules for editing the embedded script:
7474
+ * - **No backticks and no `${`** anywhere inside it — it lives in a template
7475
+ * literal. Use string concatenation.
7476
+ * - **No network of any kind.** The page declares
7477
+ * `Content-Security-Policy: default-src 'none'`, which is the property that
7478
+ * makes it safe to type a passphrase into. Anything that needs a fetch does
7479
+ * not belong here.
7480
+ */
7481
+ const OFFLINE_DECRYPTOR_HTML = `<!doctype html>
7482
+ <html lang="en">
7483
+ <head>
7484
+ <meta charset="utf-8">
7485
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7486
+ <!--
7487
+ The whole security argument for this page, in one header: with default-src
7488
+ 'none' the browser refuses every outbound request the page could make, so the
7489
+ passphrase you type and the plaintext it produces cannot leave this machine.
7490
+ 'unsafe-inline' covers the page's own inline script and styles; there is no
7491
+ connect-src, no img-src, no form-action.
7492
+ -->
7493
+ <meta http-equiv="Content-Security-Policy"
7494
+ content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
7495
+ <title>seekrit — offline archive decryptor</title>
7496
+ <style>
7497
+ :root {
7498
+ color-scheme: dark light;
7499
+ --bg: #0b0c0e; --fg: #e7e9ea; --dim: #9aa0a6; --line: #23262b;
7500
+ --panel: #101215; --accent: #7dd3a0; --warn: #f0b76b; --bad: #f08a8a;
7501
+ }
7502
+ @media (prefers-color-scheme: light) {
7503
+ :root {
7504
+ --bg: #fbfbfa; --fg: #14161a; --dim: #5f6673; --line: #e3e5e8;
7505
+ --panel: #ffffff; --accent: #1a7f4b; --warn: #9a6413; --bad: #b23b3b;
7506
+ }
7507
+ }
7508
+ * { box-sizing: border-box; }
7509
+ body {
7510
+ margin: 0; padding: 2rem 1.25rem 4rem; background: var(--bg); color: var(--fg);
7511
+ font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
7512
+ }
7513
+ main { max-width: 62rem; margin: 0 auto; }
7514
+ h1 { font-size: 1.1rem; letter-spacing: 0.02em; margin: 0 0 0.25rem; }
7515
+ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.12em;
7516
+ color: var(--dim); margin: 2rem 0 0.75rem; font-weight: 500; }
7517
+ p { margin: 0.4rem 0; color: var(--dim); }
7518
+ .panel { border: 1px solid var(--line); background: var(--panel); border-radius: 6px;
7519
+ padding: 1rem 1.1rem; }
7520
+ .drop { border: 1px dashed var(--line); border-radius: 6px; padding: 2rem 1rem;
7521
+ text-align: center; color: var(--dim); }
7522
+ .drop.over { border-color: var(--accent); color: var(--fg); }
7523
+ label { display: block; color: var(--dim); margin: 0.75rem 0 0.25rem; }
7524
+ input, select, textarea, button {
7525
+ font: inherit; color: var(--fg); background: var(--bg);
7526
+ border: 1px solid var(--line); border-radius: 4px; padding: 0.5rem 0.6rem;
7527
+ }
7528
+ input, select, textarea { width: 100%; }
7529
+ textarea { min-height: 5rem; }
7530
+ button { cursor: pointer; background: var(--panel); }
7531
+ button:hover { border-color: var(--accent); }
7532
+ button.primary { border-color: var(--accent); color: var(--accent); }
7533
+ .row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
7534
+ table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
7535
+ th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--line);
7536
+ vertical-align: top; word-break: break-all; }
7537
+ th { color: var(--dim); font-weight: 500; font-size: 0.85rem; }
7538
+ .kv { display: grid; grid-template-columns: 12rem 1fr; gap: 0.15rem 1rem; }
7539
+ .kv dt { color: var(--dim); }
7540
+ .kv dd { margin: 0; word-break: break-all; }
7541
+ .ok { color: var(--accent); } .warn { color: var(--warn); } .bad { color: var(--bad); }
7542
+ .hidden { display: none; }
7543
+ .env { margin-bottom: 1.5rem; }
7544
+ .muted { color: var(--dim); font-size: 0.85rem; }
7545
+ pre { white-space: pre-wrap; word-break: break-all; margin: 0.5rem 0 0; }
7546
+ .value { font-family: inherit; }
7547
+ ul { margin: 0.3rem 0 0; padding-left: 1.2rem; color: var(--dim); }
7548
+ </style>
7549
+ </head>
7550
+ <body>
7551
+ <main>
7552
+ <h1>seekrit — offline archive decryptor</h1>
7553
+ <p>
7554
+ Opens a <code>seekrit-archive/v1</code> file with your own key. This page has
7555
+ no network access at all (see the CSP in its source) &mdash; your passphrase
7556
+ and your secrets never leave this machine. Nothing is uploaded, and nothing
7557
+ needs to be installed.
7558
+ </p>
7559
+
7560
+ <h2>1 &middot; the archive</h2>
7561
+ <div class="drop panel" id="drop">
7562
+ <input type="file" id="file" accept=".json,application/json" style="width:auto">
7563
+ <p class="muted">or drop the file here</p>
7564
+ </div>
7565
+ <div id="manifest" class="panel hidden" style="margin-top:0.75rem"></div>
7566
+
7567
+ <div id="step2" class="hidden">
7568
+ <h2>2 &middot; your key</h2>
7569
+ <div class="panel">
7570
+ <label for="method">how you hold it</label>
7571
+ <select id="method">
7572
+ <option value="passphrase">passphrase (unlocks the key inside the archive)</option>
7573
+ <option value="token">service token (skt_&hellip;)</option>
7574
+ <option value="jwk">private key (JWK)</option>
7575
+ <option value="shares">custodian shares (recovery quorum)</option>
7576
+ </select>
7577
+
7578
+ <div id="field-passphrase">
7579
+ <label for="passphrase">passphrase</label>
7580
+ <input type="password" id="passphrase" autocomplete="off" spellcheck="false">
7581
+ <p class="muted" id="key-owner"></p>
7582
+ </div>
7583
+ <div id="field-token" class="hidden">
7584
+ <label for="token">service token</label>
7585
+ <input type="password" id="token" autocomplete="off" spellcheck="false"
7586
+ placeholder="skt_...">
7587
+ </div>
7588
+ <div id="field-jwk" class="hidden">
7589
+ <label for="jwk">private key JWK</label>
7590
+ <textarea id="jwk" spellcheck="false" placeholder='{"kty":"EC","crv":"P-256",...}'></textarea>
7591
+ </div>
7592
+ <div id="field-shares" class="hidden">
7593
+ <label for="shares">custodian shares</label>
7594
+ <textarea id="shares" spellcheck="false"
7595
+ placeholder="paste the contents of each share file from 'seekrit archive share', one after another"></textarea>
7596
+ <p class="muted">
7597
+ Reconstructs the org recovery key from a quorum, which opens every
7598
+ environment &mdash; for when the key-holder is gone.
7599
+ </p>
7600
+ </div>
7601
+
7602
+ <div class="row" style="margin-top:1rem">
7603
+ <button class="primary" id="decrypt">decrypt</button>
7604
+ <span id="status" class="muted"></span>
7605
+ </div>
7606
+ </div>
7607
+ </div>
7608
+
7609
+ <div id="results"></div>
7610
+ </main>
7611
+ <script>
7612
+ (function () {
7613
+ "use strict";
7614
+
7615
+ // ── encoding ────────────────────────────────────────────────────────────
7616
+ function b64uToBytes(text) {
7617
+ var base64 = text.replace(/-/g, "+").replace(/_/g, "/");
7618
+ var binary = atob(base64);
7619
+ var out = new Uint8Array(binary.length);
7620
+ for (var i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
7621
+ return out;
7622
+ }
7623
+ function hexToBytes(hex) {
7624
+ if (hex.length % 2 !== 0) throw new Error("malformed hex");
7625
+ var out = new Uint8Array(hex.length / 2);
7626
+ for (var i = 0; i < out.length; i++) {
7627
+ var byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
7628
+ if (Number.isNaN(byte)) throw new Error("malformed hex");
7629
+ out[i] = byte;
7630
+ }
7631
+ return out;
7632
+ }
7633
+ function bytesToHex(bytes) {
7634
+ var out = "";
7635
+ for (var i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
7636
+ return out;
7637
+ }
7638
+ function utf8(text) { return new TextEncoder().encode(text); }
7639
+ function fromUtf8(bytes) { return new TextDecoder().decode(bytes); }
7640
+
7641
+ // ── canonical JSON + digests (must match packages/core/src/archive.ts) ──
7642
+ function canonicalJson(value) {
7643
+ if (value === null || typeof value !== "object") {
7644
+ var scalar = JSON.stringify(value);
7645
+ return scalar === undefined ? "null" : scalar;
7646
+ }
7647
+ if (Array.isArray(value)) {
7648
+ return "[" + value.map(canonicalJson).join(",") + "]";
7649
+ }
7650
+ var keys = Object.keys(value).filter(function (key) { return value[key] !== undefined; });
7651
+ keys.sort();
7652
+ var parts = keys.map(function (key) {
7653
+ return JSON.stringify(key) + ":" + canonicalJson(value[key]);
7654
+ });
7655
+ return "{" + parts.join(",") + "}";
7656
+ }
7657
+ async function sha256Hex(text) {
7658
+ var digest = await crypto.subtle.digest("SHA-256", utf8(text));
7659
+ return "sha256:" + bytesToHex(new Uint8Array(digest));
7660
+ }
7661
+ function sectionCount(value) {
7662
+ if (value === null || value === undefined) return 0;
7663
+ return Array.isArray(value) ? value.length : 1;
7664
+ }
7665
+
7666
+ // ── the three blob formats ──────────────────────────────────────────────
7667
+ function splitBlob(blob, prefix, parts) {
7668
+ var pieces = String(blob).split(".");
7669
+ if (pieces.length !== parts + 1 || pieces[0] !== prefix) {
7670
+ throw new Error("not a " + prefix + ". blob");
7671
+ }
7672
+ return pieces.slice(1);
7673
+ }
7674
+
7675
+ /** pk1.<iterations>.<salt>.<iv>.<ciphertext> — PBKDF2-SHA256 + AES-256-GCM. */
7676
+ async function decryptPrivateKeyBlob(passphrase, blob) {
7677
+ var parts = splitBlob(blob, "pk1", 4);
7678
+ var iterations = parseInt(parts[0], 10);
7679
+ if (!Number.isFinite(iterations) || iterations < 1) throw new Error("bad iteration count");
7680
+ var material = await crypto.subtle.importKey("raw", utf8(passphrase), "PBKDF2", false, [
7681
+ "deriveKey",
7682
+ ]);
7683
+ var kek = await crypto.subtle.deriveKey(
7684
+ { name: "PBKDF2", hash: "SHA-256", salt: b64uToBytes(parts[1]), iterations: iterations },
7685
+ material,
7686
+ { name: "AES-GCM", length: 256 },
7687
+ false,
7688
+ ["decrypt"]
7689
+ );
7690
+ var plaintext = await crypto.subtle.decrypt(
7691
+ { name: "AES-GCM", iv: b64uToBytes(parts[2]) },
7692
+ kek,
7693
+ b64uToBytes(parts[3])
7694
+ );
7695
+ return fromUtf8(new Uint8Array(plaintext));
7696
+ }
7697
+
7698
+ /** wd1.<ephemeral pub>.<hkdf salt>.<iv>.<ciphertext> — ECDH P-256 + HKDF + AES-GCM. */
7699
+ async function unwrap(blob, privateKey) {
7700
+ var parts = splitBlob(blob, "wd1", 4);
7701
+ var ephemeral = await crypto.subtle.importKey(
7702
+ "raw",
7703
+ b64uToBytes(parts[0]),
7704
+ { name: "ECDH", namedCurve: "P-256" },
7705
+ false,
7706
+ []
7707
+ );
7708
+ var shared = await crypto.subtle.deriveBits(
7709
+ { name: "ECDH", public: ephemeral },
7710
+ privateKey,
7711
+ 256
7712
+ );
7713
+ var hkdf = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
7714
+ var wrappingKey = await crypto.subtle.deriveKey(
7715
+ {
7716
+ name: "HKDF",
7717
+ hash: "SHA-256",
7718
+ salt: b64uToBytes(parts[1]),
7719
+ info: utf8("seekrit/wrap-dek/v1"),
7720
+ },
7721
+ hkdf,
7722
+ { name: "AES-GCM", length: 256 },
7723
+ false,
7724
+ ["decrypt"]
7725
+ );
7726
+ var opened = await crypto.subtle.decrypt(
7727
+ { name: "AES-GCM", iv: b64uToBytes(parts[2]) },
7728
+ wrappingKey,
7729
+ b64uToBytes(parts[3])
7730
+ );
7731
+ return new Uint8Array(opened);
7732
+ }
7733
+
7734
+ /** sc1.<iv>.<ciphertext>, authenticated over "<environmentId>/<NAME>". */
7735
+ async function decryptSecret(dek, blob, aad) {
7736
+ var parts = splitBlob(blob, "sc1", 2);
7737
+ var key = await crypto.subtle.importKey("raw", dek, { name: "AES-GCM" }, false, ["decrypt"]);
7738
+ var plaintext = await crypto.subtle.decrypt(
7739
+ { name: "AES-GCM", iv: b64uToBytes(parts[0]), additionalData: utf8(aad) },
7740
+ key,
7741
+ b64uToBytes(parts[1])
7742
+ );
7743
+ return fromUtf8(new Uint8Array(plaintext));
7744
+ }
7745
+
7746
+ function importPrivateJwk(jwkText) {
7747
+ return crypto.subtle.importKey(
7748
+ "jwk",
7749
+ JSON.parse(jwkText),
7750
+ { name: "ECDH", namedCurve: "P-256" },
7751
+ true,
7752
+ ["deriveBits"]
7753
+ );
7754
+ }
7755
+
7756
+ /** skt_<id>_<pkcs8 base64url>: a service token carries its own private key. */
7757
+ async function importToken(token) {
7758
+ var match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(String(token).trim());
7759
+ if (!match) throw new Error("not a valid seekrit service token");
7760
+ var privateKey = await crypto.subtle.importKey(
7761
+ "pkcs8",
7762
+ b64uToBytes(match[2]),
7763
+ { name: "ECDH", namedCurve: "P-256" },
7764
+ true,
7765
+ ["deriveBits"]
7766
+ );
7767
+ return { principalId: match[1], privateKey: privateKey };
7768
+ }
7769
+
7770
+ // ── Shamir over GF(2^8) (must match packages/crypto/src/shamir.ts) ──────
7771
+ function gfMul(a, b) {
7772
+ var result = 0;
7773
+ var x = a;
7774
+ var y = b;
7775
+ for (var i = 0; i < 8; i++) {
7776
+ if (y & 1) result ^= x;
7777
+ var high = x & 0x80;
7778
+ x = (x << 1) & 0xff;
7779
+ if (high) x ^= 0x1b;
7780
+ y >>= 1;
7781
+ }
7782
+ return result & 0xff;
7783
+ }
7784
+ function gfPow(base, exponent) {
7785
+ var result = 1;
7786
+ for (var i = 0; i < exponent; i++) result = gfMul(result, base);
7787
+ return result;
7788
+ }
7789
+ function gfInv(value) {
7790
+ if (value === 0) throw new Error("no inverse for 0");
7791
+ return gfPow(value, 254);
7792
+ }
7793
+ /**
7794
+ * Lagrange interpolation at x=0 over the shares. Each share is
7795
+ * [x, y0, y1, ...]; the secret is the vector of y-intercepts.
7796
+ */
7797
+ function combineShares(shares) {
7798
+ if (shares.length === 0) throw new Error("no shares");
7799
+ var length = shares[0].length - 1;
7800
+ var out = new Uint8Array(length);
7801
+ for (var byte = 0; byte < length; byte++) {
7802
+ var acc = 0;
7803
+ for (var i = 0; i < shares.length; i++) {
7804
+ var xi = shares[i][0];
7805
+ var yi = shares[i][byte + 1];
7806
+ var numerator = 1;
7807
+ var denominator = 1;
7808
+ for (var j = 0; j < shares.length; j++) {
7809
+ if (i === j) continue;
7810
+ var xj = shares[j][0];
7811
+ numerator = gfMul(numerator, xj);
7812
+ denominator = gfMul(denominator, xi ^ xj);
7813
+ }
7814
+ acc ^= gfMul(yi, gfMul(numerator, gfInv(denominator)));
7815
+ }
7816
+ out[byte] = acc;
7817
+ }
7818
+ return out;
7819
+ }
7820
+
7821
+ // ── verification ────────────────────────────────────────────────────────
7822
+ async function verifyArchive(archive) {
7823
+ var problems = [];
7824
+ var truncated = [];
7825
+ var data = archive.data || {};
7826
+ var declared = {};
7827
+ for (var i = 0; i < archive.manifest.sections.length; i++) {
7828
+ var header = archive.manifest.sections[i];
7829
+ declared[header.name] = true;
7830
+ if (header.truncated) truncated.push(header.name);
7831
+ if (!(header.name in data)) {
7832
+ problems.push("section " + header.name + " is missing");
7833
+ continue;
7834
+ }
7835
+ var value = data[header.name] === undefined ? null : data[header.name];
7836
+ var digest = await sha256Hex(canonicalJson(value === undefined ? null : value));
7837
+ if (digest !== header.digest || sectionCount(value) !== header.count) {
7838
+ problems.push("section " + header.name + " does not match its digest");
7839
+ }
7840
+ }
7841
+ Object.keys(data).forEach(function (key) {
7842
+ if (!declared[key]) problems.push("section " + key + " is present but undeclared");
7843
+ });
7844
+ var manifestDigest = await sha256Hex(canonicalJson(archive.manifest.sections));
7845
+ if (manifestDigest !== archive.manifest.digest) {
7846
+ problems.push("the manifest digest does not cover its sections");
7847
+ }
7848
+
7849
+ var signature = "unsigned";
7850
+ var note = "";
7851
+ if (archive.signature) {
7852
+ try {
7853
+ var publicKey = await crypto.subtle.importKey(
7854
+ "raw",
7855
+ hexToBytes(archive.signature.publicKey),
7856
+ { name: "Ed25519" },
7857
+ false,
7858
+ ["verify"]
7859
+ );
7860
+ var valid = await crypto.subtle.verify(
7861
+ { name: "Ed25519" },
7862
+ publicKey,
7863
+ hexToBytes(archive.signature.value),
7864
+ utf8(canonicalJson(archive.manifest))
7865
+ );
7866
+ signature = valid ? "valid" : "invalid";
7867
+ if (!valid) problems.push("the signature does not match the manifest");
7868
+ } catch (err) {
7869
+ signature = "unverifiable";
7870
+ note = "this browser cannot check Ed25519 signatures";
7871
+ }
7872
+ }
7873
+ return {
7874
+ integrityOk: problems.length === 0,
7875
+ problems: problems,
7876
+ truncated: truncated,
7877
+ signature: signature,
7878
+ signatureNote: note,
7879
+ };
7880
+ }
7881
+
7882
+ // ── unlocking + decrypting ──────────────────────────────────────────────
7883
+ async function unlockPassphrase(archive, passphrase) {
7884
+ var keyMaterial = archive.data.keyMaterial;
7885
+ if (!keyMaterial) {
7886
+ throw new Error(
7887
+ "this archive carries no key material - use a service token, a private key, or a custodian quorum"
7888
+ );
7889
+ }
7890
+ var jwk = await decryptPrivateKeyBlob(passphrase, keyMaterial.encryptedPrivateKey);
7891
+ return {
7892
+ principalId: keyMaterial.principalId,
7893
+ privateKey: await importPrivateJwk(jwk),
7894
+ how: keyMaterial.principalType + " " + keyMaterial.principalId,
7895
+ };
7896
+ }
7897
+
7898
+ async function unlockJwk(jwkText) {
7899
+ return {
7900
+ principalId: null,
7901
+ privateKey: await importPrivateJwk(jwkText.trim()),
7902
+ how: "a private key",
7903
+ };
7904
+ }
7905
+
7906
+ async function unlockToken(token) {
7907
+ var opened = await importToken(token);
7908
+ return {
7909
+ principalId: opened.principalId,
7910
+ privateKey: opened.privateKey,
7911
+ how: "service token " + opened.principalId,
7912
+ };
7913
+ }
7914
+
7915
+ /**
7916
+ * Reconstruct the org recovery key from pasted share files. Accepts several
7917
+ * JSON objects one after another, which is what you get from concatenating
7918
+ * the output of "seekrit archive share".
7919
+ */
7920
+ async function unlockShares(archive, text) {
7921
+ var shares = [];
7922
+ var pattern = /"share"\\s*:\\s*"([0-9a-f]+)"/g;
7923
+ var match = pattern.exec(text);
7924
+ while (match !== null) {
7925
+ shares.push(hexToBytes(match[1]));
7926
+ match = pattern.exec(text);
7927
+ }
7928
+ if (shares.length === 0) throw new Error("no shares found in that text");
7929
+ var jwk = fromUtf8(combineShares(shares));
7930
+ return {
7931
+ principalId: archive.manifest.org.id,
7932
+ privateKey: await importPrivateJwk(jwk),
7933
+ how: shares.length + " custodian shares",
7934
+ };
7935
+ }
7936
+
7937
+ function envLabel(archive, env) {
7938
+ var i;
7939
+ if (env.groupId) {
7940
+ for (i = 0; i < archive.data.groups.length; i++) {
7941
+ if (archive.data.groups[i].id === env.groupId) {
7942
+ return archive.data.groups[i].slug + "@" + env.slug;
7943
+ }
7944
+ }
7945
+ return env.groupId + "@" + env.slug;
7946
+ }
7947
+ for (i = 0; i < archive.data.applications.length; i++) {
7948
+ if (archive.data.applications[i].id === env.applicationId) {
7949
+ return archive.data.applications[i].slug + "/" + env.slug;
7950
+ }
7951
+ }
7952
+ return env.applicationId + "/" + env.slug;
7953
+ }
7954
+
7955
+ async function decryptAll(archive, key) {
7956
+ var environments = [];
7957
+ var skipped = [];
7958
+ for (var e = 0; e < archive.data.environments.length; e++) {
7959
+ var env = archive.data.environments[e];
7960
+ var dek = null;
7961
+ for (var g = 0; g < archive.data.environmentKeys.length; g++) {
7962
+ var grant = archive.data.environmentKeys[g];
7963
+ if (grant.environmentId !== env.id) continue;
7964
+ if (key.principalId !== null && grant.principalId !== key.principalId) continue;
7965
+ try {
7966
+ dek = await unwrap(grant.wrappedDek, key.privateKey);
7967
+ break;
7968
+ } catch (err) {
7969
+ // Not this key's grant. A principal with no grant here is normal.
7970
+ }
7971
+ }
7972
+ if (dek === null) {
7973
+ skipped.push(envLabel(archive, env));
7974
+ continue;
7975
+ }
7976
+ var values = [];
7977
+ var failures = [];
7978
+ for (var s = 0; s < archive.data.secrets.length; s++) {
7979
+ var secret = archive.data.secrets[s];
7980
+ if (secret.environmentId !== env.id) continue;
7981
+ try {
7982
+ values.push({
7983
+ name: secret.name,
7984
+ value: await decryptSecret(
7985
+ dek,
7986
+ secret.ciphertext,
7987
+ secret.environmentId + "/" + secret.name
7988
+ ),
7989
+ });
7990
+ } catch (err) {
7991
+ failures.push(secret.name);
7992
+ }
7993
+ }
7994
+ values.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
7995
+ environments.push({ label: envLabel(archive, env), values: values, failures: failures });
7996
+ }
7997
+ return { environments: environments, skipped: skipped };
7998
+ }
7999
+
8000
+ /**
8001
+ * dotenv quoting, mirroring packages/core/src/dotenv.ts — single quotes when
8002
+ * they are safe (literal, so a JSON credential survives untouched), double
8003
+ * quotes with written-out escapes otherwise.
8004
+ */
8005
+ function needsQuoting(value) {
8006
+ return /[\\s"'\`$\\\\#]/.test(value) || value === "";
8007
+ }
8008
+ function dotenvQuote(value) {
8009
+ if (!needsQuoting(value)) return value;
8010
+ if (value.indexOf("'") === -1 && !/[\\n\\r]/.test(value)) return "'" + value + "'";
8011
+ return (
8012
+ '"' +
8013
+ value
8014
+ .replace(/\\\\/g, "\\\\\\\\")
8015
+ .replace(/"/g, '\\\\"')
8016
+ .replace(/\\n/g, "\\\\n")
8017
+ .replace(/\\r/g, "\\\\r") +
8018
+ '"'
8019
+ );
8020
+ }
8021
+ function toDotenv(values) {
8022
+ return values
8023
+ .map(function (entry) { return entry.name + "=" + dotenvQuote(entry.value); })
8024
+ .join("\\n");
8025
+ }
8026
+
8027
+ var api = {
8028
+ canonicalJson: canonicalJson,
8029
+ verifyArchive: verifyArchive,
8030
+ unlockPassphrase: unlockPassphrase,
8031
+ unlockToken: unlockToken,
8032
+ unlockJwk: unlockJwk,
8033
+ unlockShares: unlockShares,
8034
+ decryptAll: decryptAll,
8035
+ toDotenv: toDotenv,
8036
+ };
8037
+ globalThis.seekritOffline = api;
8038
+
8039
+ // Everything above is pure and runs headless; the test suite drives it through
8040
+ // globalThis.seekritOffline. Only what follows needs a document.
8041
+ if (typeof document === "undefined") return;
8042
+
8043
+ var archive = null;
8044
+ var el = function (id) { return document.getElementById(id); };
8045
+ var text = function (value) { return document.createTextNode(String(value)); };
8046
+
8047
+ function node(tag, className, content) {
8048
+ var element = document.createElement(tag);
8049
+ if (className) element.className = className;
8050
+ if (content !== undefined) element.appendChild(text(content));
8051
+ return element;
8052
+ }
8053
+
8054
+ function setStatus(message, kind) {
8055
+ var status = el("status");
8056
+ status.className = kind ? kind : "muted";
8057
+ status.textContent = message;
8058
+ }
8059
+
8060
+ async function loadArchive(fileText) {
8061
+ try {
8062
+ archive = JSON.parse(fileText);
8063
+ } catch (err) {
8064
+ archive = null;
8065
+ setStatus("that file is not JSON", "bad");
8066
+ return;
8067
+ }
8068
+ if (!archive || archive.format !== "seekrit-archive/v1" || !archive.manifest) {
8069
+ archive = null;
8070
+ el("manifest").className = "panel bad";
8071
+ el("manifest").textContent = "not a seekrit-archive/v1 file";
8072
+ return;
8073
+ }
8074
+ var check = await verifyArchive(archive);
8075
+ var panel = el("manifest");
8076
+ panel.className = "panel";
8077
+ panel.textContent = "";
8078
+
8079
+ var list = document.createElement("dl");
8080
+ list.className = "kv";
8081
+ var rows = [
8082
+ ["organization", archive.manifest.org.name + " (" + archive.manifest.org.slug + ")"],
8083
+ ["created", archive.manifest.createdAt],
8084
+ ["exported by", archive.manifest.requestedBy.label || archive.manifest.requestedBy.actorId],
8085
+ ["producer", archive.manifest.producer.service + " / " + archive.manifest.producer.environment],
8086
+ ["environments", String(sectionCount(archive.data.environments))],
8087
+ ["secrets", String(sectionCount(archive.data.secrets))],
8088
+ ["key grants", String(sectionCount(archive.data.environmentKeys))],
8089
+ ["integrity", check.integrityOk ? "every section matches its digest" : "FAILED"],
8090
+ [
8091
+ "signature",
8092
+ check.signature === "valid"
8093
+ ? "valid, key " + archive.signature.keyId
8094
+ : check.signature + (check.signatureNote ? " (" + check.signatureNote + ")" : ""),
8095
+ ],
8096
+ ];
8097
+ rows.forEach(function (row) {
8098
+ list.appendChild(node("dt", null, row[0]));
8099
+ var value = node("dd", null, row[1]);
8100
+ if (row[0] === "integrity") value.className = check.integrityOk ? "ok" : "bad";
8101
+ if (row[0] === "signature") {
8102
+ value.className =
8103
+ check.signature === "valid" ? "ok" : check.signature === "invalid" ? "bad" : "warn";
8104
+ }
8105
+ list.appendChild(value);
8106
+ });
8107
+ panel.appendChild(list);
8108
+
8109
+ if (check.problems.length > 0) {
8110
+ var problems = document.createElement("ul");
8111
+ check.problems.forEach(function (problem) {
8112
+ problems.appendChild(node("li", "bad", problem));
8113
+ });
8114
+ panel.appendChild(problems);
8115
+ }
8116
+ if (check.truncated.length > 0) {
8117
+ panel.appendChild(
8118
+ node("p", "warn", "truncated sections: " + check.truncated.join(", "))
8119
+ );
8120
+ }
8121
+ panel.classList.remove("hidden");
8122
+ el("step2").classList.remove("hidden");
8123
+ el("key-owner").textContent = archive.data.keyMaterial
8124
+ ? "unlocks " + archive.data.keyMaterial.principalType + " " + archive.data.keyMaterial.principalId
8125
+ : "this archive carries no key material - use another method";
8126
+ setStatus("");
8127
+ }
8128
+
8129
+ function showResults(result, key) {
8130
+ var container = el("results");
8131
+ container.textContent = "";
8132
+ container.appendChild(node("h2", null, "3 - plaintext"));
8133
+ container.appendChild(
8134
+ node("p", "muted", "unlocked with " + key.how + ". Values are as stored: a \${REF} reference is expanded when an app reads it, not here.")
8135
+ );
8136
+
8137
+ result.environments.forEach(function (entry) {
8138
+ var panel = node("div", "panel env");
8139
+ panel.appendChild(node("strong", null, entry.label));
8140
+ panel.appendChild(
8141
+ node("span", "muted", " " + entry.values.length + (entry.values.length === 1 ? " secret" : " secrets"))
8142
+ );
8143
+
8144
+ var table = document.createElement("table");
8145
+ var head = document.createElement("tr");
8146
+ head.appendChild(node("th", null, "name"));
8147
+ head.appendChild(node("th", null, "value"));
8148
+ table.appendChild(head);
8149
+ entry.values.forEach(function (item) {
8150
+ var row = document.createElement("tr");
8151
+ row.appendChild(node("td", null, item.name));
8152
+ var cell = node("td", "value");
8153
+ var masked = node("span", null, "•".repeat(Math.min(24, Math.max(6, item.value.length))));
8154
+ var reveal = node("button", null, "reveal");
8155
+ reveal.style.marginLeft = "0.5rem";
8156
+ reveal.addEventListener("click", function () {
8157
+ if (reveal.textContent === "reveal") {
8158
+ masked.textContent = item.value;
8159
+ reveal.textContent = "hide";
8160
+ } else {
8161
+ masked.textContent = "•".repeat(Math.min(24, Math.max(6, item.value.length)));
8162
+ reveal.textContent = "reveal";
8163
+ }
8164
+ });
8165
+ cell.appendChild(masked);
8166
+ cell.appendChild(reveal);
8167
+ row.appendChild(cell);
8168
+ table.appendChild(row);
8169
+ });
8170
+ panel.appendChild(table);
8171
+
8172
+ if (entry.failures.length > 0) {
8173
+ panel.appendChild(node("p", "bad", "failed to decrypt: " + entry.failures.join(", ")));
8174
+ }
8175
+
8176
+ var area = document.createElement("textarea");
8177
+ area.readOnly = true;
8178
+ area.spellcheck = false;
8179
+ area.className = "hidden";
8180
+ area.value = toDotenv(entry.values);
8181
+
8182
+ var show = node("button", null, "show as .env");
8183
+ show.addEventListener("click", function () {
8184
+ area.classList.toggle("hidden");
8185
+ show.textContent = area.classList.contains("hidden") ? "show as .env" : "hide .env";
8186
+ });
8187
+ var copy = node("button", null, "copy .env");
8188
+ copy.addEventListener("click", function () {
8189
+ area.classList.remove("hidden");
8190
+ area.select();
8191
+ try {
8192
+ document.execCommand("copy");
8193
+ copy.textContent = "copied";
8194
+ } catch (err) {
8195
+ copy.textContent = "select and copy";
8196
+ }
8197
+ });
8198
+ var actions = node("div", "row");
8199
+ actions.style.marginTop = "0.75rem";
8200
+ actions.appendChild(show);
8201
+ actions.appendChild(copy);
8202
+ panel.appendChild(actions);
8203
+ panel.appendChild(area);
8204
+ container.appendChild(panel);
8205
+ });
8206
+
8207
+ if (result.skipped.length > 0) {
8208
+ container.appendChild(
8209
+ node(
8210
+ "p",
8211
+ "muted",
8212
+ "skipped " + result.skipped.length +
8213
+ (result.skipped.length === 1 ? " environment" : " environments") +
8214
+ " this key holds no grant on: " + result.skipped.join(", ")
8215
+ )
8216
+ );
8217
+ }
8218
+ if (result.environments.length === 0) {
8219
+ container.appendChild(
8220
+ node("p", "bad", "this key opens none of the environments in the archive")
8221
+ );
8222
+ }
8223
+ }
8224
+
8225
+ el("file").addEventListener("change", function (event) {
8226
+ var file = event.target.files && event.target.files[0];
8227
+ if (!file) return;
8228
+ var reader = new FileReader();
8229
+ reader.onload = function () { loadArchive(String(reader.result)); };
8230
+ reader.readAsText(file);
8231
+ });
8232
+
8233
+ var drop = el("drop");
8234
+ ["dragenter", "dragover"].forEach(function (name) {
8235
+ drop.addEventListener(name, function (event) {
8236
+ event.preventDefault();
8237
+ drop.classList.add("over");
8238
+ });
8239
+ });
8240
+ ["dragleave", "drop"].forEach(function (name) {
8241
+ drop.addEventListener(name, function (event) {
8242
+ event.preventDefault();
8243
+ drop.classList.remove("over");
8244
+ });
8245
+ });
8246
+ drop.addEventListener("drop", function (event) {
8247
+ var file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
8248
+ if (!file) return;
8249
+ var reader = new FileReader();
8250
+ reader.onload = function () { loadArchive(String(reader.result)); };
8251
+ reader.readAsText(file);
8252
+ });
8253
+
8254
+ el("method").addEventListener("change", function () {
8255
+ var method = el("method").value;
8256
+ ["passphrase", "token", "jwk", "shares"].forEach(function (name) {
8257
+ el("field-" + name).classList.toggle("hidden", name !== method);
8258
+ });
8259
+ });
8260
+
8261
+ el("decrypt").addEventListener("click", async function () {
8262
+ if (!archive) {
8263
+ setStatus("load an archive first", "warn");
8264
+ return;
8265
+ }
8266
+ setStatus("working...");
8267
+ try {
8268
+ var method = el("method").value;
8269
+ var key;
8270
+ if (method === "passphrase") key = await unlockPassphrase(archive, el("passphrase").value);
8271
+ else if (method === "token") key = await unlockToken(el("token").value);
8272
+ else if (method === "jwk") key = await unlockJwk(el("jwk").value);
8273
+ else key = await unlockShares(archive, el("shares").value);
8274
+ var result = await decryptAll(archive, key);
8275
+ showResults(result, key);
8276
+ setStatus(
8277
+ "decrypted " + result.environments.length +
8278
+ (result.environments.length === 1 ? " environment" : " environments"),
8279
+ "ok"
8280
+ );
8281
+ } catch (err) {
8282
+ setStatus(err && err.message ? err.message : "could not decrypt", "bad");
8283
+ }
8284
+ });
8285
+ })();
8286
+ <\/script>
8287
+ </body>
8288
+ </html>
8289
+ `;
8290
+ //#endregion
8291
+ //#region src/format.ts
8292
+ function shellQuote(value) {
8293
+ return `'${value.replaceAll("'", `'\\''`)}'`;
8294
+ }
8295
+ function formatSecrets(values, format) {
8296
+ const names = Object.keys(values).sort();
8297
+ switch (format) {
8298
+ case "json": return JSON.stringify(values, names, 2);
8299
+ case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
8300
+ case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
8301
+ }
8302
+ }
8303
+ //#endregion
8304
+ //#region src/archive.ts
8305
+ /**
8306
+ * Break-glass archives (docs/break-glass-export.md).
8307
+ *
8308
+ * `create` is the only subcommand that talks to the API. `info`, `verify`,
8309
+ * `share`, `decrypt`, and `decryptor` are **strictly offline**: they never build
8310
+ * a client, never read credentials, and never touch the network, because the day
8311
+ * you need them is the day seekrit may not be there. Keep it that way — a
8312
+ * `buildContext()` in any of them silently breaks the promise the feature makes.
8313
+ */
8314
+ /** Collect a repeatable option into a list. */
8315
+ function collect$5(value, acc = []) {
8316
+ acc.push(value);
8317
+ return acc;
8318
+ }
8319
+ /** "1 secret" / "2 secrets" — these lines are read by people, not parsed. */
8320
+ function count(n, noun) {
8321
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
8322
+ }
8323
+ function readArchiveFile(path) {
8324
+ let text;
8325
+ try {
8326
+ text = readFileSync(path, "utf8");
8327
+ } catch (err) {
8328
+ return fail(`cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
8329
+ }
8330
+ try {
8331
+ return parseArchive(text);
8332
+ } catch (err) {
8333
+ return fail(err instanceof Error ? err.message : String(err));
8334
+ }
8335
+ }
8336
+ function writeOut(path, contents) {
8337
+ mkdirSync(dirname(path), { recursive: true });
8338
+ writeFileSync(path, contents, { mode: 384 });
8339
+ }
8340
+ /** One line per problem, for a verification that failed. */
8341
+ function verificationProblems(result) {
8342
+ const problems = [];
8343
+ if (!result.manifestDigestOk) problems.push("the manifest's digest does not cover its sections");
8344
+ for (const name of result.badSections) problems.push(`section "${name}" does not match its digest`);
8345
+ for (const name of result.missingSections) problems.push(`section "${name}" is missing`);
8346
+ for (const name of result.undeclaredSections) problems.push(`section "${name}" is present but undeclared`);
8347
+ if (result.signature === "invalid") problems.push(`signature is invalid${result.signatureNote ? ` (${result.signatureNote})` : ""}`);
8348
+ if (result.signature === "unsigned") problems.push("archive is unsigned — integrity is checkable, provenance is not");
8349
+ if (result.signature === "unverifiable") problems.push(`signature could not be checked: ${result.signatureNote ?? "unknown reason"}`);
8350
+ return problems;
8351
+ }
8352
+ function printVerification(result) {
8353
+ printFields([
8354
+ ["integrity", result.badSections.length === 0 && result.manifestDigestOk ? "ok" : "FAILED"],
8355
+ ["signature", result.signature],
8356
+ ["truncated", result.truncatedSections.length > 0 ? result.truncatedSections.join(", ") : "no"]
8357
+ ]);
8358
+ const problems = verificationProblems(result);
8359
+ if (problems.length > 0) {
8360
+ section("problems");
8361
+ for (const problem of problems) console.log(`- ${problem}`);
8362
+ }
8363
+ }
8364
+ /** `app/env`, or `group@env` for a group-owned environment. */
8365
+ function envLabel(archive, env) {
8366
+ if (env.groupId) return `${archive.data.groups.find((g) => g.id === env.groupId)?.slug ?? env.groupId}@${env.slug}`;
8367
+ return `${archive.data.applications.find((a) => a.id === env.applicationId)?.slug ?? env.applicationId}/${env.slug}`;
8368
+ }
8369
+ /** Filesystem path for an environment's output file, mirroring its label. */
8370
+ function envPath(archive, env, extension) {
8371
+ if (env.groupId) return join("groups", archive.data.groups.find((g) => g.id === env.groupId)?.slug ?? env.groupId, `${env.slug}.${extension}`);
8372
+ return join("apps", archive.data.applications.find((a) => a.id === env.applicationId)?.slug ?? env.applicationId ?? "unknown", `${env.slug}.${extension}`);
8373
+ }
8374
+ /**
8375
+ * Recover a private key to open the archive with, from (in order) a service
8376
+ * token, a private-key JWK file, a custodian quorum, or the archive's own
8377
+ * `keyMaterial` plus a passphrase.
8378
+ */
8379
+ async function resolveDecryptKey(archive, options) {
8380
+ if (options.token) {
8381
+ if (!isServiceToken(options.token)) fail("--token expects a `skt_…` service token");
8382
+ const { tokenId, privateKey } = await parseServiceToken(options.token);
8383
+ return {
8384
+ privateKey,
8385
+ principalId: tokenId,
8386
+ how: `service token ${tokenId}`
8387
+ };
8388
+ }
8389
+ if (options.keyFile) {
8390
+ let jwk;
8391
+ try {
8392
+ jwk = readFileSync(options.keyFile, "utf8").trim();
8393
+ } catch (err) {
8394
+ fail(`cannot read ${options.keyFile}: ${err instanceof Error ? err.message : String(err)}`);
8395
+ }
8396
+ return {
8397
+ privateKey: await importPrivateKey(jwk).catch(() => fail(`${options.keyFile} is not a P-256 private key JWK`)),
8398
+ principalId: null,
8399
+ how: `private key from ${options.keyFile}`
8400
+ };
8401
+ }
8402
+ if (options.share && options.share.length > 0) {
8403
+ const shares = options.share.map((path) => readShareFile(path));
8404
+ return {
8405
+ 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)}`)),
8406
+ principalId: archive.manifest.org.id,
8407
+ how: count(shares.length, "custodian share")
8408
+ };
8409
+ }
8410
+ const keyMaterial = archive.data.keyMaterial;
8411
+ if (!keyMaterial) fail("this archive carries no key material — decrypt it with --token, --key-file, or a custodian quorum (--share)");
8412
+ return {
8413
+ 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)))),
8414
+ principalId: keyMaterial.principalId,
8415
+ how: `${keyMaterial.principalType} ${keyMaterial.principalId} (passphrase)`
8416
+ };
8417
+ }
8418
+ const SHARE_FORMAT = "seekrit-recovery-share/v1";
8419
+ function readShareFile(path) {
8420
+ let parsed;
8421
+ try {
8422
+ parsed = JSON.parse(readFileSync(path, "utf8"));
8423
+ } catch (err) {
8424
+ return fail(`cannot read share ${path}: ${err instanceof Error ? err.message : String(err)}`);
8425
+ }
8426
+ if (parsed.format !== SHARE_FORMAT || typeof parsed.share !== "string") return fail(`${path} is not a ${SHARE_FORMAT} file`);
8427
+ try {
8428
+ return hexToBytes(parsed.share);
8429
+ } catch {
8430
+ return fail(`${path} holds a malformed share`);
8431
+ }
8432
+ }
8433
+ /**
8434
+ * Decrypt what one key can reach. Environments the key holds no grant on are
8435
+ * *skipped*, not failed: an archive spans the whole org and no single principal
8436
+ * is expected to open all of it.
8437
+ */
8438
+ async function decryptArchive(archive, key, filter) {
8439
+ const environments = [];
8440
+ const skipped = [];
8441
+ const failures = [];
8442
+ for (const env of archive.data.environments) {
8443
+ const label = envLabel(archive, env);
8444
+ if (filter && label !== filter && env.slug !== filter && env.id !== filter) continue;
8445
+ const grants = archive.data.environmentKeys.filter((grant) => grant.environmentId === env.id && (key.principalId === null || grant.principalId === key.principalId));
8446
+ let dek = null;
8447
+ for (const grant of grants) try {
8448
+ dek = await unwrapDek(grant.wrappedDek, key.privateKey);
8449
+ break;
8450
+ } catch {}
8451
+ if (!dek) {
8452
+ skipped.push(label);
8453
+ continue;
8454
+ }
8455
+ const values = {};
8456
+ for (const secret of archive.data.secrets.filter((s) => s.environmentId === env.id)) try {
8457
+ values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(secret.environmentId, secret.name));
8458
+ } catch (err) {
8459
+ failures.push(`${label} ${secret.name}: ${err instanceof Error ? err.message : "failed"}`);
8460
+ }
8461
+ environments.push({
8462
+ env,
8463
+ label,
8464
+ values
8465
+ });
8466
+ }
8467
+ return {
8468
+ environments,
8469
+ skipped,
8470
+ failures
8471
+ };
8472
+ }
8473
+ function registerArchiveCommands(program) {
8474
+ const archive = program.command("archive").description("export the whole org as one signed file, and open it offline");
8475
+ 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) => {
8476
+ const ctx = buildContext();
8477
+ const ref = await resolveOrg(ctx, options.org);
8478
+ const input = {
8479
+ includeVersions: options.versions,
8480
+ includeAudit: options.audit
8481
+ };
8482
+ if (options.auditLimit !== void 0) input.auditLimit = options.auditLimit;
8483
+ const result = await ctx.client.exportArchive(ref.id, input);
8484
+ if (options.json) {
8485
+ console.log(JSON.stringify(result, null, 2));
8486
+ return;
8487
+ }
8488
+ const stamp = result.manifest.createdAt.slice(0, 10);
8489
+ const path = options.out ?? `seekrit-${result.manifest.org.slug}-${stamp}.json`;
8490
+ writeOut(path, JSON.stringify(result, null, 2));
8491
+ const check = await verifyArchive(result);
8492
+ console.error(`wrote ${path}`);
8493
+ printTable(result.manifest.sections.filter((s) => s.count > 0), [
8494
+ {
8495
+ header: "section",
8496
+ value: (s) => s.name
8497
+ },
8498
+ {
8499
+ header: "rows",
8500
+ value: (s) => String(s.count)
8501
+ },
8502
+ {
8503
+ header: "truncated",
8504
+ value: (s) => s.truncated ? "yes" : ""
8505
+ }
8506
+ ], "the archive is empty");
8507
+ console.error(check.ok ? `verified: digests ok, signed by key ${result.signature?.keyId}` : `WARNING: ${verificationProblems(check).join("; ")}`);
8508
+ console.error("keep it with `seekrit archive decryptor` — together they open without seekrit, offline.");
8509
+ });
8510
+ archive.command("info <file>").description("summarize an archive (offline)").option("--json", "print the manifest as JSON").action(async (file, options) => {
8511
+ const parsed = readArchiveFile(file);
8512
+ const check = await verifyArchive(parsed);
8513
+ emit(options, {
8514
+ manifest: parsed.manifest,
8515
+ verification: check
8516
+ }, () => {
8517
+ printFields([
8518
+ ["archive", parsed.manifest.archiveId],
8519
+ ["created", parsed.manifest.createdAt],
8520
+ ["org", `${parsed.manifest.org.name} (${parsed.manifest.org.slug})`],
8521
+ ["producer", `${parsed.manifest.producer.service} / ${parsed.manifest.producer.environment}`],
8522
+ ["format", parsed.manifest.producer.formatVersion],
8523
+ ["requested by", parsed.manifest.requestedBy.label ?? parsed.manifest.requestedBy.actorId],
8524
+ ["key material", parsed.data.keyMaterial ? parsed.data.keyMaterial.principalId : "none"],
8525
+ ["signature", parsed.signature ? `${parsed.signature.algorithm} / ${parsed.signature.keyId}` : "unsigned"]
8526
+ ]);
8527
+ section("sections");
8528
+ printTable(parsed.manifest.sections, [
8529
+ {
8530
+ header: "section",
8531
+ value: (s) => s.name
8532
+ },
8533
+ {
8534
+ header: "rows",
8535
+ value: (s) => String(s.count)
8536
+ },
8537
+ {
8538
+ header: "truncated",
8539
+ value: (s) => s.truncated ? "yes" : ""
8540
+ }
8541
+ ], "none");
8542
+ section("verification");
8543
+ printVerification(check);
8544
+ });
8545
+ });
8546
+ 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) => {
8547
+ const check = await verifyArchive(readArchiveFile(file), {
8548
+ expectKeyId: options.keyId,
8549
+ skipSignature: options.skipSignature
8550
+ });
8551
+ const integrityOk = check.badSections.length === 0 && check.missingSections.length === 0 && check.undeclaredSections.length === 0 && check.manifestDigestOk;
8552
+ const passed = options.skipSignature ? integrityOk : check.ok;
8553
+ emit(options, check, () => printVerification(check));
8554
+ if (!passed) process.exit(1);
8555
+ });
8556
+ 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) => {
8557
+ const parsed = readArchiveFile(file);
8558
+ if (parsed.data.recoveryShares.length === 0) fail("this archive holds no recovery shares — customer-controlled recovery is not set up");
8559
+ const key = await resolveDecryptKey(parsed, {
8560
+ token: options.token,
8561
+ keyFile: options.keyFile
8562
+ });
8563
+ const candidates = parsed.data.recoveryShares.filter((share) => key.principalId === null || share.custodianId === key.principalId);
8564
+ for (const candidate of candidates) try {
8565
+ const bytes = await unwrapRecoveryShare(candidate.wrappedShare, key.privateKey);
8566
+ const payload = {
8567
+ format: SHARE_FORMAT,
8568
+ org: {
8569
+ id: parsed.manifest.org.id,
8570
+ slug: parsed.manifest.org.slug
8571
+ },
8572
+ custodianType: candidate.custodianType,
8573
+ custodianId: candidate.custodianId,
8574
+ shareIndex: candidate.shareIndex,
8575
+ share: bytesToHex(bytes),
8576
+ note: "Sensitive: a threshold of these shares reconstructs the org recovery key, which opens every environment. Destroy this file after the ceremony."
8577
+ };
8578
+ const text = JSON.stringify(payload, null, 2);
8579
+ if (options.out) {
8580
+ writeOut(options.out, text);
8581
+ console.error(`wrote share ${candidate.shareIndex} to ${options.out}`);
8582
+ } else console.log(text);
8583
+ console.error(`combine ${parsed.data.recoveryConfig?.threshold ?? "M"} shares with: seekrit archive decrypt ${file} --share … --share …`);
8584
+ return;
8585
+ } catch {}
8586
+ fail("none of the recovery shares in this archive unwrap with that key");
8587
+ });
8588
+ 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) => {
8589
+ if (!options.out && !options.stdout) fail("choose a destination: --out <dir> to write files, or --stdout to print plaintext");
8590
+ if (![
8591
+ "dotenv",
8592
+ "json",
8593
+ "shell"
8594
+ ].includes(options.format)) fail(`unknown --format "${options.format}" (dotenv | json | shell)`);
8595
+ const parsed = readArchiveFile(file);
8596
+ const check = await verifyArchive(parsed);
8597
+ if (check.badSections.length > 0 || !check.manifestDigestOk) fail(`refusing to decrypt: ${verificationProblems(check).join("; ")} — run \`seekrit archive verify ${file}\``);
8598
+ if (check.signature !== "valid") console.error(`warning: ${verificationProblems(check).join("; ")}`);
8599
+ if (options.stdout) await confirmDestructive(options.yes, "This prints decrypted secret values to stdout, where they may land in scrollback or CI logs. Continue?");
8600
+ const key = await resolveDecryptKey(parsed, options);
8601
+ const result = await decryptArchive(parsed, key, options.env);
8602
+ console.error(`unlocked with ${key.how}`);
8603
+ const extension = options.format === "json" ? "json" : options.format === "shell" ? "sh" : "env";
8604
+ let written = 0;
8605
+ for (const entry of result.environments) {
8606
+ const body = formatSecrets(entry.values, options.format);
8607
+ if (options.out) {
8608
+ const path = join(options.out, envPath(parsed, entry.env, extension));
8609
+ writeOut(path, `${body}\n`);
8610
+ written += 1;
8611
+ console.error(`${path} (${count(Object.keys(entry.values).length, "secret")})`);
8612
+ } else {
8613
+ console.log(`# ${entry.label}`);
8614
+ console.log(body);
8615
+ console.log("");
8616
+ }
8617
+ }
8618
+ if (result.skipped.length > 0) console.error(`skipped ${count(result.skipped.length, "environment")} this key holds no grant on: ${result.skipped.join(", ")}`);
8619
+ for (const failure of result.failures) console.error(`failed: ${failure}`);
8620
+ if (options.out) console.error(`wrote ${count(written, "file")} under ${options.out}`);
8621
+ if (result.environments.length === 0) fail("nothing decrypted — this key opens none of the environments in the archive");
8622
+ console.error(`note: values are as stored — \`\${REF}\` references are expanded at read time, not here.`);
8623
+ });
8624
+ 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) => {
8625
+ writeOut(options.out, OFFLINE_DECRYPTOR_HTML);
8626
+ console.error(`wrote ${options.out}`);
8627
+ console.error("open it in any browser — it declares a Content-Security-Policy of `default-src 'none'`, so it cannot reach the network.");
8628
+ });
8629
+ }
8630
+ //#endregion
7198
8631
  //#region src/audit.ts
7199
8632
  /** The API's per-page ceiling (`auditQuerySchema.limit`). */
7200
8633
  const MAX_PAGE = 200;
@@ -7721,19 +9154,6 @@ function message(err) {
7721
9154
  return err instanceof Error ? err.message : String(err);
7722
9155
  }
7723
9156
  //#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
9157
  //#region src/gcp.ts
7738
9158
  /**
7739
9159
  * `seekrit gcp` — temporary GCP credentials via IAM Credentials
@@ -11840,6 +13260,7 @@ program.command("mcp").description("run an MCP server over stdio so AI agents ca
11840
13260
  });
11841
13261
  registerAuditCommands(program);
11842
13262
  registerAccountCommands(program);
13263
+ registerArchiveCommands(program);
11843
13264
  registerLogSinkCommands(program);
11844
13265
  registerSyncCommands(program);
11845
13266
  registerBillingCommands(program);