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