@happyvertical/secrets 0.86.4 → 0.88.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 CHANGED
@@ -1,6 +1,9 @@
1
1
  import { syncSchema } from "@happyvertical/sql";
2
2
  import { createId } from "@happyvertical/utils";
3
3
  import * as crypto from "node:crypto";
4
+ import { createHash, randomUUID, sign, verify } from "node:crypto";
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+ import { spawn } from "node:child_process";
4
7
  //#region \0rolldown/runtime.js
5
8
  var __defProp = Object.defineProperty;
6
9
  var __exportAll = (all, no_symbols) => {
@@ -341,7 +344,7 @@ var DEFAULT_ROTATION_PERIOD_MS = 2160 * 60 * 60 * 1e3;
341
344
  * await store.initialize();
342
345
  *
343
346
  * // Encrypt a secret for a tenant
344
- * const envelope = await store.encrypt('tenant-123', 'api-key', 'sk_live_xxx');
347
+ * const envelope = await store.encrypt('tenant-123', 'api-key', 'synthetic-secret');
345
348
  *
346
349
  * // Decrypt
347
350
  * const { value } = await store.decrypt('tenant-123', envelope);
@@ -644,6 +647,1069 @@ var DatabaseSecretStore = class {
644
647
  }
645
648
  };
646
649
  //#endregion
