@centient/secrets 0.9.0 → 0.11.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 +424 -8
- package/dist/audit/hmac-chain.d.ts +117 -0
- package/dist/audit/hmac-chain.d.ts.map +1 -0
- package/dist/audit/hmac-chain.js +811 -0
- package/dist/audit/hmac-chain.js.map +1 -0
- package/dist/audit/ocsf.d.ts +108 -0
- package/dist/audit/ocsf.d.ts.map +1 -0
- package/dist/audit/ocsf.js +204 -0
- package/dist/audit/ocsf.js.map +1 -0
- package/dist/cli/hidden-prompt.d.ts.map +1 -1
- package/dist/cli/hidden-prompt.js +55 -10
- package/dist/cli/hidden-prompt.js.map +1 -1
- package/dist/cli/messages.d.ts +40 -8
- package/dist/cli/messages.d.ts.map +1 -1
- package/dist/cli/messages.js +34 -8
- package/dist/cli/messages.js.map +1 -1
- package/dist/cli/secrets-cli.d.ts.map +1 -1
- package/dist/cli/secrets-cli.js +191 -32
- package/dist/cli/secrets-cli.js.map +1 -1
- package/dist/client.d.ts +26 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +129 -0
- package/dist/client.js.map +1 -0
- package/dist/compatibility.d.ts +199 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +419 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/crypto/darwin-keychain.d.ts +394 -0
- package/dist/crypto/darwin-keychain.d.ts.map +1 -0
- package/dist/crypto/darwin-keychain.js +793 -0
- package/dist/crypto/darwin-keychain.js.map +1 -0
- package/dist/crypto/vault-common.d.ts +55 -12
- package/dist/crypto/vault-common.d.ts.map +1 -1
- package/dist/crypto/vault-common.js +74 -71
- package/dist/crypto/vault-common.js.map +1 -1
- package/dist/environment/EnvironmentManager.d.ts.map +1 -1
- package/dist/environment/EnvironmentManager.js +5 -10
- package/dist/environment/EnvironmentManager.js.map +1 -1
- package/dist/index.d.ts +37 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -6
- package/dist/index.js.map +1 -1
- package/dist/key-providers/keychain-provider.d.ts +30 -2
- package/dist/key-providers/keychain-provider.d.ts.map +1 -1
- package/dist/key-providers/keychain-provider.js +29 -5
- package/dist/key-providers/keychain-provider.js.map +1 -1
- package/dist/key-providers/onepassword-provider.d.ts +65 -5
- package/dist/key-providers/onepassword-provider.d.ts.map +1 -1
- package/dist/key-providers/onepassword-provider.js +159 -67
- package/dist/key-providers/onepassword-provider.js.map +1 -1
- package/dist/key-providers/op-cli.d.ts +78 -0
- package/dist/key-providers/op-cli.d.ts.map +1 -0
- package/dist/key-providers/op-cli.js +123 -0
- package/dist/key-providers/op-cli.js.map +1 -0
- package/dist/key-providers/passphrase-provider.d.ts +20 -0
- package/dist/key-providers/passphrase-provider.d.ts.map +1 -1
- package/dist/key-providers/passphrase-provider.js +49 -0
- package/dist/key-providers/passphrase-provider.js.map +1 -1
- package/dist/key-providers/probe.d.ts +46 -0
- package/dist/key-providers/probe.d.ts.map +1 -0
- package/dist/key-providers/probe.js +70 -0
- package/dist/key-providers/probe.js.map +1 -0
- package/dist/key-providers/types.d.ts +51 -1
- package/dist/key-providers/types.d.ts.map +1 -1
- package/dist/platform/agent-detect.d.ts +39 -3
- package/dist/platform/agent-detect.d.ts.map +1 -1
- package/dist/platform/agent-detect.js +55 -9
- package/dist/platform/agent-detect.js.map +1 -1
- package/dist/platform/session-context.d.ts +77 -0
- package/dist/platform/session-context.d.ts.map +1 -0
- package/dist/platform/session-context.js +83 -0
- package/dist/platform/session-context.js.map +1 -0
- package/dist/policies/audit-trail.d.ts +25 -0
- package/dist/policies/audit-trail.d.ts.map +1 -0
- package/dist/policies/audit-trail.js +31 -0
- package/dist/policies/audit-trail.js.map +1 -0
- package/dist/secret-state.d.ts +54 -0
- package/dist/secret-state.d.ts.map +1 -0
- package/dist/secret-state.js +2 -0
- package/dist/secret-state.js.map +1 -0
- package/dist/sinks/index.d.ts +9 -0
- package/dist/sinks/index.d.ts.map +1 -0
- package/dist/sinks/index.js +5 -0
- package/dist/sinks/index.js.map +1 -0
- package/dist/sinks/jsonl.d.ts +16 -0
- package/dist/sinks/jsonl.d.ts.map +1 -0
- package/dist/sinks/jsonl.js +51 -0
- package/dist/sinks/jsonl.js.map +1 -0
- package/dist/sinks/otel.d.ts +22 -0
- package/dist/sinks/otel.d.ts.map +1 -0
- package/dist/sinks/otel.js +155 -0
- package/dist/sinks/otel.js.map +1 -0
- package/dist/sinks/queue.d.ts +12 -0
- package/dist/sinks/queue.d.ts.map +1 -0
- package/dist/sinks/queue.js +89 -0
- package/dist/sinks/queue.js.map +1 -0
- package/dist/sinks/syslog.d.ts +17 -0
- package/dist/sinks/syslog.d.ts.map +1 -0
- package/dist/sinks/syslog.js +103 -0
- package/dist/sinks/syslog.js.map +1 -0
- package/dist/sinks/types.d.ts +27 -0
- package/dist/sinks/types.d.ts.map +1 -0
- package/dist/sinks/types.js +18 -0
- package/dist/sinks/types.js.map +1 -0
- package/dist/vault/policy.d.ts +64 -34
- package/dist/vault/policy.d.ts.map +1 -1
- package/dist/vault/policy.js +109 -67
- package/dist/vault/policy.js.map +1 -1
- package/dist/vault/session-vault-errors.d.ts +72 -4
- package/dist/vault/session-vault-errors.d.ts.map +1 -1
- package/dist/vault/session-vault-errors.js +95 -4
- package/dist/vault/session-vault-errors.js.map +1 -1
- package/dist/vault/session-vault.d.ts +145 -2
- package/dist/vault/session-vault.d.ts.map +1 -1
- package/dist/vault/session-vault.js +548 -72
- package/dist/vault/session-vault.js.map +1 -1
- package/dist/vault/sidecar.d.ts +9 -0
- package/dist/vault/sidecar.d.ts.map +1 -1
- package/dist/vault/sidecar.js +72 -3
- package/dist/vault/sidecar.js.map +1 -1
- package/dist/vault/types.d.ts +58 -10
- package/dist/vault/types.d.ts.map +1 -1
- package/dist/vault/types.js.map +1 -1
- package/dist/vault/vault-env.d.ts +43 -50
- package/dist/vault/vault-env.d.ts.map +1 -1
- package/dist/vault/vault-env.js +141 -62
- package/dist/vault/vault-env.js.map +1 -1
- package/dist/vault/vault-gpg.d.ts +6 -1
- package/dist/vault/vault-gpg.d.ts.map +1 -1
- package/dist/vault/vault-gpg.js +10 -8
- package/dist/vault/vault-gpg.js.map +1 -1
- package/dist/vault/vault-libsecret.d.ts +6 -1
- package/dist/vault/vault-libsecret.d.ts.map +1 -1
- package/dist/vault/vault-libsecret.js +16 -14
- package/dist/vault/vault-libsecret.js.map +1 -1
- package/dist/vault/vault-onepassword.d.ts +119 -0
- package/dist/vault/vault-onepassword.d.ts.map +1 -0
- package/dist/vault/vault-onepassword.js +271 -0
- package/dist/vault/vault-onepassword.js.map +1 -0
- package/dist/vault/vault-utils.d.ts +37 -3
- package/dist/vault/vault-utils.d.ts.map +1 -1
- package/dist/vault/vault-utils.js +65 -3
- package/dist/vault/vault-utils.js.map +1 -1
- package/dist/vault/vault-windows.d.ts +7 -2
- package/dist/vault/vault-windows.d.ts.map +1 -1
- package/dist/vault/vault-windows.js +21 -16
- package/dist/vault/vault-windows.js.map +1 -1
- package/dist/vault/vault.d.ts +120 -9
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +222 -139
- package/dist/vault/vault.js.map +1 -1
- package/package.json +8 -2
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const HMAC_KEY_BYTES = 32;
|
|
3
|
+
const SHA256_HEX_LENGTH = 64;
|
|
4
|
+
const MAX_CANONICAL_DEPTH = 64;
|
|
5
|
+
const MAX_CANONICAL_BYTES = 1024 * 1024;
|
|
6
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
7
|
+
const CREDENTIAL_AUDIT_TYPE_UIDS = new Set([300402, 300404, 300499]);
|
|
8
|
+
/** Configuration/state errors produced by the writer side of the chain. */
|
|
9
|
+
export class AuditChainError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "AuditChainError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isPlainRecord(value) {
|
|
18
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const prototype = Object.getPrototypeOf(value);
|
|
22
|
+
return prototype === Object.prototype || prototype === null;
|
|
23
|
+
}
|
|
24
|
+
function assertWellFormedUnicode(value) {
|
|
25
|
+
for (let index = 0; index < value.length; index++) {
|
|
26
|
+
const code = value.charCodeAt(index);
|
|
27
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
28
|
+
const next = value.charCodeAt(index + 1);
|
|
29
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
30
|
+
throw new TypeError("JCS input contains an unpaired UTF-16 surrogate");
|
|
31
|
+
}
|
|
32
|
+
index++;
|
|
33
|
+
}
|
|
34
|
+
else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
35
|
+
throw new TypeError("JCS input contains an unpaired UTF-16 surrogate");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* RFC 8785 JSON Canonicalization Scheme for the JSON data model used by OCSF.
|
|
41
|
+
*
|
|
42
|
+
* Objects are plain data objects, names are ordered by UTF-16 code units, and
|
|
43
|
+
* strings/numbers use ECMAScript JSON serialization. Values outside I-JSON
|
|
44
|
+
* (cycles, sparse arrays, accessors, non-finite numbers, lone surrogates,
|
|
45
|
+
* `undefined`, `bigint`, functions, symbols) fail closed.
|
|
46
|
+
*/
|
|
47
|
+
export function canonicalizeAuditEvent(value) {
|
|
48
|
+
const ancestors = new Set();
|
|
49
|
+
const visit = (current, depth) => {
|
|
50
|
+
if (depth > MAX_CANONICAL_DEPTH) {
|
|
51
|
+
throw new TypeError("JCS input exceeds the maximum nesting depth");
|
|
52
|
+
}
|
|
53
|
+
if (current === null)
|
|
54
|
+
return "null";
|
|
55
|
+
switch (typeof current) {
|
|
56
|
+
case "boolean":
|
|
57
|
+
return current ? "true" : "false";
|
|
58
|
+
case "number": {
|
|
59
|
+
if (!Number.isFinite(current)) {
|
|
60
|
+
throw new TypeError("JCS input contains a non-finite number");
|
|
61
|
+
}
|
|
62
|
+
return JSON.stringify(current);
|
|
63
|
+
}
|
|
64
|
+
case "string": {
|
|
65
|
+
assertWellFormedUnicode(current);
|
|
66
|
+
return JSON.stringify(current);
|
|
67
|
+
}
|
|
68
|
+
case "object":
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
throw new TypeError(`JCS cannot serialize a ${typeof current} value`);
|
|
72
|
+
}
|
|
73
|
+
if (ancestors.has(current)) {
|
|
74
|
+
throw new TypeError("JCS input contains a cycle");
|
|
75
|
+
}
|
|
76
|
+
ancestors.add(current);
|
|
77
|
+
try {
|
|
78
|
+
if (Array.isArray(current)) {
|
|
79
|
+
const items = [];
|
|
80
|
+
for (let index = 0; index < current.length; index++) {
|
|
81
|
+
if (!Object.prototype.hasOwnProperty.call(current, index)) {
|
|
82
|
+
throw new TypeError("JCS input contains a sparse array");
|
|
83
|
+
}
|
|
84
|
+
items.push(visit(current[index], depth + 1));
|
|
85
|
+
}
|
|
86
|
+
return `[${items.join(",")}]`;
|
|
87
|
+
}
|
|
88
|
+
if (!isPlainRecord(current)) {
|
|
89
|
+
throw new TypeError("JCS input must contain only plain JSON objects");
|
|
90
|
+
}
|
|
91
|
+
if (Object.getOwnPropertySymbols(current).length > 0) {
|
|
92
|
+
throw new TypeError("JCS input contains a symbol-keyed property");
|
|
93
|
+
}
|
|
94
|
+
const descriptors = Object.getOwnPropertyDescriptors(current);
|
|
95
|
+
const keys = Object.keys(current).sort();
|
|
96
|
+
const members = [];
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
assertWellFormedUnicode(key);
|
|
99
|
+
const descriptor = descriptors[key];
|
|
100
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
101
|
+
throw new TypeError("JCS input contains an accessor property");
|
|
102
|
+
}
|
|
103
|
+
members.push(`${JSON.stringify(key)}:${visit(descriptor.value, depth + 1)}`);
|
|
104
|
+
}
|
|
105
|
+
return `{${members.join(",")}}`;
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
ancestors.delete(current);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const canonical = visit(value, 0);
|
|
112
|
+
if (Buffer.byteLength(canonical, "utf8") > MAX_CANONICAL_BYTES) {
|
|
113
|
+
throw new TypeError("JCS input exceeds the maximum canonical byte length");
|
|
114
|
+
}
|
|
115
|
+
return canonical;
|
|
116
|
+
}
|
|
117
|
+
function assertIdentifier(value, field) {
|
|
118
|
+
if (typeof value !== "string" || !IDENTIFIER_PATTERN.test(value)) {
|
|
119
|
+
throw new AuditChainError(field === "chainId" ? "INVALID_CHAIN_ID" : "INVALID_KEY_ID", `${field} must be 1-128 safe ASCII characters`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function copyKey(key) {
|
|
123
|
+
if (!(key instanceof Uint8Array) || key.byteLength !== HMAC_KEY_BYTES) {
|
|
124
|
+
throw new AuditChainError("INVALID_KEY", `audit chain key must be exactly ${HMAC_KEY_BYTES} bytes`);
|
|
125
|
+
}
|
|
126
|
+
return Buffer.from(key);
|
|
127
|
+
}
|
|
128
|
+
function isSha256Hex(value) {
|
|
129
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
130
|
+
}
|
|
131
|
+
function isCredentialAuditTypeUid(value) {
|
|
132
|
+
return typeof value === "number" && CREDENTIAL_AUDIT_TYPE_UIDS.has(value);
|
|
133
|
+
}
|
|
134
|
+
function makeFingerprint(value) {
|
|
135
|
+
return {
|
|
136
|
+
algorithm_id: 99,
|
|
137
|
+
algorithm: "HMAC-SHA-256",
|
|
138
|
+
encoding_id: 1,
|
|
139
|
+
serialization_id: 2,
|
|
140
|
+
value,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function snapshotCheckpoint(checkpoint) {
|
|
144
|
+
// Read every caller-owned field exactly once, then validate and operate only
|
|
145
|
+
// on this plain snapshot. Accessor-backed arrays must not change identity
|
|
146
|
+
// between validation and use.
|
|
147
|
+
try {
|
|
148
|
+
const chainId = checkpoint.chainId;
|
|
149
|
+
const sequenceNumber = checkpoint.sequenceNumber;
|
|
150
|
+
const eventUid = checkpoint.eventUid;
|
|
151
|
+
const eventHash = checkpoint.eventHash;
|
|
152
|
+
const typeUid = checkpoint.typeUid;
|
|
153
|
+
const keyId = checkpoint.keyId;
|
|
154
|
+
const rawUsedKeyIds = checkpoint.usedKeyIds;
|
|
155
|
+
const rawUsedKeyFingerprints = checkpoint.usedKeyFingerprints;
|
|
156
|
+
if (!Array.isArray(rawUsedKeyIds) || !Array.isArray(rawUsedKeyFingerprints)) {
|
|
157
|
+
throw new AuditChainError("INVALID_CHECKPOINT", "checkpoint key history must use arrays");
|
|
158
|
+
}
|
|
159
|
+
const candidate = {
|
|
160
|
+
chainId,
|
|
161
|
+
sequenceNumber,
|
|
162
|
+
eventUid,
|
|
163
|
+
eventHash,
|
|
164
|
+
typeUid,
|
|
165
|
+
keyId,
|
|
166
|
+
usedKeyIds: Array.from(rawUsedKeyIds),
|
|
167
|
+
usedKeyFingerprints: Array.from(rawUsedKeyFingerprints),
|
|
168
|
+
};
|
|
169
|
+
validateCheckpoint(candidate);
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
...candidate,
|
|
172
|
+
usedKeyIds: Object.freeze([...candidate.usedKeyIds]),
|
|
173
|
+
usedKeyFingerprints: Object.freeze([...candidate.usedKeyFingerprints]),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof AuditChainError && error.code === "INVALID_CHECKPOINT") {
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
throw new AuditChainError("INVALID_CHECKPOINT", "audit chain checkpoint could not be snapshotted");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function validateCheckpoint(checkpoint) {
|
|
184
|
+
try {
|
|
185
|
+
assertIdentifier(checkpoint.chainId, "chainId");
|
|
186
|
+
assertIdentifier(checkpoint.keyId, "keyId");
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
throw new AuditChainError("INVALID_CHECKPOINT", error instanceof Error ? error.message : "checkpoint identifier is invalid");
|
|
190
|
+
}
|
|
191
|
+
if (!Number.isSafeInteger(checkpoint.sequenceNumber) ||
|
|
192
|
+
checkpoint.sequenceNumber < 1 ||
|
|
193
|
+
typeof checkpoint.eventUid !== "string" ||
|
|
194
|
+
checkpoint.eventUid.length === 0 ||
|
|
195
|
+
!isSha256Hex(checkpoint.eventHash) ||
|
|
196
|
+
!isCredentialAuditTypeUid(checkpoint.typeUid) ||
|
|
197
|
+
!Array.isArray(checkpoint.usedKeyIds) ||
|
|
198
|
+
checkpoint.usedKeyIds.length === 0 ||
|
|
199
|
+
!Array.isArray(checkpoint.usedKeyFingerprints) ||
|
|
200
|
+
checkpoint.usedKeyFingerprints.length !== checkpoint.usedKeyIds.length) {
|
|
201
|
+
throw new AuditChainError("INVALID_CHECKPOINT", "audit chain checkpoint is malformed");
|
|
202
|
+
}
|
|
203
|
+
const used = new Set();
|
|
204
|
+
const usedFingerprints = new Set();
|
|
205
|
+
for (let index = 0; index < checkpoint.usedKeyIds.length; index++) {
|
|
206
|
+
const keyId = checkpoint.usedKeyIds[index];
|
|
207
|
+
const keyFingerprint = checkpoint.usedKeyFingerprints[index];
|
|
208
|
+
try {
|
|
209
|
+
assertIdentifier(keyId, "keyId");
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
throw new AuditChainError("INVALID_CHECKPOINT", "checkpoint contains an invalid key ID");
|
|
213
|
+
}
|
|
214
|
+
if (used.has(keyId)) {
|
|
215
|
+
throw new AuditChainError("INVALID_CHECKPOINT", "checkpoint contains duplicate key IDs");
|
|
216
|
+
}
|
|
217
|
+
used.add(keyId);
|
|
218
|
+
if (!isSha256Hex(keyFingerprint) || usedFingerprints.has(keyFingerprint)) {
|
|
219
|
+
throw new AuditChainError("INVALID_CHECKPOINT", "checkpoint contains an invalid or duplicate key fingerprint");
|
|
220
|
+
}
|
|
221
|
+
usedFingerprints.add(keyFingerprint);
|
|
222
|
+
}
|
|
223
|
+
if (!used.has(checkpoint.keyId) ||
|
|
224
|
+
checkpoint.usedKeyIds[checkpoint.usedKeyIds.length - 1] !== checkpoint.keyId) {
|
|
225
|
+
throw new AuditChainError("INVALID_CHECKPOINT", "checkpoint keyId must be the final used key ID");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function deepFreeze(value, seen = new Set()) {
|
|
229
|
+
if (typeof value !== "object" || value === null || seen.has(value))
|
|
230
|
+
return value;
|
|
231
|
+
seen.add(value);
|
|
232
|
+
for (const child of Object.values(value)) {
|
|
233
|
+
deepFreeze(child, seen);
|
|
234
|
+
}
|
|
235
|
+
return Object.freeze(value);
|
|
236
|
+
}
|
|
237
|
+
function unsignedEventForHash(event) {
|
|
238
|
+
const attestations = event.attestation_list;
|
|
239
|
+
if (!Array.isArray(attestations) || attestations.length !== 1) {
|
|
240
|
+
throw new TypeError("record-integrity event must contain one attestation");
|
|
241
|
+
}
|
|
242
|
+
const current = attestations[0];
|
|
243
|
+
if (!isPlainRecord(current)) {
|
|
244
|
+
throw new TypeError("record-integrity attestation must be an object");
|
|
245
|
+
}
|
|
246
|
+
const { fingerprint: _fingerprint, ...unsignedAttestation } = current;
|
|
247
|
+
return {
|
|
248
|
+
...event,
|
|
249
|
+
attestation_list: [unsignedAttestation],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function hmacSha256(key, canonical) {
|
|
253
|
+
return createHmac("sha256", key).update(canonical, "utf8").digest();
|
|
254
|
+
}
|
|
255
|
+
function hmacHex(key, canonical) {
|
|
256
|
+
return hmacSha256(key, canonical).toString("hex");
|
|
257
|
+
}
|
|
258
|
+
function keyMaterialFingerprint(chainId, key) {
|
|
259
|
+
return createHmac("sha256", key)
|
|
260
|
+
.update("centient.audit-chain/key-fingerprint/v1\0", "utf8")
|
|
261
|
+
.update(chainId, "utf8")
|
|
262
|
+
.digest("hex");
|
|
263
|
+
}
|
|
264
|
+
function buildUnsignedEvent(event, chainId, keyId, sequenceNumber, previous) {
|
|
265
|
+
if (event.attestation_list !== undefined || event.metadata.sequence !== undefined) {
|
|
266
|
+
throw new AuditChainError("EVENT_ALREADY_CHAINED", "audit chain append expects an event without record-integrity state");
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
...event,
|
|
270
|
+
metadata: {
|
|
271
|
+
...event.metadata,
|
|
272
|
+
sequence: sequenceNumber,
|
|
273
|
+
profiles: ["record_integrity"],
|
|
274
|
+
},
|
|
275
|
+
unmapped: {
|
|
276
|
+
...event.unmapped,
|
|
277
|
+
"centient.audit_chain.key_id": keyId,
|
|
278
|
+
},
|
|
279
|
+
attestation_list: [
|
|
280
|
+
{
|
|
281
|
+
chain_uid: chainId,
|
|
282
|
+
...(previous
|
|
283
|
+
? {
|
|
284
|
+
prev_event: {
|
|
285
|
+
uid: previous.eventUid,
|
|
286
|
+
type_uid: previous.typeUid,
|
|
287
|
+
fingerprint: makeFingerprint(previous.eventHash),
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
: {}),
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/** Create one synchronous, process-local HMAC-SHA-256 audit chain. */
|
|
296
|
+
export function createHmacAuditChain(options) {
|
|
297
|
+
// Snapshot caller-owned options once. Re-reading a mutable object or getter
|
|
298
|
+
// after validation could otherwise change the identity of a live chain.
|
|
299
|
+
const chainId = options.chainId;
|
|
300
|
+
const initialKeyId = options.keyId;
|
|
301
|
+
const initialKey = options.key;
|
|
302
|
+
const checkpoint = options.checkpoint;
|
|
303
|
+
assertIdentifier(chainId, "chainId");
|
|
304
|
+
assertIdentifier(initialKeyId, "keyId");
|
|
305
|
+
let key = copyKey(initialKey);
|
|
306
|
+
let keyId = initialKeyId;
|
|
307
|
+
let keyFingerprint = keyMaterialFingerprint(chainId, key);
|
|
308
|
+
let destroyed = false;
|
|
309
|
+
let state = null;
|
|
310
|
+
// `seen*` also reserve keys selected by rotate() before they emit an event.
|
|
311
|
+
// The aligned `emitted*` arrays are the durable subset in checkpoints.
|
|
312
|
+
const seenKeyIds = new Set();
|
|
313
|
+
const seenKeyFingerprints = new Set();
|
|
314
|
+
const emittedKeyIds = [];
|
|
315
|
+
const emittedKeyFingerprints = [];
|
|
316
|
+
if (checkpoint !== undefined) {
|
|
317
|
+
try {
|
|
318
|
+
const trustedCheckpoint = snapshotCheckpoint(checkpoint);
|
|
319
|
+
if (trustedCheckpoint.chainId !== chainId) {
|
|
320
|
+
throw new AuditChainError("CHAIN_ID_MISMATCH", "checkpoint chainId does not match the configured chainId");
|
|
321
|
+
}
|
|
322
|
+
for (let index = 0; index < trustedCheckpoint.usedKeyIds.length; index++) {
|
|
323
|
+
const usedKeyId = trustedCheckpoint.usedKeyIds[index];
|
|
324
|
+
const usedKeyFingerprint = trustedCheckpoint.usedKeyFingerprints[index];
|
|
325
|
+
seenKeyIds.add(usedKeyId);
|
|
326
|
+
seenKeyFingerprints.add(usedKeyFingerprint);
|
|
327
|
+
emittedKeyIds.push(usedKeyId);
|
|
328
|
+
emittedKeyFingerprints.push(usedKeyFingerprint);
|
|
329
|
+
}
|
|
330
|
+
if (seenKeyIds.has(keyId) && keyId !== trustedCheckpoint.keyId) {
|
|
331
|
+
throw new AuditChainError("KEY_ID_REUSE", "a resumed chain cannot reuse an earlier key ID");
|
|
332
|
+
}
|
|
333
|
+
const checkpointKeyFingerprint = trustedCheckpoint.usedKeyFingerprints.at(-1);
|
|
334
|
+
if (keyId === trustedCheckpoint.keyId &&
|
|
335
|
+
keyFingerprint !== checkpointKeyFingerprint) {
|
|
336
|
+
throw new AuditChainError("CHECKPOINT_KEY_MISMATCH", "the configured key does not match the checkpoint's current key ID");
|
|
337
|
+
}
|
|
338
|
+
if (keyId !== trustedCheckpoint.keyId &&
|
|
339
|
+
seenKeyFingerprints.has(keyFingerprint)) {
|
|
340
|
+
throw new AuditChainError("KEY_MATERIAL_REUSE", "a resumed chain cannot reuse historical key material under a fresh ID");
|
|
341
|
+
}
|
|
342
|
+
seenKeyIds.add(keyId);
|
|
343
|
+
seenKeyFingerprints.add(keyFingerprint);
|
|
344
|
+
state = {
|
|
345
|
+
sequenceNumber: trustedCheckpoint.sequenceNumber,
|
|
346
|
+
eventUid: trustedCheckpoint.eventUid,
|
|
347
|
+
eventHash: trustedCheckpoint.eventHash,
|
|
348
|
+
typeUid: trustedCheckpoint.typeUid,
|
|
349
|
+
keyId: trustedCheckpoint.keyId,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
key.fill(0);
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
seenKeyIds.add(keyId);
|
|
359
|
+
seenKeyFingerprints.add(keyFingerprint);
|
|
360
|
+
}
|
|
361
|
+
const assertOpen = () => {
|
|
362
|
+
if (destroyed) {
|
|
363
|
+
throw new AuditChainError("CHAIN_DESTROYED", "audit chain has been destroyed");
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
return {
|
|
367
|
+
append(event) {
|
|
368
|
+
assertOpen();
|
|
369
|
+
const sequenceNumber = (state?.sequenceNumber ?? 0) + 1;
|
|
370
|
+
if (!Number.isSafeInteger(sequenceNumber)) {
|
|
371
|
+
throw new AuditChainError("SEQUENCE_EXHAUSTED", "audit chain sequence exhausted the safe integer range");
|
|
372
|
+
}
|
|
373
|
+
const unsignedCandidate = buildUnsignedEvent(event, chainId, keyId, sequenceNumber, state);
|
|
374
|
+
let canonical;
|
|
375
|
+
let unsignedClone;
|
|
376
|
+
try {
|
|
377
|
+
canonical = canonicalizeAuditEvent(unsignedCandidate);
|
|
378
|
+
unsignedClone = JSON.parse(canonical);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
throw new AuditChainError("CANONICALIZATION_FAILED", error instanceof Error ? error.message : "audit event canonicalization failed");
|
|
382
|
+
}
|
|
383
|
+
const eventHash = hmacHex(key, canonical);
|
|
384
|
+
const unsignedAttestation = unsignedClone.attestation_list[0];
|
|
385
|
+
const signed = {
|
|
386
|
+
...unsignedClone,
|
|
387
|
+
attestation_list: [
|
|
388
|
+
{
|
|
389
|
+
...unsignedAttestation,
|
|
390
|
+
fingerprint: makeFingerprint(eventHash),
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
};
|
|
394
|
+
// Commit state before handing the immutable record to a caller/sink. A
|
|
395
|
+
// later sink failure must leave an observable sequence gap, not roll the
|
|
396
|
+
// chain back and silently erase the missing record.
|
|
397
|
+
state = {
|
|
398
|
+
sequenceNumber,
|
|
399
|
+
eventUid: signed.metadata.uid,
|
|
400
|
+
eventHash,
|
|
401
|
+
typeUid: signed.type_uid,
|
|
402
|
+
keyId,
|
|
403
|
+
};
|
|
404
|
+
if (emittedKeyIds[emittedKeyIds.length - 1] !== keyId) {
|
|
405
|
+
emittedKeyIds.push(keyId);
|
|
406
|
+
emittedKeyFingerprints.push(keyFingerprint);
|
|
407
|
+
}
|
|
408
|
+
return deepFreeze(signed);
|
|
409
|
+
},
|
|
410
|
+
rotate(next) {
|
|
411
|
+
assertOpen();
|
|
412
|
+
const nextKeyId = next.keyId;
|
|
413
|
+
const nextKeyInput = next.key;
|
|
414
|
+
assertIdentifier(nextKeyId, "keyId");
|
|
415
|
+
if (seenKeyIds.has(nextKeyId)) {
|
|
416
|
+
throw new AuditChainError("KEY_ID_REUSE", "audit chain key IDs cannot be reused");
|
|
417
|
+
}
|
|
418
|
+
const nextKey = copyKey(nextKeyInput);
|
|
419
|
+
const nextKeyFingerprint = keyMaterialFingerprint(chainId, nextKey);
|
|
420
|
+
if (seenKeyFingerprints.has(nextKeyFingerprint)) {
|
|
421
|
+
nextKey.fill(0);
|
|
422
|
+
throw new AuditChainError("KEY_MATERIAL_REUSE", "audit chain key material cannot be reused");
|
|
423
|
+
}
|
|
424
|
+
key.fill(0);
|
|
425
|
+
key = nextKey;
|
|
426
|
+
keyId = nextKeyId;
|
|
427
|
+
keyFingerprint = nextKeyFingerprint;
|
|
428
|
+
seenKeyIds.add(keyId);
|
|
429
|
+
seenKeyFingerprints.add(keyFingerprint);
|
|
430
|
+
},
|
|
431
|
+
checkpoint() {
|
|
432
|
+
assertOpen();
|
|
433
|
+
if (!state)
|
|
434
|
+
return null;
|
|
435
|
+
return snapshotCheckpoint({
|
|
436
|
+
chainId,
|
|
437
|
+
sequenceNumber: state.sequenceNumber,
|
|
438
|
+
eventUid: state.eventUid,
|
|
439
|
+
eventHash: state.eventHash,
|
|
440
|
+
typeUid: state.typeUid,
|
|
441
|
+
keyId: state.keyId,
|
|
442
|
+
usedKeyIds: [...emittedKeyIds],
|
|
443
|
+
usedKeyFingerprints: [...emittedKeyFingerprints],
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
destroy() {
|
|
447
|
+
if (destroyed)
|
|
448
|
+
return;
|
|
449
|
+
key.fill(0);
|
|
450
|
+
destroyed = true;
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function verificationFailure(code, message, index, sequenceNumber) {
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
error: {
|
|
458
|
+
code,
|
|
459
|
+
message,
|
|
460
|
+
index,
|
|
461
|
+
...(sequenceNumber !== undefined ? { sequenceNumber } : {}),
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function parseFingerprint(value) {
|
|
466
|
+
if (!isPlainRecord(value)) {
|
|
467
|
+
return { ok: false, code: "MALFORMED_EVENT", message: "fingerprint must be an object" };
|
|
468
|
+
}
|
|
469
|
+
if (value.algorithm_id !== 99 || value.algorithm !== "HMAC-SHA-256") {
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
code: "UNSUPPORTED_ALGORITHM",
|
|
473
|
+
message: "audit chain fingerprint must use HMAC-SHA-256",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (value.serialization_id !== 2) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
code: "UNSUPPORTED_CANONICALIZATION",
|
|
480
|
+
message: "audit chain fingerprint must use RFC 8785 JCS",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (value.encoding_id !== 1 || !isSha256Hex(value.value)) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
code: "MALFORMED_EVENT",
|
|
487
|
+
message: "audit chain fingerprint must be lowercase SHA-256 hex",
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return { ok: true, value: value };
|
|
491
|
+
}
|
|
492
|
+
function parseIntegrityEvent(value) {
|
|
493
|
+
if (!isPlainRecord(value)) {
|
|
494
|
+
return { ok: false, code: "MALFORMED_EVENT", message: "audit event must be an object" };
|
|
495
|
+
}
|
|
496
|
+
if (!isCredentialAuditTypeUid(value.type_uid)) {
|
|
497
|
+
return { ok: false, code: "MALFORMED_EVENT", message: "audit event type_uid is invalid" };
|
|
498
|
+
}
|
|
499
|
+
if (!isPlainRecord(value.metadata)) {
|
|
500
|
+
return { ok: false, code: "MALFORMED_EVENT", message: "audit metadata is missing" };
|
|
501
|
+
}
|
|
502
|
+
const sequenceNumber = value.metadata.sequence;
|
|
503
|
+
if (!Number.isSafeInteger(sequenceNumber) || sequenceNumber < 1) {
|
|
504
|
+
return { ok: false, code: "INVALID_SEQUENCE", message: "audit sequence is invalid" };
|
|
505
|
+
}
|
|
506
|
+
if (typeof value.metadata.uid !== "string" || value.metadata.uid.length === 0) {
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
code: "MALFORMED_EVENT",
|
|
510
|
+
message: "audit metadata uid is missing",
|
|
511
|
+
sequenceNumber: sequenceNumber,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (!Array.isArray(value.metadata.profiles) ||
|
|
515
|
+
value.metadata.profiles.length !== 1 ||
|
|
516
|
+
value.metadata.profiles[0] !== "record_integrity") {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
code: "MALFORMED_EVENT",
|
|
520
|
+
message: "record_integrity profile is missing",
|
|
521
|
+
sequenceNumber: sequenceNumber,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (!isPlainRecord(value.unmapped)) {
|
|
525
|
+
return {
|
|
526
|
+
ok: false,
|
|
527
|
+
code: "MALFORMED_EVENT",
|
|
528
|
+
message: "audit unmapped extension is missing",
|
|
529
|
+
sequenceNumber: sequenceNumber,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const keyId = value.unmapped["centient.audit_chain.key_id"];
|
|
533
|
+
if (typeof keyId !== "string" || !IDENTIFIER_PATTERN.test(keyId)) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
code: "MALFORMED_EVENT",
|
|
537
|
+
message: "audit chain key ID is invalid",
|
|
538
|
+
sequenceNumber: sequenceNumber,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
if (!Array.isArray(value.attestation_list) || value.attestation_list.length !== 1) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
code: "MALFORMED_EVENT",
|
|
545
|
+
message: "audit event must contain one attestation",
|
|
546
|
+
sequenceNumber: sequenceNumber,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const attestation = value.attestation_list[0];
|
|
550
|
+
if (!isPlainRecord(attestation) || typeof attestation.chain_uid !== "string") {
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
code: "MALFORMED_EVENT",
|
|
554
|
+
message: "audit attestation chain ID is invalid",
|
|
555
|
+
sequenceNumber: sequenceNumber,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (!IDENTIFIER_PATTERN.test(attestation.chain_uid)) {
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
code: "MALFORMED_EVENT",
|
|
562
|
+
message: "audit attestation chain ID is invalid",
|
|
563
|
+
sequenceNumber: sequenceNumber,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const fingerprint = parseFingerprint(attestation.fingerprint);
|
|
567
|
+
if (!fingerprint.ok) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
code: fingerprint.code,
|
|
571
|
+
message: fingerprint.message,
|
|
572
|
+
sequenceNumber: sequenceNumber,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
let previousEvent;
|
|
576
|
+
if (attestation.prev_event !== undefined) {
|
|
577
|
+
if (!isPlainRecord(attestation.prev_event)) {
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
code: "MALFORMED_EVENT",
|
|
581
|
+
message: "previous event reference is invalid",
|
|
582
|
+
sequenceNumber: sequenceNumber,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const previousFingerprint = parseFingerprint(attestation.prev_event.fingerprint);
|
|
586
|
+
if (!previousFingerprint.ok) {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
code: previousFingerprint.code,
|
|
590
|
+
message: previousFingerprint.message,
|
|
591
|
+
sequenceNumber: sequenceNumber,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (typeof attestation.prev_event.uid !== "string" ||
|
|
595
|
+
attestation.prev_event.uid.length === 0 ||
|
|
596
|
+
!isCredentialAuditTypeUid(attestation.prev_event.type_uid)) {
|
|
597
|
+
return {
|
|
598
|
+
ok: false,
|
|
599
|
+
code: "MALFORMED_EVENT",
|
|
600
|
+
message: "previous event locator is invalid",
|
|
601
|
+
sequenceNumber: sequenceNumber,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
previousEvent = {
|
|
605
|
+
uid: attestation.prev_event.uid,
|
|
606
|
+
typeUid: attestation.prev_event.type_uid,
|
|
607
|
+
fingerprint: previousFingerprint.value,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
ok: true,
|
|
612
|
+
value: {
|
|
613
|
+
record: value,
|
|
614
|
+
sequenceNumber: sequenceNumber,
|
|
615
|
+
chainId: attestation.chain_uid,
|
|
616
|
+
keyId,
|
|
617
|
+
eventUid: value.metadata.uid,
|
|
618
|
+
typeUid: value.type_uid,
|
|
619
|
+
fingerprint: fingerprint.value,
|
|
620
|
+
...(previousEvent ? { previousEvent } : {}),
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function checkpointsEqual(actual, expected) {
|
|
625
|
+
return (actual !== null &&
|
|
626
|
+
actual.chainId === expected.chainId &&
|
|
627
|
+
actual.sequenceNumber === expected.sequenceNumber &&
|
|
628
|
+
actual.eventUid === expected.eventUid &&
|
|
629
|
+
actual.eventHash === expected.eventHash &&
|
|
630
|
+
actual.typeUid === expected.typeUid &&
|
|
631
|
+
actual.keyId === expected.keyId &&
|
|
632
|
+
actual.usedKeyIds.length === expected.usedKeyIds.length &&
|
|
633
|
+
actual.usedKeyIds.every((keyId, index) => keyId === expected.usedKeyIds[index]) &&
|
|
634
|
+
actual.usedKeyFingerprints.length === expected.usedKeyFingerprints.length &&
|
|
635
|
+
actual.usedKeyFingerprints.every((fingerprint, index) => fingerprint === expected.usedKeyFingerprints[index]));
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Verify one ordered chain segment without mutating records or key material.
|
|
639
|
+
*
|
|
640
|
+
* The function returns a discriminated failure for hostile input. It requires
|
|
641
|
+
* genesis (`sequence=1`, no `prev_event`) unless a trusted initial checkpoint
|
|
642
|
+
* anchors a later segment. An optional trusted final checkpoint detects suffix
|
|
643
|
+
* deletion; no hash-chain algorithm can detect deletion of the entire log by
|
|
644
|
+
* looking only at that log.
|
|
645
|
+
*/
|
|
646
|
+
export function verifyHmacAuditChain(events, options) {
|
|
647
|
+
let previous = null;
|
|
648
|
+
let rotations = 0;
|
|
649
|
+
const usedKeyIds = new Set();
|
|
650
|
+
const usedKeyFingerprints = new Set();
|
|
651
|
+
const keyFingerprintsById = new Map();
|
|
652
|
+
let expectedChainId;
|
|
653
|
+
let expectedFinalCheckpoint;
|
|
654
|
+
let resolveKey;
|
|
655
|
+
try {
|
|
656
|
+
const configuredExpectedChainId = options.expectedChainId;
|
|
657
|
+
const initialCheckpoint = options.initialCheckpoint;
|
|
658
|
+
const finalCheckpoint = options.expectedFinalCheckpoint;
|
|
659
|
+
resolveKey = options.resolveKey;
|
|
660
|
+
if (configuredExpectedChainId !== undefined) {
|
|
661
|
+
assertIdentifier(configuredExpectedChainId, "chainId");
|
|
662
|
+
expectedChainId = configuredExpectedChainId;
|
|
663
|
+
}
|
|
664
|
+
if (initialCheckpoint !== undefined) {
|
|
665
|
+
const trustedInitialCheckpoint = snapshotCheckpoint(initialCheckpoint);
|
|
666
|
+
previous = trustedInitialCheckpoint;
|
|
667
|
+
for (let index = 0; index < previous.usedKeyIds.length; index++) {
|
|
668
|
+
const keyId = previous.usedKeyIds[index];
|
|
669
|
+
const keyFingerprint = previous.usedKeyFingerprints[index];
|
|
670
|
+
usedKeyIds.add(keyId);
|
|
671
|
+
usedKeyFingerprints.add(keyFingerprint);
|
|
672
|
+
keyFingerprintsById.set(keyId, keyFingerprint);
|
|
673
|
+
}
|
|
674
|
+
if (expectedChainId !== undefined && previous.chainId !== expectedChainId) {
|
|
675
|
+
return verificationFailure("CHAIN_ID_MISMATCH", "initial checkpoint does not match the expected chain ID", -1, previous.sequenceNumber);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (finalCheckpoint !== undefined) {
|
|
679
|
+
const trustedFinalCheckpoint = snapshotCheckpoint(finalCheckpoint);
|
|
680
|
+
expectedFinalCheckpoint = trustedFinalCheckpoint;
|
|
681
|
+
if (expectedChainId !== undefined &&
|
|
682
|
+
expectedFinalCheckpoint.chainId !== expectedChainId) {
|
|
683
|
+
return verificationFailure("CHAIN_ID_MISMATCH", "final checkpoint does not match the expected chain ID", -1, expectedFinalCheckpoint.sequenceNumber);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
return verificationFailure("MALFORMED_EVENT", error instanceof Error ? error.message : "audit checkpoint is malformed", -1);
|
|
689
|
+
}
|
|
690
|
+
for (let index = 0; index < events.length; index++) {
|
|
691
|
+
let parsedResult;
|
|
692
|
+
try {
|
|
693
|
+
parsedResult = parseIntegrityEvent(events[index]);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
return verificationFailure("MALFORMED_EVENT", "audit event could not be parsed", index);
|
|
697
|
+
}
|
|
698
|
+
if (!parsedResult.ok) {
|
|
699
|
+
return verificationFailure(parsedResult.code, parsedResult.message, index, parsedResult.sequenceNumber);
|
|
700
|
+
}
|
|
701
|
+
const parsed = parsedResult.value;
|
|
702
|
+
if (expectedChainId !== undefined && parsed.chainId !== expectedChainId) {
|
|
703
|
+
return verificationFailure("CHAIN_ID_MISMATCH", "audit event does not match the expected chain ID", index, parsed.sequenceNumber);
|
|
704
|
+
}
|
|
705
|
+
if (previous === null) {
|
|
706
|
+
if (parsed.sequenceNumber !== 1 || parsed.previousEvent !== undefined) {
|
|
707
|
+
return verificationFailure("UNANCHORED_SEGMENT", "verification must start at genesis or a trusted checkpoint", index, parsed.sequenceNumber);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
if (parsed.chainId !== previous.chainId) {
|
|
712
|
+
return verificationFailure("CHAIN_ID_MISMATCH", "audit event changed chain ID", index, parsed.sequenceNumber);
|
|
713
|
+
}
|
|
714
|
+
if (parsed.sequenceNumber !== previous.sequenceNumber + 1) {
|
|
715
|
+
return verificationFailure("SEQUENCE_GAP_OR_REORDER", "audit sequence contains a gap, duplicate, or reorder", index, parsed.sequenceNumber);
|
|
716
|
+
}
|
|
717
|
+
if (!parsed.previousEvent) {
|
|
718
|
+
return verificationFailure("PREVIOUS_EVENT_MISMATCH", "audit event is missing its previous-event reference", index, parsed.sequenceNumber);
|
|
719
|
+
}
|
|
720
|
+
if (parsed.previousEvent.uid !== previous.eventUid ||
|
|
721
|
+
parsed.previousEvent.typeUid !== previous.typeUid) {
|
|
722
|
+
return verificationFailure("PREVIOUS_EVENT_MISMATCH", "audit previous-event locator does not match", index, parsed.sequenceNumber);
|
|
723
|
+
}
|
|
724
|
+
if (parsed.previousEvent.fingerprint.value !== previous.eventHash) {
|
|
725
|
+
return verificationFailure("PREVIOUS_HASH_MISMATCH", "audit previous-event fingerprint does not match", index, parsed.sequenceNumber);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const isRotation = previous !== null && parsed.keyId !== previous.keyId;
|
|
729
|
+
if (isRotation) {
|
|
730
|
+
if (usedKeyIds.has(parsed.keyId)) {
|
|
731
|
+
return verificationFailure("KEY_ID_REUSE", "audit chain reused an earlier key ID", index, parsed.sequenceNumber);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
let resolved;
|
|
735
|
+
try {
|
|
736
|
+
resolved = resolveKey(parsed.keyId);
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return verificationFailure("KEY_RESOLUTION_FAILED", "audit verification key resolution failed", index, parsed.sequenceNumber);
|
|
740
|
+
}
|
|
741
|
+
if (resolved === undefined) {
|
|
742
|
+
return verificationFailure("UNKNOWN_KEY_ID", "audit verification key ID is unknown", index, parsed.sequenceNumber);
|
|
743
|
+
}
|
|
744
|
+
if (!(resolved instanceof Uint8Array) || resolved.byteLength !== HMAC_KEY_BYTES) {
|
|
745
|
+
return verificationFailure("INVALID_KEY", "audit verification key must be exactly 32 bytes", index, parsed.sequenceNumber);
|
|
746
|
+
}
|
|
747
|
+
const resolvedKeyFingerprint = keyMaterialFingerprint(parsed.chainId, resolved);
|
|
748
|
+
const knownKeyFingerprint = keyFingerprintsById.get(parsed.keyId);
|
|
749
|
+
if (knownKeyFingerprint !== undefined &&
|
|
750
|
+
knownKeyFingerprint !== resolvedKeyFingerprint) {
|
|
751
|
+
return verificationFailure("KEY_ID_MATERIAL_MISMATCH", "audit key ID resolved to different key material", index, parsed.sequenceNumber);
|
|
752
|
+
}
|
|
753
|
+
if (knownKeyFingerprint === undefined &&
|
|
754
|
+
usedKeyFingerprints.has(resolvedKeyFingerprint)) {
|
|
755
|
+
return verificationFailure("KEY_MATERIAL_REUSE", "audit chain reused historical key material under a fresh ID", index, parsed.sequenceNumber);
|
|
756
|
+
}
|
|
757
|
+
if (knownKeyFingerprint === undefined) {
|
|
758
|
+
usedKeyIds.add(parsed.keyId);
|
|
759
|
+
usedKeyFingerprints.add(resolvedKeyFingerprint);
|
|
760
|
+
keyFingerprintsById.set(parsed.keyId, resolvedKeyFingerprint);
|
|
761
|
+
}
|
|
762
|
+
if (isRotation)
|
|
763
|
+
rotations++;
|
|
764
|
+
let canonical;
|
|
765
|
+
try {
|
|
766
|
+
canonical = canonicalizeAuditEvent(unsignedEventForHash(parsed.record));
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
return verificationFailure("MALFORMED_EVENT", "audit event cannot be canonicalized", index, parsed.sequenceNumber);
|
|
770
|
+
}
|
|
771
|
+
const keyCopy = Buffer.from(resolved);
|
|
772
|
+
let expected;
|
|
773
|
+
try {
|
|
774
|
+
expected = hmacSha256(keyCopy, canonical);
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
keyCopy.fill(0);
|
|
778
|
+
}
|
|
779
|
+
const observed = Buffer.from(parsed.fingerprint.value, "hex");
|
|
780
|
+
if (!timingSafeEqual(expected, observed)) {
|
|
781
|
+
expected.fill(0);
|
|
782
|
+
observed.fill(0);
|
|
783
|
+
return verificationFailure("HASH_MISMATCH", "audit event HMAC does not match", index, parsed.sequenceNumber);
|
|
784
|
+
}
|
|
785
|
+
expected.fill(0);
|
|
786
|
+
observed.fill(0);
|
|
787
|
+
previous = snapshotCheckpoint({
|
|
788
|
+
chainId: parsed.chainId,
|
|
789
|
+
sequenceNumber: parsed.sequenceNumber,
|
|
790
|
+
eventUid: parsed.eventUid,
|
|
791
|
+
eventHash: parsed.fingerprint.value,
|
|
792
|
+
typeUid: parsed.typeUid,
|
|
793
|
+
keyId: parsed.keyId,
|
|
794
|
+
usedKeyIds: [...usedKeyIds],
|
|
795
|
+
usedKeyFingerprints: [...usedKeyFingerprints],
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
if (expectedFinalCheckpoint !== undefined &&
|
|
799
|
+
!checkpointsEqual(previous, expectedFinalCheckpoint)) {
|
|
800
|
+
return verificationFailure("FINAL_CHECKPOINT_MISMATCH", "audit chain does not reach the expected final checkpoint", events.length, previous?.sequenceNumber);
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
ok: true,
|
|
804
|
+
value: {
|
|
805
|
+
verifiedCount: events.length,
|
|
806
|
+
rotations,
|
|
807
|
+
checkpoint: previous,
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
//# sourceMappingURL=hmac-chain.js.map
|