@happyvertical/secrets 0.87.0 → 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/README.md +142 -2
- package/dist/adapters/database.d.ts +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1069 -3
- package/dist/index.js.map +1 -1
- package/dist/shared/custody.d.ts +264 -0
- package/dist/shared/custody.d.ts.map +1 -0
- package/dist/shared/factory.d.ts +1 -1
- package/package.json +3 -3
- package/LICENSE +0 -7
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', '
|
|
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', '
|
|
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
|