650
+ //#region src/shared/custody.ts
651
+ var REDACTED = "[REDACTED]";
652
+ var MAX_TIMER_DELAY_MS = 2147483647;
653
+ var SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
654
+ var ENVIRONMENT_VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
655
+ var CREDENTIAL_PATTERNS = [
656
+ {
657
+ pattern: /\b(?:gh[pousr]|github_pat|hvwk|sk|xox[baprs])_[A-Za-z0-9_-]{16,}\b/gi,
658
+ replacement: REDACTED
659
+ },
660
+ {
661
+ pattern: /\b[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\b/g,
662
+ replacement: REDACTED
663
+ },
664
+ {
665
+ pattern: /((?:authorization|credential|password|secret|token)\s*[:=]\s*(?:bearer\s+)?)[^\s,;]+/gi,
666
+ replacement: `$1${REDACTED}`
667
+ }
668
+ ];
669
+ var environmentLock = Promise.resolve();
670
+ var environmentLockContext = new AsyncLocalStorage();
671
+ async function withEnvironmentLock(operation) {
672
+ if (environmentLockContext.getStore()) throw new CustodyError("REENTRANT_ENVIRONMENT_INJECTION", "inject", "Nested environment credential injection is not allowed");
673
+ const previous = environmentLock;
674
+ let release = () => void 0;
675
+ const current = new Promise((resolve) => {
676
+ release = resolve;
677
+ });
678
+ const queued = previous.then(() => current);
679
+ environmentLock = queued;
680
+ await previous;
681
+ try {
682
+ return await environmentLockContext.run(true, operation);
683
+ } finally {
684
+ release();
685
+ if (environmentLock === queued) environmentLock = Promise.resolve();
686
+ }
687
+ }
688
+ var CustodyError = class extends Error {
689
+ code;
690
+ stage;
691
+ details;
692
+ constructor(code, stage, message, options) {
693
+ super(redactCredentialText(message));
694
+ this.name = "CustodyError";
695
+ this.code = SAFE_IDENTIFIER.test(code) && redactCredentialText(code) === code ? code : "INVALID_CUSTODY_ERROR_CODE";
696
+ this.stage = stage;
697
+ this.details = options?.details ? redactCredentialValues(options.details) : void 0;
698
+ }
699
+ toJSON() {
700
+ return {
701
+ name: this.name,
702
+ code: this.code,
703
+ stage: this.stage,
704
+ message: this.message,
705
+ ...this.details ? { details: this.details } : {}
706
+ };
707
+ }
708
+ };
709
+ var SecretMaterial = class SecretMaterial {
710
+ #bytes;
711
+ #destroyed = false;
712
+ constructor(value) {
713
+ this.#bytes = Buffer.from(value, "utf8");
714
+ }
715
+ static fromString(value) {
716
+ if (!value) throw new CustodyError("EMPTY_SECRET_MATERIAL", "issue", "Secret material must not be empty");
717
+ return new SecretMaterial(value);
718
+ }
719
+ get destroyed() {
720
+ return this.#destroyed;
721
+ }
722
+ async use(operation) {
723
+ if (this.#destroyed) throw new CustodyError("SECRET_MATERIAL_DESTROYED", "inject", "Secret material is no longer available");
724
+ const plaintext = this.#bytes.toString("utf8");
725
+ try {
726
+ await operation(plaintext);
727
+ } catch (cause) {
728
+ if (cause instanceof CustodyError) throw new CustodyError(redactCredentialText(cause.code, [plaintext]) === cause.code ? cause.code : "INVALID_CUSTODY_ERROR_CODE", cause.stage, redactCredentialText(cause.message, [plaintext]), { details: redactCredentialValues(cause.details, [plaintext]) });
729
+ throw new CustodyError("SECRET_MATERIAL_OPERATION_FAILED", "inject", "Secret material operation failed", { cause });
730
+ }
731
+ }
732
+ destroy() {
733
+ this.#bytes.fill(0);
734
+ this.#destroyed = true;
735
+ }
736
+ toJSON() {
737
+ return REDACTED;
738
+ }
739
+ toString() {
740
+ return REDACTED;
741
+ }
742
+ [Symbol.for("nodejs.util.inspect.custom")]() {
743
+ return REDACTED;
744
+ }
745
+ };
746
+ var Ed25519CustodyReceiptAttestor = class {
747
+ name;
748
+ keyId;
749
+ #privateKey;
750
+ constructor(options) {
751
+ if (options.privateKey.type !== "private" || options.privateKey.asymmetricKeyType !== "ed25519") throw new CustodyError("INVALID_ATTESTATION_KEY", "record", "Custody attestation requires a private signing key");
752
+ this.#privateKey = options.privateKey;
753
+ this.name = options.name ?? "ed25519";
754
+ this.keyId = options.keyId;
755
+ assertSafeIdentifier(this.name, "attestor.name", "record");
756
+ assertSafeIdentifier(this.keyId, "attestor.keyId", "record");
757
+ }
758
+ async attest(payload) {
759
+ return sign(null, Buffer.from(payload), this.#privateKey).toString("base64url");
760
+ }
761
+ };
762
+ var InMemoryCustodyLedger = class {
763
+ #receipts = [];
764
+ #events = [];
765
+ async recordIssuance(receipt, event) {
766
+ if (event.type !== "finalization-pending") throw new CustodyError("INVALID_CUSTODY_EVENT", "record", "Prepared receipt requires a finalization-pending event");
767
+ if (this.#receipts.some((item) => item.receiptId === receipt.receiptId)) throw new CustodyError("DUPLICATE_CUSTODY_RECEIPT", "record", "Custody receipt already exists");
768
+ if (receipt.replacesReceiptId) {
769
+ const predecessor = this.#receipts.find((item) => item.receiptId === receipt.replacesReceiptId);
770
+ const predecessorTerminal = this.#events.some((item) => item.receiptId === receipt.replacesReceiptId && [
771
+ "revoked",
772
+ "expired",
773
+ "replaced"
774
+ ].includes(item.type));
775
+ const predecessorIssued = this.#events.some((item) => item.receiptId === receipt.replacesReceiptId && item.type === "issued");
776
+ const existingChild = this.#receipts.some((item) => item.replacesReceiptId === receipt.replacesReceiptId && !this.#events.some((event) => event.receiptId === item.receiptId && [
777
+ "revoked",
778
+ "expired",
779
+ "replaced"
780
+ ].includes(event.type)));
781
+ if (!predecessor || !predecessorIssued || predecessorTerminal || existingChild) throw new CustodyError("CUSTODY_ROTATION_CONFLICT", "record", "Custody predecessor is not active and replaceable");
782
+ }
783
+ this.#receipts.push(structuredClone(receipt));
784
+ this.#events.push(structuredClone(event));
785
+ }
786
+ async appendEvent(event) {
787
+ const existing = this.#events.find((item) => item.eventId === event.eventId);
788
+ if (existing) {
789
+ if (sameCustodyEvent(existing, event)) return;
790
+ throw new CustodyError("CUSTODY_EVENT_ID_CONFLICT", "record", "Custody event ID is already bound to different content");
791
+ }
792
+ this.#events.push(structuredClone(event));
793
+ }
794
+ async commitIssuance(receiptId, event, followup) {
795
+ if (event.type !== "issued" || followup !== void 0 && followup.type !== "retirement-pending") throw new CustodyError("INVALID_CUSTODY_EVENT", "record", "Issuance commit requires issued and optional retirement-pending events");
796
+ if (!this.#receipts.some((receipt) => receipt.receiptId === receiptId)) throw new CustodyError("CUSTODY_RECEIPT_NOT_FOUND", "record", "Prepared custody receipt was not found");
797
+ if (!this.#events.some((item) => item.receiptId === receiptId && item.type === "issued")) {
798
+ this.#events.push(structuredClone(event));
799
+ if (followup) this.#events.push(structuredClone(followup));
800
+ }
801
+ }
802
+ async listReceipts() {
803
+ return structuredClone(this.#receipts);
804
+ }
805
+ async listEvents() {
806
+ return structuredClone(this.#events);
807
+ }
808
+ };
809
+ var CredentialCustody = class {
810
+ #issuer;
811
+ #verifier;
812
+ #ledger;
813
+ #attestor;
814
+ #finalizer;
815
+ #sink;
816
+ #ephemeralTtlMs;
817
+ #ephemeralRevokeRetryMs;
818
+ #orphanGraceMs;
819
+ #finalizationTakeoverMs;
820
+ #now;
821
+ #onBackgroundError;
822
+ #leaseInvalidators = /* @__PURE__ */ new Map();
823
+ #pendingTimers = /* @__PURE__ */ new Map();
824
+ constructor(options) {
825
+ assertSafeIdentifier(options.issuer.name, "issuer.name", "issue");
826
+ assertSafeIdentifier(options.verifier.name, "verifier.name", "verify");
827
+ assertSafeIdentifier(options.attestor.name, "attestor.name", "record");
828
+ assertSafeIdentifier(options.attestor.keyId, "attestor.keyId", "record");
829
+ assertSafeIdentifier(options.finalizer.name, "finalizer.name", "activate");
830
+ if (options.sink) assertSafeIdentifier(options.sink.name, "sink.name", "store");
831
+ this.#issuer = options.issuer;
832
+ this.#verifier = options.verifier;
833
+ this.#ledger = options.ledger;
834
+ this.#attestor = options.attestor;
835
+ this.#finalizer = options.finalizer;
836
+ this.#sink = options.sink;
837
+ this.#ephemeralTtlMs = options.ephemeralTtlMs ?? 5 * 6e4;
838
+ this.#ephemeralRevokeRetryMs = options.ephemeralRevokeRetryMs ?? 1e3;
839
+ this.#orphanGraceMs = options.orphanGraceMs ?? 5 * 6e4;
840
+ this.#finalizationTakeoverMs = options.finalizationTakeoverMs ?? 3e4;
841
+ if (!Number.isFinite(this.#ephemeralTtlMs) || !Number.isFinite(this.#ephemeralRevokeRetryMs) || !Number.isFinite(this.#orphanGraceMs) || !Number.isFinite(this.#finalizationTakeoverMs) || this.#ephemeralTtlMs <= 0 || this.#ephemeralRevokeRetryMs <= 0 || this.#orphanGraceMs < 0 || this.#finalizationTakeoverMs < 0) throw new CustodyError("INVALID_CUSTODY_DURATION", "issue", "Custody durations must be positive");
842
+ this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
843
+ this.#onBackgroundError = options.onBackgroundError;
844
+ }
845
+ async issue(request) {
846
+ if (request.mode === "durable" && !this.#sink) throw new CustodyError("DURABLE_SINK_REQUIRED", "store", "Durable credential issuance requires a secret sink");
847
+ if (request.replacesReceiptId && request.mode !== "durable") throw new CustodyError("DURABLE_ROTATION_REQUIRED", "issue", "Credential replacement requires durable mode");
848
+ assertSafeIdentifier(request.subject, "subject", "issue");
849
+ assertAttribution(request.attribution);
850
+ const replacedReceipt = request.replacesReceiptId ? await this.#findReceipt(request.replacesReceiptId) : void 0;
851
+ if (replacedReceipt?.mode !== void 0 && replacedReceipt.mode !== "durable") throw new CustodyError("DURABLE_ROTATION_REQUIRED", "issue", "Only durable credentials can be replaced");
852
+ const now = this.#now();
853
+ const expiresAt = request.expiresAt ?? (request.mode === "ephemeral" ? new Date(now.getTime() + this.#ephemeralTtlMs).toISOString() : void 0);
854
+ const requestedExpiryMs = expiresAt ? parseTimestamp(expiresAt, "expiresAt", "issue") : void 0;
855
+ if (requestedExpiryMs !== void 0 && requestedExpiryMs <= now.getTime()) throw new CustodyError("INVALID_EXPIRY", "issue", "Credential expiry must be in the future");
856
+ const metadata = sanitizeMetadata(request.metadata);
857
+ let issued;
858
+ let sinkRecord;
859
+ let retrieved;
860
+ let preparedReceipt;
861
+ let receiptRecorded = false;
862
+ let stage = "issue";
863
+ try {
864
+ issued = await this.#issuer.issue({
865
+ mode: request.mode,
866
+ subject: request.subject,
867
+ expiresAt,
868
+ metadata
869
+ });
870
+ assertSafeIdentifier(issued.credentialId, "credentialId", "issue");
871
+ if (issued.issuedAt) parseTimestamp(issued.issuedAt, "issuedAt", "issue");
872
+ if (issued.expiresAt) {
873
+ const providerExpiryMs = parseTimestamp(issued.expiresAt, "provider expiresAt", "issue");
874
+ if (providerExpiryMs <= now.getTime()) throw new CustodyError("INVALID_EXPIRY", "issue", "Credential provider returned an expired credential");
875
+ if (requestedExpiryMs !== void 0 && providerExpiryMs > requestedExpiryMs) throw new CustodyError("EXPIRY_BOUND_EXCEEDED", "issue", "Credential provider exceeded the requested expiry bound");
876
+ }
877
+ let material = issued.secret;
878
+ if (request.mode === "durable") {
879
+ const sink = this.#sink;
880
+ if (!sink) throw new CustodyError("DURABLE_SINK_REQUIRED", "store", "Durable credential issuance requires a secret sink");
881
+ stage = "store";
882
+ sinkRecord = await sink.store({
883
+ credentialId: issued.credentialId,
884
+ secret: issued.secret,
885
+ metadata
886
+ });
887
+ assertSinkRecord(sinkRecord, sink.name);
888
+ stage = "retrieve";
889
+ retrieved = await sink.retrieve(sinkRecord);
890
+ material = retrieved;
891
+ }
892
+ stage = "verify";
893
+ const verification = await this.#verifier.verify({
894
+ credentialId: issued.credentialId,
895
+ secret: material
896
+ });
897
+ if (!verification.verified) throw new CustodyError("CREDENTIAL_VERIFICATION_FAILED", "verify", "Credential verification failed");
898
+ if (verification.verificationId) assertSafeIdentifier(verification.verificationId, "verificationId", "verify");
899
+ const receiptId = randomUUID();
900
+ const finalizationId = randomUUID();
901
+ const unsignedReceipt = {
902
+ receiptId,
903
+ mode: request.mode,
904
+ credentialId: issued.credentialId,
905
+ subject: request.subject,
906
+ issuer: this.#issuer.name,
907
+ verifier: this.#verifier.name,
908
+ ...verification.verificationId ? { verificationId: verification.verificationId } : {},
909
+ verificationState: "verified",
910
+ issuedAt: issued.issuedAt ?? now.toISOString(),
911
+ verifiedAt: this.#now().toISOString(),
912
+ ...issued.expiresAt ?? expiresAt ? { expiresAt: issued.expiresAt ?? expiresAt } : {},
913
+ ...sinkRecord ? { sink: sinkRecord } : {},
914
+ ...request.replacesReceiptId ? { replacesReceiptId: request.replacesReceiptId } : {},
915
+ rotationRootReceiptId: replacedReceipt?.rotationRootReceiptId ?? receiptId,
916
+ attribution: { ...request.attribution },
917
+ metadata,
918
+ finalizer: this.#finalizer.name,
919
+ finalizationId
920
+ };
921
+ stage = "record";
922
+ let signature = "";
923
+ await material.use(async (plaintext) => {
924
+ signature = await this.#attestor.attest(custodyAttestationPayload(unsignedReceipt, credentialCommitment(plaintext), {
925
+ algorithm: "Ed25519",
926
+ attestor: this.#attestor.name,
927
+ keyId: this.#attestor.keyId
928
+ }));
929
+ });
930
+ if (!/^[A-Za-z0-9_-]{86}$/.test(signature) || Buffer.from(signature, "base64url").byteLength !== 64 || Buffer.from(signature, "base64url").toString("base64url") !== signature || redactCredentialText(signature) !== signature) throw new CustodyError("INVALID_CUSTODY_ATTESTATION", "record", "Custody attestor returned an invalid signature");
931
+ const receipt = {
932
+ ...unsignedReceipt,
933
+ attestation: {
934
+ algorithm: "Ed25519",
935
+ attestor: this.#attestor.name,
936
+ keyId: this.#attestor.keyId,
937
+ signature
938
+ }
939
+ };
940
+ stage = "record";
941
+ await this.#ledger.recordIssuance(receipt, this.#event("finalization-pending", receiptId));
942
+ receiptRecorded = true;
943
+ stage = "activate";
944
+ preparedReceipt = receipt;
945
+ if (!(await this.#finalizer.prepare({
946
+ receipt: structuredClone(receipt),
947
+ secret: material
948
+ })).prepared) throw new CustodyError("CREDENTIAL_ACTIVATION_FAILED", "activate", "Credential post-attestation activation failed");
949
+ retrieved?.destroy();
950
+ retrieved = void 0;
951
+ if (request.mode === "durable") issued.secret.destroy();
952
+ stage = "activate";
953
+ await this.#finalizer.commit({ receipt: structuredClone(receipt) });
954
+ stage = "record";
955
+ const retirementRecoveryId = replacedReceipt ? randomUUID() : void 0;
956
+ const retirementPending = replacedReceipt && retirementRecoveryId ? {
957
+ ...this.#event("retirement-pending", replacedReceipt.receiptId),
958
+ eventId: retirementRecoveryId,
959
+ recoveryId: retirementRecoveryId,
960
+ credentialId: replacedReceipt.credentialId,
961
+ recoveryReceipt: structuredClone(replacedReceipt),
962
+ replacementReceiptId: receipt.receiptId
963
+ } : void 0;
964
+ await this.#ledger.commitIssuance(receipt.receiptId, this.#event("issued", receipt.receiptId), retirementPending);
965
+ const lease = this.#createLease(receipt, request.mode === "ephemeral" ? issued.secret : void 0);
966
+ if (replacedReceipt) try {
967
+ this.#leaseInvalidators.get(replacedReceipt.receiptId)?.();
968
+ await this.#revokeReceipt(replacedReceipt, "replaced", "replaced");
969
+ this.#leaseInvalidators.delete(replacedReceipt.receiptId);
970
+ await this.#ledger.appendEvent({
971
+ ...this.#event("retirement-complete", replacedReceipt.receiptId),
972
+ recoveryId: retirementRecoveryId
973
+ });
974
+ } catch (cause) {
975
+ if (retirementRecoveryId) this.#scheduleRetirement(retirementRecoveryId, replacedReceipt, receipt.receiptId);
976
+ try {
977
+ this.#onBackgroundError?.(new CustodyError("PREDECESSOR_RETIREMENT_PENDING", "revoke", "Replacement is active while predecessor retirement retries", {
978
+ cause,
979
+ details: { recoveryId: retirementRecoveryId }
980
+ }));
981
+ } catch {}
982
+ }
983
+ preparedReceipt = void 0;
984
+ return lease;
985
+ } catch (cause) {
986
+ const safeCause = await sanitizeCustodyCause(cause, [retrieved, issued?.secret]);
987
+ retrieved?.destroy();
988
+ issued?.secret.destroy();
989
+ if (issued) {
990
+ const requiresSink = request.mode === "durable";
991
+ try {
992
+ if (receiptRecorded && preparedReceipt) await this.#abortAndRevoke(preparedReceipt, `rollback after ${stage}`);
993
+ else await this.#rollback(issued.credentialId, sinkRecord, stage, requiresSink);
994
+ } catch (rollbackCause) {
995
+ const recoveryId = randomUUID();
996
+ const pendingEvent = {
997
+ ...this.#event("rollback-pending", receiptRecorded ? preparedReceipt?.receiptId : void 0, sinkRecord?.reference, `rollback after ${stage}`),
998
+ eventId: recoveryId,
999
+ recoveryId,
1000
+ credentialId: issued.credentialId,
1001
+ requiresSink,
1002
+ finalizationReceipt: preparedReceipt ? structuredClone(preparedReceipt) : void 0
1003
+ };
1004
+ try {
1005
+ await this.#ledger.appendEvent(pendingEvent);
1006
+ } catch {}
1007
+ this.#scheduleRollback(recoveryId, issued.credentialId, sinkRecord, stage, requiresSink, preparedReceipt, receiptRecorded);
1008
+ throw new CustodyError("CUSTODY_ROLLBACK_FAILED", "revoke", "Credential rollback is pending automatic retry", {
1009
+ cause: rollbackCause,
1010
+ details: {
1011
+ recoveryId,
1012
+ credentialId: issued.credentialId,
1013
+ requiresSink,
1014
+ failedStage: stage
1015
+ }
1016
+ });
1017
+ }
1018
+ }
1019
+ if (safeCause instanceof CustodyError) throw safeCause;
1020
+ throw new CustodyError(`CUSTODY_${stage.toUpperCase()}_FAILED`, stage, `Credential custody failed during ${stage}`, { cause: safeCause });
1021
+ }
1022
+ }
1023
+ async rotate(receiptId, request) {
1024
+ if ((await this.#findReceipt(receiptId)).mode !== "durable") throw new CustodyError("DURABLE_ROTATION_REQUIRED", "issue", "Only durable credentials can be rotated");
1025
+ return this.issue({
1026
+ ...request,
1027
+ mode: "durable",
1028
+ replacesReceiptId: receiptId
1029
+ });
1030
+ }
1031
+ async revoke(receiptId, reason = "recovery") {
1032
+ const receipt = await this.#findReceipt(receiptId);
1033
+ this.#leaseInvalidators.get(receiptId)?.();
1034
+ await this.#revokeReceipt(receipt, reason);
1035
+ this.#leaseInvalidators.delete(receiptId);
1036
+ }
1037
+ async recoverPendingRollbacks() {
1038
+ const events = await this.#ledger.listEvents();
1039
+ const receipts = await this.#ledger.listReceipts();
1040
+ const activeIds = new Set(events.filter((event) => event.type === "issued").map((event) => event.receiptId));
1041
+ const terminalIds = new Set(events.filter((event) => [
1042
+ "revoked",
1043
+ "expired",
1044
+ "replaced"
1045
+ ].includes(event.type)).map((event) => event.receiptId));
1046
+ const completedRollbackIds = new Set(events.filter((event) => event.type === "rollback-complete").map((event) => event.recoveryId));
1047
+ const rollbackReceiptIds = new Set(events.filter((event) => event.type === "rollback-pending" && !completedRollbackIds.has(event.recoveryId)).map((event) => event.receiptId));
1048
+ const pendingByReceipt = new Map(events.filter((event) => event.type === "finalization-pending" && event.receiptId).map((event) => [event.receiptId, event]));
1049
+ const takeoverCutoff = this.#now().getTime() - this.#finalizationTakeoverMs;
1050
+ const failures = [];
1051
+ for (const receipt of receipts.filter((item) => !activeIds.has(item.receiptId) && !terminalIds.has(item.receiptId) && !rollbackReceiptIds.has(item.receiptId) && (() => {
1052
+ const pending = pendingByReceipt.get(item.receiptId);
1053
+ if (pending) {
1054
+ const pendingAt = parseTimestamp(pending.occurredAt, "occurredAt", "reconcile");
1055
+ if (pendingAt > takeoverCutoff) {
1056
+ this.#scheduleFinalizationTakeover(item.finalizationId, pendingAt + this.#finalizationTakeoverMs);
1057
+ return false;
1058
+ }
1059
+ }
1060
+ return pending !== void 0 && parseTimestamp(pending.occurredAt, "occurredAt", "reconcile") <= takeoverCutoff;
1061
+ })())) try {
1062
+ if (await this.#finalizer.status({ receipt: structuredClone(receipt) }) === "committed") {
1063
+ const predecessor = receipt.replacesReceiptId ? receipts.find((item) => item.receiptId === receipt.replacesReceiptId) : void 0;
1064
+ const recoveryId = predecessor ? randomUUID() : void 0;
1065
+ await this.#ledger.commitIssuance(receipt.receiptId, this.#event("issued", receipt.receiptId), predecessor && recoveryId ? {
1066
+ ...this.#event("retirement-pending", predecessor.receiptId),
1067
+ eventId: recoveryId,
1068
+ recoveryId,
1069
+ credentialId: predecessor.credentialId,
1070
+ recoveryReceipt: structuredClone(predecessor),
1071
+ replacementReceiptId: receipt.receiptId
1072
+ } : void 0);
1073
+ activeIds.add(receipt.receiptId);
1074
+ if (predecessor && recoveryId) this.#scheduleRetirement(recoveryId, predecessor, receipt.receiptId);
1075
+ } else await this.#abortAndRevoke(receipt, "incomplete finalization recovery");
1076
+ } catch {
1077
+ failures.push(receipt.finalizationId);
1078
+ this.#scheduleFinalizationTakeover(receipt.finalizationId, this.#now().getTime() + this.#ephemeralRevokeRetryMs);
1079
+ }
1080
+ for (const receipt of receipts.filter((item) => item.mode === "ephemeral" && activeIds.has(item.receiptId) && !terminalIds.has(item.receiptId) && item.expiresAt !== void 0)) try {
1081
+ const deadline = parseTimestamp(receipt.expiresAt ?? "", "expiresAt", "revoke");
1082
+ if (deadline <= this.#now().getTime()) await this.#revokeReceipt(receipt, "restart expiry recovery", "expired");
1083
+ else this.#scheduleReceiptExpiry(receipt, deadline);
1084
+ } catch {
1085
+ failures.push(receipt.receiptId);
1086
+ }
1087
+ const completed = new Set(events.filter((event) => event.type === "rollback-complete").map((event) => event.recoveryId));
1088
+ const pendingRollbacks = new Map(events.filter((event) => event.type === "rollback-pending" && event.recoveryId && !completed.has(event.recoveryId)).map((event) => [event.recoveryId, event]));
1089
+ for (const pending of pendingRollbacks.values()) {
1090
+ if (!pending.credentialId || !pending.recoveryId) continue;
1091
+ const timer = this.#pendingTimers.get(pending.recoveryId);
1092
+ if (timer) clearTimeout(timer);
1093
+ this.#pendingTimers.delete(pending.recoveryId);
1094
+ try {
1095
+ assertSafeIdentifier(pending.credentialId, "credentialId", "revoke");
1096
+ if (pending.receiptId && pending.finalizationReceipt) await this.#abortAndRevoke(pending.finalizationReceipt, "pending rollback recovery");
1097
+ else await this.#rollback(pending.credentialId, void 0, "revoke", pending.requiresSink === true);
1098
+ await this.#completeRollback(pending.recoveryId, pending.credentialId, pending.sinkReference);
1099
+ if (pending.receiptId) {
1100
+ terminalIds.add(pending.receiptId);
1101
+ this.#leaseInvalidators.delete(pending.receiptId);
1102
+ }
1103
+ } catch {
1104
+ failures.push(pending.recoveryId);
1105
+ this.#scheduleRollback(pending.recoveryId, pending.credentialId, void 0, "revoke", pending.requiresSink === true, pending.finalizationReceipt, Boolean(pending.receiptId));
1106
+ }
1107
+ }
1108
+ const retired = new Set(events.filter((event) => event.type === "retirement-complete").map((event) => event.recoveryId));
1109
+ for (const pending of events.filter((event) => event.type === "retirement-pending" && event.recoveryId && !retired.has(event.recoveryId))) {
1110
+ if (!pending.recoveryId || !pending.recoveryReceipt) continue;
1111
+ if (pending.replacementReceiptId && (!activeIds.has(pending.replacementReceiptId) || terminalIds.has(pending.replacementReceiptId))) {
1112
+ await this.#ledger.appendEvent({
1113
+ ...this.#event("retirement-complete", pending.recoveryReceipt.receiptId),
1114
+ recoveryId: pending.recoveryId,
1115
+ reason: "replacement is not active"
1116
+ });
1117
+ continue;
1118
+ }
1119
+ try {
1120
+ await this.#revokeReceipt(pending.recoveryReceipt, "replacement retirement recovery", "replaced");
1121
+ await this.#ledger.appendEvent({
1122
+ ...this.#event("retirement-complete", pending.recoveryReceipt.receiptId),
1123
+ recoveryId: pending.recoveryId
1124
+ });
1125
+ this.#leaseInvalidators.delete(pending.recoveryReceipt.receiptId);
1126
+ } catch {
1127
+ failures.push(pending.recoveryId);
1128
+ this.#scheduleRetirement(pending.recoveryId, pending.recoveryReceipt, pending.replacementReceiptId);
1129
+ }
1130
+ }
1131
+ if (failures.length) throw new CustodyError("PENDING_ROLLBACK_RECOVERY_FAILED", "revoke", "One or more pending credential rollbacks could not be recovered", { details: { recoveryIds: failures } });
1132
+ }
1133
+ async recoverCredential(credentialId, options = {}) {
1134
+ assertSafeIdentifier(credentialId, "credentialId", "revoke");
1135
+ await this.#rollback(credentialId, void 0, "revoke", options.requiresSink === true);
1136
+ }
1137
+ async reconcile() {
1138
+ if (!this.#sink) throw new CustodyError("DURABLE_SINK_REQUIRED", "reconcile", "Reconciliation requires a secret sink");
1139
+ const [receipts, events, inventory] = await Promise.all([
1140
+ this.#ledger.listReceipts(),
1141
+ this.#ledger.listEvents(),
1142
+ this.#sink.inventory()
1143
+ ]);
1144
+ for (const entry of inventory) {
1145
+ assertSinkRecord(entry, this.#sink.name);
1146
+ assertSafeIdentifier(entry.credentialId, "credentialId", "reconcile");
1147
+ }
1148
+ const terminal = new Set(events.filter((event) => [
1149
+ "revoked",
1150
+ "expired",
1151
+ "replaced"
1152
+ ].includes(event.type)).map((event) => event.receiptId));
1153
+ const issued = new Set(events.filter((event) => event.type === "issued").map((event) => event.receiptId));
1154
+ for (const replacement of receipts) if (replacement.replacesReceiptId && issued.has(replacement.receiptId) && !terminal.has(replacement.receiptId)) terminal.add(replacement.replacesReceiptId);
1155
+ const active = receipts.filter((receipt) => receipt.mode === "durable" && receipt.sink !== void 0 && issued.has(receipt.receiptId) && !terminal.has(receipt.receiptId));
1156
+ const orphanCutoff = this.#now().getTime() - this.#orphanGraceMs;
1157
+ return {
1158
+ checkedAt: this.#now().toISOString(),
1159
+ orphaned: inventory.filter((entry) => parseTimestamp(entry.storedAt, "storedAt", "reconcile") <= orphanCutoff && !active.some((receipt) => sameSinkRecord(receipt.sink, entry) && entry.credentialId === receipt.credentialId)),
1160
+ missing: active.filter((receipt) => !inventory.some((entry) => sameSinkRecord(receipt.sink, entry) && entry.credentialId === receipt.credentialId))
1161
+ };
1162
+ }
1163
+ async recoverOrphans(report) {
1164
+ if (!this.#sink) throw new CustodyError("DURABLE_SINK_REQUIRED", "reconcile", "Orphan recovery requires a secret sink");
1165
+ const current = await this.reconcile();
1166
+ const requested = new Set(report.orphaned.map((entry) => sinkIdentity(entry, entry.credentialId)));
1167
+ const confirmed = current.orphaned.filter((entry) => requested.has(sinkIdentity(entry, entry.credentialId)));
1168
+ for (const orphan of confirmed) {
1169
+ assertSinkRecord(orphan, this.#sink.name);
1170
+ await this.#ledger.appendEvent(this.#event("orphan-detected", void 0, orphan.reference));
1171
+ assertSafeIdentifier(orphan.credentialId, "credentialId", "reconcile");
1172
+ try {
1173
+ await this.#issuer.revoke(orphan.credentialId, "orphan recovery");
1174
+ } catch {
1175
+ throw new CustodyError("ORPHAN_REVOCATION_FAILED", "reconcile", "Orphan credential revocation failed");
1176
+ }
1177
+ await this.#sink.remove(orphan, "orphan recovery");
1178
+ await this.#ledger.appendEvent(this.#event("orphan-removed", void 0, orphan.reference));
1179
+ }
1180
+ }
1181
+ #createLease(receipt, ephemeralMaterial) {
1182
+ let unavailable = false;
1183
+ let revocationComplete = false;
1184
+ let revocationInFlight;
1185
+ let timer;
1186
+ const revoke = async (reason = "requested", terminalType = "revoked") => {
1187
+ unavailable = true;
1188
+ if (timer) clearTimeout(timer);
1189
+ ephemeralMaterial?.destroy();
1190
+ if (revocationComplete) return;
1191
+ if (revocationInFlight) return revocationInFlight;
1192
+ revocationInFlight = this.#revokeReceipt(receipt, reason, terminalType).then(() => {
1193
+ revocationComplete = true;
1194
+ this.#leaseInvalidators.delete(receipt.receiptId);
1195
+ }).finally(() => {
1196
+ revocationInFlight = void 0;
1197
+ });
1198
+ return revocationInFlight;
1199
+ };
1200
+ const invalidate = () => {
1201
+ unavailable = true;
1202
+ if (timer) clearTimeout(timer);
1203
+ ephemeralMaterial?.destroy();
1204
+ };
1205
+ this.#leaseInvalidators.set(receipt.receiptId, invalidate);
1206
+ if (receipt.mode === "ephemeral" && receipt.expiresAt) {
1207
+ const deadline = parseTimestamp(receipt.expiresAt, "expiresAt", "issue");
1208
+ const schedule = (delay) => {
1209
+ timer = setTimeout(() => {
1210
+ const remaining = deadline - this.#now().getTime();
1211
+ if (remaining > 0) {
1212
+ schedule(Math.min(remaining, MAX_TIMER_DELAY_MS));
1213
+ return;
1214
+ }
1215
+ revoke("expired", "expired").catch((cause) => {
1216
+ try {
1217
+ this.#onBackgroundError?.(cause instanceof CustodyError ? cause : new CustodyError("EPHEMERAL_CLEANUP_FAILED", "revoke", "Ephemeral credential cleanup failed", { cause }));
1218
+ } catch {} finally {
1219
+ schedule(this.#ephemeralRevokeRetryMs);
1220
+ }
1221
+ });
1222
+ }, Math.min(Math.max(0, delay), MAX_TIMER_DELAY_MS));
1223
+ timer.unref?.();
1224
+ };
1225
+ schedule(deadline - this.#now().getTime());
1226
+ }
1227
+ return {
1228
+ receipt: structuredClone(receipt),
1229
+ withEnvironment: async (variableName, operation) => {
1230
+ this.#assertLeaseAvailable(receipt, unavailable);
1231
+ if (!ENVIRONMENT_VARIABLE.test(variableName)) throw new CustodyError("INVALID_ENVIRONMENT_VARIABLE", "inject", "Environment variable name is invalid");
1232
+ let material = ephemeralMaterial;
1233
+ if (!material) {
1234
+ if (!this.#sink || !receipt.sink) throw new CustodyError("DURABLE_SINK_REQUIRED", "retrieve", "Durable credential retrieval requires a secret sink record");
1235
+ material = await this.#sink.retrieve(receipt.sink);
1236
+ }
1237
+ try {
1238
+ this.#assertLeaseAvailable(receipt, unavailable);
1239
+ await withEnvironmentSecret(material, variableName, operation, { expiresAt: receipt.expiresAt });
1240
+ } finally {
1241
+ if (!ephemeralMaterial) material.destroy();
1242
+ }
1243
+ },
1244
+ withChildProcess: async (options) => {
1245
+ this.#assertLeaseAvailable(receipt, unavailable);
1246
+ let material = ephemeralMaterial;
1247
+ if (!material) {
1248
+ if (!this.#sink || !receipt.sink) throw new CustodyError("DURABLE_SINK_REQUIRED", "retrieve", "Durable credential retrieval requires a secret sink record");
1249
+ material = await this.#sink.retrieve(receipt.sink);
1250
+ }
1251
+ try {
1252
+ this.#assertLeaseAvailable(receipt, unavailable);
1253
+ return await runCredentialChildProcess(material, options, { expiresAt: receipt.expiresAt });
1254
+ } catch (cause) {
1255
+ if (cause instanceof CustodyError && cause.code === "CREDENTIAL_CHILD_PROCESS_CLEANUP_FAILED") {
1256
+ invalidate();
1257
+ await this.#recoverChildProcessCleanupFailure(receipt);
1258
+ }
1259
+ throw cause;
1260
+ } finally {
1261
+ if (!ephemeralMaterial) material.destroy();
1262
+ }
1263
+ },
1264
+ revoke: (reason) => revoke(reason)
1265
+ };
1266
+ }
1267
+ #assertLeaseAvailable(receipt, unavailable) {
1268
+ const expired = receipt.expiresAt !== void 0 && parseTimestamp(receipt.expiresAt, "expiresAt", "inject") <= this.#now().getTime();
1269
+ if (unavailable || expired) throw new CustodyError("CREDENTIAL_REVOKED", "inject", "Credential is no longer available");
1270
+ }
1271
+ async #revokeReceipt(receipt, reason, terminalType = "revoked") {
1272
+ const failures = [];
1273
+ try {
1274
+ await this.#issuer.revoke(receipt.credentialId, reason);
1275
+ } catch {
1276
+ failures.push("issuer");
1277
+ }
1278
+ if (!failures.length && receipt.sink && this.#sink) try {
1279
+ await this.#sink.remove(receipt.sink, reason);
1280
+ } catch {
1281
+ failures.push("sink");
1282
+ }
1283
+ if (failures.length) throw new CustodyError("CREDENTIAL_REVOCATION_FAILED", "revoke", "Credential revocation did not complete", { details: { failedOperations: failures } });
1284
+ await this.#appendEventEventually(this.#event(terminalType, receipt.receiptId, void 0, reason));
1285
+ }
1286
+ async #rollback(credentialId, sinkRecord, failedStage, requiresSink = false) {
1287
+ const failures = [];
1288
+ try {
1289
+ await this.#issuer.revoke(credentialId, `rollback after ${failedStage}`);
1290
+ } catch {
1291
+ failures.push("issuer");
1292
+ }
1293
+ if (!failures.length && requiresSink && !this.#sink) failures.push("sink");
1294
+ if (!failures.length && requiresSink && this.#sink) try {
1295
+ if (sinkRecord) await this.#sink.remove(sinkRecord, `rollback after ${failedStage}`);
1296
+ else await this.#sink.removeByCredentialId(credentialId, `rollback after ${failedStage}`);
1297
+ } catch {
1298
+ failures.push("sink");
1299
+ }
1300
+ if (failures.length) throw new CustodyError("CUSTODY_ROLLBACK_FAILED", "revoke", "Credential rollback did not complete", { details: {
1301
+ failedOperations: failures,
1302
+ failedStage
1303
+ } });
1304
+ }
1305
+ async #abortAndRevoke(receipt, reason) {
1306
+ const failures = [];
1307
+ try {
1308
+ await this.#finalizer.abort({
1309
+ receipt: structuredClone(receipt),
1310
+ reason
1311
+ });
1312
+ } catch {
1313
+ failures.push("finalizer");
1314
+ }
1315
+ try {
1316
+ await this.#revokeReceipt(receipt, reason);
1317
+ } catch {
1318
+ failures.push("credential");
1319
+ }
1320
+ if (failures.length) throw new CustodyError("CUSTODY_ROLLBACK_FAILED", "revoke", "Finalization rollback did not complete", { details: { failedOperations: failures } });
1321
+ }
1322
+ async #recoverChildProcessCleanupFailure(receipt) {
1323
+ const reason = "child process cleanup could not be verified";
1324
+ try {
1325
+ await this.#abortAndRevoke(receipt, reason);
1326
+ this.#leaseInvalidators.delete(receipt.receiptId);
1327
+ return;
1328
+ } catch (cause) {
1329
+ const recoveryId = randomUUID();
1330
+ const pendingEvent = {
1331
+ ...this.#event("rollback-pending", receipt.receiptId, receipt.sink?.reference, reason),
1332
+ eventId: recoveryId,
1333
+ recoveryId,
1334
+ credentialId: receipt.credentialId,
1335
+ requiresSink: receipt.mode === "durable",
1336
+ finalizationReceipt: structuredClone(receipt)
1337
+ };
1338
+ await this.#persistRollbackIntent(pendingEvent);
1339
+ this.#scheduleRollback(recoveryId, receipt.credentialId, receipt.sink, "inject", receipt.mode === "durable", receipt, true);
1340
+ try {
1341
+ this.#onBackgroundError?.(new CustodyError("CREDENTIAL_CHILD_PROCESS_CLEANUP_PENDING", "revoke", "Credential revocation is pending after child process cleanup failure", {
1342
+ cause,
1343
+ details: { recoveryId }
1344
+ }));
1345
+ } catch {}
1346
+ }
1347
+ }
1348
+ async #persistRollbackIntent(event) {
1349
+ for (;;) try {
1350
+ await this.#ledger.appendEvent(event);
1351
+ return;
1352
+ } catch (cause) {
1353
+ try {
1354
+ if ((await this.#ledger.listEvents()).some((existing) => existing.eventId === event.eventId && sameCustodyEvent(existing, event))) return;
1355
+ } catch {}
1356
+ try {
1357
+ this.#onBackgroundError?.(new CustodyError("ROLLBACK_INTENT_PERSISTENCE_FAILED", "record", "Credential rollback intent persistence is retrying", {
1358
+ cause,
1359
+ details: { recoveryId: event.recoveryId }
1360
+ }));
1361
+ } catch {}
1362
+ await new Promise((resolve) => {
1363
+ setTimeout(resolve, this.#ephemeralRevokeRetryMs);
1364
+ });
1365
+ }
1366
+ }
1367
+ #scheduleRollback(recoveryId, credentialId, sinkRecord, failedStage, requiresSink, finalizationReceipt, receiptRecorded = false) {
1368
+ if (this.#pendingTimers.has(recoveryId)) return;
1369
+ const timer = setTimeout(() => {
1370
+ this.#pendingTimers.delete(recoveryId);
1371
+ Promise.resolve().then(async () => {
1372
+ if (receiptRecorded && finalizationReceipt) await this.#abortAndRevoke(finalizationReceipt, `rollback after ${failedStage}`);
1373
+ else await this.#rollback(credentialId, sinkRecord, failedStage, requiresSink);
1374
+ }).then(() => this.#completeRollback(recoveryId, credentialId, sinkRecord?.reference)).then(() => {
1375
+ if (receiptRecorded && finalizationReceipt) this.#leaseInvalidators.delete(finalizationReceipt.receiptId);
1376
+ }).catch((cause) => {
1377
+ try {
1378
+ this.#onBackgroundError?.(cause instanceof CustodyError ? cause : new CustodyError("CUSTODY_ROLLBACK_FAILED", "revoke", "Credential rollback retry failed", { cause }));
1379
+ } catch {} finally {
1380
+ this.#scheduleRollback(recoveryId, credentialId, sinkRecord, failedStage, requiresSink, finalizationReceipt, receiptRecorded);
1381
+ }
1382
+ });
1383
+ }, this.#ephemeralRevokeRetryMs);
1384
+ this.#pendingTimers.set(recoveryId, timer);
1385
+ timer.unref?.();
1386
+ }
1387
+ #scheduleRetirement(recoveryId, receipt, replacementReceiptId) {
1388
+ if (this.#pendingTimers.has(recoveryId)) return;
1389
+ const timer = setTimeout(() => {
1390
+ this.#pendingTimers.delete(recoveryId);
1391
+ this.#ledger.listEvents().then(async (events) => {
1392
+ if (replacementReceiptId && (!events.some((event) => event.receiptId === replacementReceiptId && event.type === "issued") || events.some((event) => event.receiptId === replacementReceiptId && [
1393
+ "revoked",
1394
+ "expired",
1395
+ "replaced"
1396
+ ].includes(event.type)))) return;
1397
+ await this.#revokeReceipt(receipt, "replacement retirement retry", "replaced");
1398
+ this.#leaseInvalidators.delete(receipt.receiptId);
1399
+ }).then(() => this.#ledger.appendEvent({
1400
+ ...this.#event("retirement-complete", receipt.receiptId),
1401
+ recoveryId
1402
+ })).catch(() => this.#scheduleRetirement(recoveryId, receipt, replacementReceiptId));
1403
+ }, this.#ephemeralRevokeRetryMs);
1404
+ this.#pendingTimers.set(recoveryId, timer);
1405
+ timer.unref?.();
1406
+ }
1407
+ #scheduleReceiptExpiry(receipt, deadline) {
1408
+ if (this.#pendingTimers.has(receipt.receiptId)) return;
1409
+ const delay = Math.min(Math.max(0, deadline - this.#now().getTime()), MAX_TIMER_DELAY_MS);
1410
+ const timer = setTimeout(() => {
1411
+ this.#pendingTimers.delete(receipt.receiptId);
1412
+ if (deadline > this.#now().getTime()) {
1413
+ this.#scheduleReceiptExpiry(receipt, deadline);
1414
+ return;
1415
+ }
1416
+ this.#revokeReceipt(receipt, "restart expiry recovery", "expired").catch(() => this.#scheduleReceiptExpiry(receipt, deadline));
1417
+ }, delay);
1418
+ this.#pendingTimers.set(receipt.receiptId, timer);
1419
+ timer.unref?.();
1420
+ }
1421
+ #scheduleFinalizationTakeover(recoveryId, deadline) {
1422
+ if (this.#pendingTimers.has(recoveryId)) return;
1423
+ const timer = setTimeout(() => {
1424
+ this.#pendingTimers.delete(recoveryId);
1425
+ this.recoverPendingRollbacks().catch((cause) => {
1426
+ try {
1427
+ this.#onBackgroundError?.(cause instanceof CustodyError ? cause : new CustodyError("PENDING_ROLLBACK_RECOVERY_FAILED", "revoke", "Finalization takeover recovery failed", { cause }));
1428
+ } catch {}
1429
+ });
1430
+ }, Math.min(Math.max(0, deadline - this.#now().getTime()), MAX_TIMER_DELAY_MS));
1431
+ this.#pendingTimers.set(recoveryId, timer);
1432
+ timer.unref?.();
1433
+ }
1434
+ async #completeRollback(recoveryId, credentialId, sinkReference) {
1435
+ await this.#ledger.appendEvent({
1436
+ ...this.#event("rollback-complete", void 0, sinkReference, "credential rollback completed"),
1437
+ recoveryId,
1438
+ credentialId
1439
+ });
1440
+ }
1441
+ async #appendEventEventually(event) {
1442
+ try {
1443
+ await this.#ledger.appendEvent(event);
1444
+ } catch (cause) {
1445
+ const recoveryId = `event-${event.eventId}`;
1446
+ if (this.#pendingTimers.has(recoveryId)) return;
1447
+ const timer = setTimeout(() => {
1448
+ this.#pendingTimers.delete(recoveryId);
1449
+ this.#appendEventEventually(event).catch(() => void 0);
1450
+ }, this.#ephemeralRevokeRetryMs);
1451
+ this.#pendingTimers.set(recoveryId, timer);
1452
+ try {
1453
+ this.#onBackgroundError?.(new CustodyError("CUSTODY_EVENT_RECORD_FAILED", "record", "Custody terminal event recording is pending retry", { cause }));
1454
+ } catch {}
1455
+ }
1456
+ }
1457
+ async #findReceipt(receiptId) {
1458
+ assertSafeIdentifier(receiptId, "receiptId", "record");
1459
+ const receipt = (await this.#ledger.listReceipts()).find((candidate) => candidate.receiptId === receiptId);
1460
+ if (!receipt) throw new CustodyError("CUSTODY_RECEIPT_NOT_FOUND", "record", "Custody receipt was not found");
1461
+ return receipt;
1462
+ }
1463
+ #event(type, receiptId, sinkReference, reason) {
1464
+ return {
1465
+ eventId: randomUUID(),
1466
+ type,
1467
+ occurredAt: this.#now().toISOString(),
1468
+ ...receiptId ? { receiptId } : {},
1469
+ ...sinkReference ? { sinkReference } : {},
1470
+ ...reason ? { reason: redactCredentialText(reason) } : {}
1471
+ };
1472
+ }
1473
+ };
1474
+ async function withEnvironmentSecret(material, variableName, operation, bounds) {
1475
+ if (!ENVIRONMENT_VARIABLE.test(variableName)) throw new CustodyError("INVALID_ENVIRONMENT_VARIABLE", "inject", "Environment variable name is invalid");
1476
+ if (bounds?.expiresAt) {
1477
+ parseTimestamp(bounds.expiresAt, "expiresAt", "inject");
1478
+ throw new CustodyError("EXPIRING_ENVIRONMENT_CALLBACK_UNSAFE", "inject", "Expiring credentials require bounded child-process injection");
1479
+ }
1480
+ await withEnvironmentLock(async () => {
1481
+ const existed = Object.hasOwn(process.env, variableName);
1482
+ const previous = process.env[variableName];
1483
+ try {
1484
+ await material.use(async (value) => {
1485
+ process.env[variableName] = value;
1486
+ await operation();
1487
+ });
1488
+ } catch (cause) {
1489
+ if (cause instanceof CustodyError) throw cause;
1490
+ throw new CustodyError("SECRET_OPERATION_FAILED", "inject", "Bounded secret operation failed", { cause });
1491
+ } finally {
1492
+ if (existed) process.env[variableName] = previous;
1493
+ else delete process.env[variableName];
1494
+ }
1495
+ });
1496
+ }
1497
+ async function runCredentialChildProcess(material, options, bounds) {
1498
+ if (options.trust !== "cooperative-process-group" || !options.command || !ENVIRONMENT_VARIABLE.test(options.environmentVariable)) throw new CustodyError("INVALID_CHILD_PROCESS_OPTIONS", "inject", "Credential child-process options are invalid");
1499
+ if (process.platform === "win32") throw new CustodyError("CREDENTIAL_CHILD_PROCESS_UNSUPPORTED", "inject", "Credential child-process group custody is unavailable on this platform");
1500
+ const timeoutMs = options.timeoutMs ?? 6e4;
1501
+ const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
1502
+ if (!Number.isFinite(timeoutMs) || !Number.isFinite(maxOutputBytes) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS || maxOutputBytes <= 0) throw new CustodyError("INVALID_CHILD_PROCESS_BOUNDS", "inject", "Credential child-process bounds must be positive");
1503
+ let result;
1504
+ await material.use(async (secret) => {
1505
+ const expiryLimit = bounds?.expiresAt ? parseTimestamp(bounds.expiresAt, "expiresAt", "inject") - Date.now() : Number.POSITIVE_INFINITY;
1506
+ const effectiveTimeout = Math.min(timeoutMs, expiryLimit);
1507
+ if (effectiveTimeout <= 0) throw new CustodyError("CREDENTIAL_EXPIRED", "inject", "Credential expired before child-process launch");
1508
+ const childEnvironment = await withEnvironmentLock(() => ({
1509
+ ...process.env,
1510
+ ...options.environment,
1511
+ [options.environmentVariable]: secret
1512
+ }));
1513
+ result = await new Promise((resolve, reject) => {
1514
+ const child = spawn(options.command, [...options.args ?? []], {
1515
+ cwd: options.cwd,
1516
+ detached: true,
1517
+ env: childEnvironment,
1518
+ shell: false,
1519
+ stdio: [
1520
+ "ignore",
1521
+ "pipe",
1522
+ "pipe"
1523
+ ]
1524
+ });
1525
+ const stdout = [];
1526
+ const stderr = [];
1527
+ let outputBytes = 0;
1528
+ let outputExceeded = false;
1529
+ let timedOut = false;
1530
+ let settled = false;
1531
+ let escalationTimer;
1532
+ const terminateGroup = (signal) => {
1533
+ if (child.pid) try {
1534
+ process.kill(-child.pid, signal);
1535
+ return;
1536
+ } catch {}
1537
+ child.kill(signal);
1538
+ };
1539
+ const waitForGroupExit = async () => {
1540
+ if (!child.pid) return true;
1541
+ const deadline = Date.now() + 2e3;
1542
+ while (Date.now() < deadline) {
1543
+ try {
1544
+ process.kill(-child.pid, 0);
1545
+ } catch (cause) {
1546
+ if (cause.code === "ESRCH") return true;
1547
+ }
1548
+ await new Promise((next) => setTimeout(next, 10));
1549
+ }
1550
+ return false;
1551
+ };
1552
+ const beginTermination = () => {
1553
+ terminateGroup("SIGTERM");
1554
+ if (!escalationTimer) {
1555
+ escalationTimer = setTimeout(() => {
1556
+ escalationTimer = void 0;
1557
+ terminateGroup("SIGKILL");
1558
+ }, 1e3);
1559
+ escalationTimer.unref?.();
1560
+ }
1561
+ };
1562
+ const timer = setTimeout(() => {
1563
+ timedOut = true;
1564
+ beginTermination();
1565
+ }, effectiveTimeout);
1566
+ timer.unref?.();
1567
+ const capture = (target) => (chunk) => {
1568
+ outputBytes += chunk.length;
1569
+ if (outputBytes > maxOutputBytes) {
1570
+ timedOut = true;
1571
+ outputExceeded = true;
1572
+ target.length = 0;
1573
+ beginTermination();
1574
+ return;
1575
+ }
1576
+ if (outputExceeded) return;
1577
+ target.push(Buffer.from(chunk));
1578
+ };
1579
+ child.stdout.on("data", capture(stdout));
1580
+ child.stderr.on("data", capture(stderr));
1581
+ child.once("error", () => {
1582
+ if (settled) return;
1583
+ settled = true;
1584
+ clearTimeout(timer);
1585
+ if (escalationTimer) clearTimeout(escalationTimer);
1586
+ terminateGroup("SIGKILL");
1587
+ reject(new CustodyError("CREDENTIAL_CHILD_PROCESS_FAILED", "inject", "Credential child process could not start"));
1588
+ });
1589
+ child.once("close", async (exitCode, signal) => {
1590
+ if (settled) return;
1591
+ settled = true;
1592
+ clearTimeout(timer);
1593
+ if (escalationTimer) clearTimeout(escalationTimer);
1594
+ terminateGroup("SIGTERM");
1595
+ terminateGroup("SIGKILL");
1596
+ if (!await waitForGroupExit()) {
1597
+ reject(new CustodyError("CREDENTIAL_CHILD_PROCESS_CLEANUP_FAILED", "inject", "Credential child process group cleanup could not be verified"));
1598
+ return;
1599
+ }
1600
+ resolve({
1601
+ exitCode,
1602
+ signal,
1603
+ stdout: outputExceeded ? REDACTED : redactCredentialText(Buffer.concat(stdout).toString("utf8"), [secret]),
1604
+ stderr: outputExceeded ? REDACTED : redactCredentialText(Buffer.concat(stderr).toString("utf8"), [secret]),
1605
+ timedOut
1606
+ });
1607
+ });
1608
+ });
1609
+ });
1610
+ if (!result) throw new CustodyError("CREDENTIAL_CHILD_PROCESS_FAILED", "inject", "Credential child process did not return a result");
1611
+ return result;
1612
+ }
1613
+ async function verifyCustodyReceiptAttestation(receipt, material, publicKey) {
1614
+ if (receipt.attestation.algorithm !== "Ed25519" || publicKey.type !== "public" || publicKey.asymmetricKeyType !== "ed25519" || !/^[A-Za-z0-9_-]{86}$/.test(receipt.attestation.signature) || Buffer.from(receipt.attestation.signature, "base64url").byteLength !== 64 || Buffer.from(receipt.attestation.signature, "base64url").toString("base64url") !== receipt.attestation.signature) return false;
1615
+ let verified = false;
1616
+ await material.use((plaintext) => {
1617
+ const payload = custodyAttestationPayload(receipt, credentialCommitment(plaintext));
1618
+ try {
1619
+ verified = verify(null, Buffer.from(payload), publicKey, Buffer.from(receipt.attestation.signature, "base64url"));
1620
+ } catch {
1621
+ verified = false;
1622
+ }
1623
+ });
1624
+ return verified;
1625
+ }
1626
+ function redactCredentialText(input, knownSecrets = []) {
1627
+ let output = input;
1628
+ for (const secret of knownSecrets.filter(Boolean)) output = output.replaceAll(secret, REDACTED);
1629
+ for (const { pattern, replacement } of CREDENTIAL_PATTERNS) output = output.replace(pattern, replacement);
1630
+ return output;
1631
+ }
1632
+ function redactCredentialValues(value, knownSecrets = []) {
1633
+ if (value instanceof SecretMaterial) return REDACTED;
1634
+ if (typeof value === "string") return redactCredentialText(value, knownSecrets);
1635
+ if (Array.isArray(value)) return value.map((entry) => redactCredentialValues(entry, knownSecrets));
1636
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, entry]) => [redactCredentialText(key, knownSecrets), redactCredentialValues(entry, knownSecrets)]));
1637
+ return value;
1638
+ }
1639
+ async function sanitizeCustodyCause(cause, materials) {
1640
+ if (!(cause instanceof CustodyError)) return cause;
1641
+ const knownSecrets = [];
1642
+ for (const material of materials) {
1643
+ if (!material || material.destroyed) continue;
1644
+ await material.use((value) => {
1645
+ knownSecrets.push(value);
1646
+ });
1647
+ }
1648
+ return new CustodyError(redactCredentialText(cause.code, knownSecrets) === cause.code ? cause.code : "INVALID_CUSTODY_ERROR_CODE", cause.stage, redactCredentialText(cause.message, knownSecrets), cause.details ? { details: redactCredentialValues(cause.details, knownSecrets) } : void 0);
1649
+ }
1650
+ function credentialCommitment(plaintext) {
1651
+ return createHash("sha256").update(plaintext).digest("base64url");
1652
+ }
1653
+ function custodyAttestationPayload(receipt, commitment, identity) {
1654
+ const { attestation, ...unsigned } = receipt;
1655
+ return JSON.stringify(canonicalize({
1656
+ schema: "happyvertical.credential-custody-attestation.v1",
1657
+ receipt: unsigned,
1658
+ attestation: identity ?? {
1659
+ algorithm: attestation.algorithm,
1660
+ attestor: attestation.attestor,
1661
+ keyId: attestation.keyId
1662
+ },
1663
+ credentialCommitment: commitment
1664
+ }));
1665
+ }
1666
+ function canonicalize(value) {
1667
+ if (Array.isArray(value)) return value.map(canonicalize);
1668
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => [key, canonicalize(entry)]));
1669
+ return value;
1670
+ }
1671
+ function sameCustodyEvent(left, right) {
1672
+ return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
1673
+ }
1674
+ function assertAttribution(attribution) {
1675
+ assertSafeIdentifier(attribution.actor, "attribution.actor", "issue");
1676
+ assertSafeIdentifier(attribution.runtime, "attribution.runtime", "issue");
1677
+ assertSafeIdentifier(attribution.session, "attribution.session", "issue");
1678
+ }
1679
+ function assertSinkRecord(record, sinkName) {
1680
+ if (record.sinkName !== sinkName) throw new CustodyError("SINK_IDENTITY_MISMATCH", "store", "Secret sink returned a mismatched identity");
1681
+ assertSafeIdentifier(record.sinkName, "sinkName", "store");
1682
+ assertSafeIdentifier(record.reference, "sinkReference", "store");
1683
+ assertSafeIdentifier(record.version, "sinkVersion", "store");
1684
+ if (!Number.isFinite(new Date(record.storedAt).getTime())) throw new CustodyError("INVALID_SINK_TIMESTAMP", "store", "Secret sink returned an invalid timestamp");
1685
+ }
1686
+ function assertSafeIdentifier(value, field, stage) {
1687
+ if (!SAFE_IDENTIFIER.test(value) || redactCredentialText(value) !== value) throw new CustodyError("UNSAFE_CUSTODY_IDENTIFIER", stage, `Custody ${field} must be a non-secret identifier`);
1688
+ }
1689
+ function sanitizeMetadata(metadata) {
1690
+ return Object.freeze(Object.fromEntries(Object.entries(metadata ?? {}).map(([key, value]) => {
1691
+ if (redactCredentialText(key) !== key) throw new CustodyError("UNSAFE_CUSTODY_METADATA_KEY", "issue", "Custody metadata key must not contain credential material");
1692
+ return [key, redactCredentialText(value)];
1693
+ })));
1694
+ }
1695
+ function parseTimestamp(value, field, stage) {
1696
+ const timestamp = new Date(value).getTime();
1697
+ if (!Number.isFinite(timestamp)) throw new CustodyError("INVALID_CUSTODY_TIMESTAMP", stage, `Custody ${field} timestamp is invalid`);
1698
+ return timestamp;
1699
+ }
1700
+ function sameSinkRecord(left, right) {
1701
+ return left.sinkName === right.sinkName && left.reference === right.reference && left.version === right.version && left.storedAt === right.storedAt;
1702
+ }
1703
+ function sinkIdentity(record, credentialId) {
1704
+ return [
1705
+ record.sinkName,
1706
+ record.reference,
1707
+ record.version,
1708
+ record.storedAt,
1709
+ credentialId ?? ""
1710
+ ].join("\0");
1711
+ }
1712
+ //#endregion
647
1713
  //#region src/shared/factory.ts
