@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.
- package/dist/index.js +1440 -19
- 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.
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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) — 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 · 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 · 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_…)</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 — 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);
|