648
1714
  /**
649
1715
  * Type guard for database options
@@ -688,7 +1754,7 @@ function isAzureKeyVaultOptions(opts) {
688
1754
  * });
689
1755
  *
690
1756
  * // Encrypt a secret
691
- * const envelope = await store.encrypt('tenant-123', 'api-key', 'sk_live_xxx');
1757
+ * const envelope = await store.encrypt('tenant-123', 'api-key', 'synthetic-secret');
692
1758
  *
693
1759
  * // Decrypt
694
1760
  * const decrypted = await store.decrypt('tenant-123', envelope);
@@ -711,6 +1777,6 @@ async function getSecretStore(options) {
711
1777
  /** @internal */
712
1778
  var PACKAGE_VERSION_INITIALIZED = true;
713
1779
  //#endregion
714
- export { AMKUnavailableError, DatabaseSecretStore, DecryptionError, EncryptionError, EnvelopeEncryption, InvalidKeyFormatError, KeyNotFoundError, KeyRotationError, PACKAGE_VERSION_INITIALIZED, SecretError, StoreNotInitializedError, TenantKeyMissingError, getSecretStore, isAWSKMSOptions, isAzureKeyVaultOptions, isDatabaseOptions, isVaultOptions };
1780
+ export { AMKUnavailableError, CredentialCustody, CustodyError, DatabaseSecretStore, DecryptionError, Ed25519CustodyReceiptAttestor, EncryptionError, EnvelopeEncryption, InMemoryCustodyLedger, InvalidKeyFormatError, KeyNotFoundError, KeyRotationError, PACKAGE_VERSION_INITIALIZED, SecretError, SecretMaterial, StoreNotInitializedError, TenantKeyMissingError, getSecretStore, isAWSKMSOptions, isAzureKeyVaultOptions, isDatabaseOptions, isVaultOptions, redactCredentialText, redactCredentialValues, runCredentialChildProcess, verifyCustodyReceiptAttestation, withEnvironmentSecret };
715
1781
 
716
1782
  //# sourceMappingURL=index.js.map