usertrust-verify 1.5.0 → 2.0.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 +44 -0
- package/dist/anchor-verify.d.ts +229 -0
- package/dist/anchor-verify.d.ts.map +1 -0
- package/dist/anchor-verify.js +809 -0
- package/dist/anchor-verify.js.map +1 -0
- package/dist/cli.js +334 -20
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +93 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +300 -16
- package/dist/index.js.map +1 -1
- package/dist/receipt.d.ts +7 -0
- package/dist/receipt.d.ts.map +1 -1
- package/dist/receipt.js +4 -1
- package/dist/receipt.js.map +1 -1
- package/dist/rekor-verify.d.ts +116 -0
- package/dist/rekor-verify.d.ts.map +1 -0
- package/dist/rekor-verify.js +533 -0
- package/dist/rekor-verify.js.map +1 -0
- package/package.json +12 -3
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Usertools, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* External Anchor Verification — Zero-dependency standalone
|
|
5
|
+
*
|
|
6
|
+
* INTENTIONAL DUPLICATION: this file exists byte-for-byte in BOTH
|
|
7
|
+
* packages/verify/src/anchor-verify.ts and
|
|
8
|
+
* packages/core/src/audit/anchor-verify.ts — only the import paths differ.
|
|
9
|
+
* The anchor differential/parity tests pin the two together; any change must
|
|
10
|
+
* land in both packages.
|
|
11
|
+
*
|
|
12
|
+
* Verifies signed anchor checkpoints (spec: docs/superpowers/specs/
|
|
13
|
+
* 2026-07-26-external-anchoring-design.md) against a caller-pinned trust root:
|
|
14
|
+
* - strict record parsing (unknown fields / range violations are INVALID)
|
|
15
|
+
* - Ed25519 signature verification via node:crypto (ECDSA P-256 helper kept
|
|
16
|
+
* alg-agile for Phase 2 transparency-log checkpoints)
|
|
17
|
+
* - anchor-chain verification: prevAnchorHash linkage, anchorSeq contiguity,
|
|
18
|
+
* key epochs via cross-signed rotation records, fork/duplicate semantics
|
|
19
|
+
* - vault binding: Merkle root / head hash at each anchored treeSize, with
|
|
20
|
+
* externally-bound consistency proofs between anchors
|
|
21
|
+
* - the spec §7.2 state machine (UNANCHORED / ANCHORED_VERIFIED / ANCHOR_STALE
|
|
22
|
+
* / ANCHOR_UNVERIFIABLE / ANCHOR_INVALID / ANCHOR_MISMATCH)
|
|
23
|
+
*
|
|
24
|
+
* Trust NEVER comes from the vault under audit: the caller pins the genesis
|
|
25
|
+
* root key (and optional rotation-successor pins) out-of-band.
|
|
26
|
+
*/
|
|
27
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
28
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { canonicalize } from "./canonical.js";
|
|
31
|
+
import { GENESIS_HASH } from "./constants.js";
|
|
32
|
+
import { buildMerkleTree, generateConsistencyProof, verifyConsistencyProof, verifyInclusionProof, } from "./verify.js";
|
|
33
|
+
function nullIfNaN(n) {
|
|
34
|
+
return Number.isFinite(n) ? n : null;
|
|
35
|
+
}
|
|
36
|
+
// ── Reason-code classes (see spec §7.2) ──
|
|
37
|
+
const INVALID_REASONS = new Set([
|
|
38
|
+
"malformed-anchor",
|
|
39
|
+
"range-invalid",
|
|
40
|
+
"sig-invalid",
|
|
41
|
+
"rekor-receipt-invalid",
|
|
42
|
+
]);
|
|
43
|
+
const NON_FAIL_REASONS = new Set(["no-trust-material", "witness-unreachable"]);
|
|
44
|
+
// ── Strict parsing ──
|
|
45
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
46
|
+
const KEY_ID = /^sha256:[0-9a-f]{64}$/;
|
|
47
|
+
const RECORD_KEYS = new Set([
|
|
48
|
+
"v",
|
|
49
|
+
"vaultId",
|
|
50
|
+
"anchorSeq",
|
|
51
|
+
"prevAnchorHash",
|
|
52
|
+
"treeSize",
|
|
53
|
+
"lastHash",
|
|
54
|
+
"merkleRoot",
|
|
55
|
+
"timestamp",
|
|
56
|
+
"keyId",
|
|
57
|
+
"rotation",
|
|
58
|
+
"sig",
|
|
59
|
+
]);
|
|
60
|
+
const ROTATION_KEYS = new Set(["nextKeyId", "nextPublicKeySpki"]);
|
|
61
|
+
function isSafePositiveInt(n) {
|
|
62
|
+
return typeof n === "number" && Number.isSafeInteger(n) && n >= 1;
|
|
63
|
+
}
|
|
64
|
+
// Matching control characters is the entire point here — they are what gets
|
|
65
|
+
// removed from anything echoed back to a terminal.
|
|
66
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the intent
|
|
67
|
+
const CONTROL_CHARS = /[\x00-\x1f\x7f]/g;
|
|
68
|
+
/**
|
|
69
|
+
* An untrusted field NAME on its way into an error string. Control characters
|
|
70
|
+
* are stripped before truncation: these strings are printed to a terminal, and
|
|
71
|
+
* an escape sequence inside a key would let the party under audit repaint the
|
|
72
|
+
* line its own verdict is printed on.
|
|
73
|
+
*/
|
|
74
|
+
function clipKey(key) {
|
|
75
|
+
const scrubbed = key.replace(CONTROL_CHARS, "");
|
|
76
|
+
return scrubbed.length <= 80 ? scrubbed : `${scrubbed.slice(0, 80)}...`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Strict parse of a single anchor record. Unknown fields, missing fields, and
|
|
80
|
+
* range violations are all rejected (fail-closed — spec §3 strict-parse rules;
|
|
81
|
+
* range checks run BEFORE any Merkle primitive so a validly-signed-but-
|
|
82
|
+
* degenerate record yields a deterministic verdict, never a throw).
|
|
83
|
+
* Returns `{ record }` or `{ error }` with a code-prefixed message.
|
|
84
|
+
*/
|
|
85
|
+
export function parseAnchorRecord(raw) {
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(raw);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { record: null, error: "malformed-anchor: not valid JSON" };
|
|
92
|
+
}
|
|
93
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
94
|
+
return { record: null, error: "malformed-anchor: not an object" };
|
|
95
|
+
}
|
|
96
|
+
const obj = parsed;
|
|
97
|
+
for (const key of Object.keys(obj)) {
|
|
98
|
+
if (!RECORD_KEYS.has(key)) {
|
|
99
|
+
return { record: null, error: `malformed-anchor: unknown field "${clipKey(key)}"` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (obj.v !== 1) {
|
|
103
|
+
return { record: null, error: "malformed-anchor: unsupported version" };
|
|
104
|
+
}
|
|
105
|
+
if (typeof obj.vaultId !== "string" || obj.vaultId.length === 0) {
|
|
106
|
+
return { record: null, error: "malformed-anchor: missing vaultId" };
|
|
107
|
+
}
|
|
108
|
+
if (!isSafePositiveInt(obj.anchorSeq)) {
|
|
109
|
+
return { record: null, error: "range-invalid: anchorSeq must be a safe integer >= 1" };
|
|
110
|
+
}
|
|
111
|
+
if (!isSafePositiveInt(obj.treeSize)) {
|
|
112
|
+
return { record: null, error: "range-invalid: treeSize must be a safe integer >= 1" };
|
|
113
|
+
}
|
|
114
|
+
if (typeof obj.prevAnchorHash !== "string" || !HEX64.test(obj.prevAnchorHash)) {
|
|
115
|
+
return { record: null, error: "malformed-anchor: prevAnchorHash must be 64-hex" };
|
|
116
|
+
}
|
|
117
|
+
if (typeof obj.lastHash !== "string" || !HEX64.test(obj.lastHash)) {
|
|
118
|
+
return { record: null, error: "malformed-anchor: lastHash must be 64-hex" };
|
|
119
|
+
}
|
|
120
|
+
if (typeof obj.merkleRoot !== "string" || !HEX64.test(obj.merkleRoot)) {
|
|
121
|
+
return { record: null, error: "malformed-anchor: merkleRoot must be 64-hex" };
|
|
122
|
+
}
|
|
123
|
+
if (typeof obj.timestamp !== "string" ||
|
|
124
|
+
obj.timestamp.length === 0 ||
|
|
125
|
+
!Number.isFinite(Date.parse(obj.timestamp))) {
|
|
126
|
+
// A signed-but-unparseable timestamp would make the --max-anchor-age
|
|
127
|
+
// staleness gate silently fail open (Date.parse → NaN) — an
|
|
128
|
+
// adversary-controlled bypass of the caller's freshness policy.
|
|
129
|
+
return { record: null, error: "malformed-anchor: timestamp must be a parseable date-time" };
|
|
130
|
+
}
|
|
131
|
+
if (typeof obj.keyId !== "string" || !KEY_ID.test(obj.keyId)) {
|
|
132
|
+
return { record: null, error: "malformed-anchor: keyId must be sha256:<64-hex>" };
|
|
133
|
+
}
|
|
134
|
+
if (typeof obj.sig !== "string" || obj.sig.length === 0) {
|
|
135
|
+
return { record: null, error: "malformed-anchor: missing sig" };
|
|
136
|
+
}
|
|
137
|
+
if (obj.rotation !== undefined) {
|
|
138
|
+
const rot = obj.rotation;
|
|
139
|
+
if (rot === null || typeof rot !== "object" || Array.isArray(rot)) {
|
|
140
|
+
return { record: null, error: "malformed-anchor: rotation must be an object" };
|
|
141
|
+
}
|
|
142
|
+
const rotObj = rot;
|
|
143
|
+
for (const key of Object.keys(rotObj)) {
|
|
144
|
+
if (!ROTATION_KEYS.has(key)) {
|
|
145
|
+
return {
|
|
146
|
+
record: null,
|
|
147
|
+
error: `malformed-anchor: unknown rotation field "${clipKey(key)}"`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (typeof rotObj.nextKeyId !== "string" || !KEY_ID.test(rotObj.nextKeyId)) {
|
|
152
|
+
return {
|
|
153
|
+
record: null,
|
|
154
|
+
error: "malformed-anchor: rotation.nextKeyId must be sha256:<64-hex>",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (typeof rotObj.nextPublicKeySpki !== "string" ||
|
|
158
|
+
Buffer.from(rotObj.nextPublicKeySpki, "base64").length === 0) {
|
|
159
|
+
return {
|
|
160
|
+
record: null,
|
|
161
|
+
error: "malformed-anchor: rotation.nextPublicKeySpki must be base64",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { record: obj, error: null };
|
|
166
|
+
}
|
|
167
|
+
/** Parse a JSONL (or single-JSON) anchors artifact. Every line must parse. */
|
|
168
|
+
export function parseAnchorsContent(content) {
|
|
169
|
+
const records = [];
|
|
170
|
+
const errors = [];
|
|
171
|
+
const lines = content
|
|
172
|
+
.split("\n")
|
|
173
|
+
.map((l) => l.trim())
|
|
174
|
+
.filter((l) => l.length > 0);
|
|
175
|
+
for (let i = 0; i < lines.length; i++) {
|
|
176
|
+
const { record, error } = parseAnchorRecord(lines[i]);
|
|
177
|
+
if (record) {
|
|
178
|
+
records.push(record);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
errors.push(`anchor line ${i + 1}: ${error}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { records, errors };
|
|
185
|
+
}
|
|
186
|
+
// ── Crypto helpers ──
|
|
187
|
+
/** Signing pre-image = canonicalize(record − sig). Spec §3. */
|
|
188
|
+
export function anchorSigningPreimage(record) {
|
|
189
|
+
const { sig: _sig, ...rest } = record;
|
|
190
|
+
return canonicalize(rest);
|
|
191
|
+
}
|
|
192
|
+
/** sha256 hex of the signing pre-image — what the successor's prevAnchorHash carries. */
|
|
193
|
+
export function anchorPayloadHash(record) {
|
|
194
|
+
return createHash("sha256").update(anchorSigningPreimage(record), "utf8").digest("hex");
|
|
195
|
+
}
|
|
196
|
+
export function keyIdFromSpkiDer(der) {
|
|
197
|
+
return `sha256:${createHash("sha256").update(der).digest("hex")}`;
|
|
198
|
+
}
|
|
199
|
+
export function publicKeyFromPem(pem) {
|
|
200
|
+
try {
|
|
201
|
+
return createPublicKey(pem);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export function publicKeyFromSpkiBase64(b64) {
|
|
208
|
+
try {
|
|
209
|
+
return createPublicKey({ key: Buffer.from(b64, "base64"), format: "der", type: "spki" });
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export function keyIdFromKeyObject(key) {
|
|
216
|
+
return keyIdFromSpkiDer(key.export({ type: "spki", format: "der" }));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Raw signature verification over UTF-8 data. Ed25519 for usertrust anchor
|
|
220
|
+
* records; ECDSA P-256 kept alg-agile for Phase 2 transparency-log checkpoints
|
|
221
|
+
* (Rekor signs with ECDSA — spec reconciliation R2). Never throws.
|
|
222
|
+
*/
|
|
223
|
+
export function verifySignatureRaw(alg, dataUtf8, publicKey, sigBase64) {
|
|
224
|
+
const data = Buffer.from(dataUtf8, "utf8");
|
|
225
|
+
const sig = Buffer.from(sigBase64, "base64");
|
|
226
|
+
if (sig.length === 0)
|
|
227
|
+
return false;
|
|
228
|
+
try {
|
|
229
|
+
if (alg === "ed25519") {
|
|
230
|
+
return cryptoVerify(null, data, publicKey, sig);
|
|
231
|
+
}
|
|
232
|
+
return (cryptoVerify("sha256", data, { key: publicKey, dsaEncoding: "ieee-p1363" }, sig) ||
|
|
233
|
+
cryptoVerify("sha256", data, { key: publicKey, dsaEncoding: "der" }, sig));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Verify one record's Ed25519 signature against explicit candidate keys.
|
|
241
|
+
* The candidate whose keyId matches the record's keyId is used; a record whose
|
|
242
|
+
* keyId matches none of the candidates fails with a distinct error.
|
|
243
|
+
*/
|
|
244
|
+
export function verifyAnchorSignature(record, candidateKeysPem) {
|
|
245
|
+
const errors = [];
|
|
246
|
+
for (const pem of candidateKeysPem) {
|
|
247
|
+
const key = publicKeyFromPem(pem);
|
|
248
|
+
if (key === null) {
|
|
249
|
+
errors.push("sig-invalid: unparseable candidate public key");
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (keyIdFromKeyObject(key) !== record.keyId)
|
|
253
|
+
continue;
|
|
254
|
+
if (verifySignatureRaw("ed25519", anchorSigningPreimage(record), key, record.sig)) {
|
|
255
|
+
return { ok: true, keyId: record.keyId, errors: [] };
|
|
256
|
+
}
|
|
257
|
+
errors.push(`sig-invalid: signature does not verify under keyId ${record.keyId}`);
|
|
258
|
+
return { ok: false, keyId: record.keyId, errors };
|
|
259
|
+
}
|
|
260
|
+
errors.push(`sig-invalid: no candidate key matches keyId ${record.keyId}`);
|
|
261
|
+
return { ok: false, keyId: record.keyId, errors };
|
|
262
|
+
}
|
|
263
|
+
// ── Duplicate / fork semantics (spec §7.2) ──
|
|
264
|
+
/** Committed-field equality: everything except timestamp and sig. */
|
|
265
|
+
export function committedFieldsEqual(a, b) {
|
|
266
|
+
const strip = (r) => {
|
|
267
|
+
const { sig: _s, timestamp: _t, ...rest } = r;
|
|
268
|
+
return canonicalize(rest);
|
|
269
|
+
};
|
|
270
|
+
return strip(a) === strip(b);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Collapse same-anchorSeq records WITHIN one stream (store or mirror):
|
|
274
|
+
* identical committed content = benign duplicate (warn, keep first);
|
|
275
|
+
* divergent committed content = fork evidence (MISMATCH).
|
|
276
|
+
*/
|
|
277
|
+
export function dedupeAnchorSet(records) {
|
|
278
|
+
const warnings = [];
|
|
279
|
+
const errors = [];
|
|
280
|
+
const mismatchReasons = [];
|
|
281
|
+
const dropped = [];
|
|
282
|
+
const bySeq = new Map();
|
|
283
|
+
for (const r of records) {
|
|
284
|
+
const existing = bySeq.get(r.anchorSeq);
|
|
285
|
+
if (existing === undefined) {
|
|
286
|
+
bySeq.set(r.anchorSeq, r);
|
|
287
|
+
}
|
|
288
|
+
else if (committedFieldsEqual(existing, r)) {
|
|
289
|
+
// Benign duplicate ONLY if its signature also verifies — the walk
|
|
290
|
+
// re-checks every dropped twin under its epoch key (spec §7.2
|
|
291
|
+
// "both signatures valid"). Order must not decide the verdict.
|
|
292
|
+
warnings.push("duplicate-anchor");
|
|
293
|
+
dropped.push(r);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
errors.push(`fork: divergent records at anchorSeq ${r.anchorSeq} (same position, different committed content)`);
|
|
297
|
+
mismatchReasons.push("fork");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const unique = [...bySeq.values()].sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
301
|
+
return { unique, dropped, warnings, errors, mismatchReasons };
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Verify the anchor stream as its own hash chain under the pinned trust root:
|
|
305
|
+
* signatures per key epoch (rotations walked forward from the root),
|
|
306
|
+
* prevAnchorHash linkage from GENESIS, anchorSeq contiguity from 1, treeSize
|
|
307
|
+
* monotonicity, single vaultId, and — when event hashes are supplied —
|
|
308
|
+
* consistency binding between consecutive anchored roots. The proof's
|
|
309
|
+
* firstRoot/secondRoot are compared to the SIGNED roots before
|
|
310
|
+
* verifyConsistencyProof is called: the bare call checks only internal proof
|
|
311
|
+
* consistency and passes unconditionally on locally generated proofs
|
|
312
|
+
* (spec §7.2 — omitting the binding recreates F1).
|
|
313
|
+
*
|
|
314
|
+
* Callers pass a set already deduped per stream (dedupeAnchorSet); same-seq
|
|
315
|
+
* survivors here are treated as forks.
|
|
316
|
+
*/
|
|
317
|
+
export function verifyAnchorChain(records, trust, eventHashes) {
|
|
318
|
+
const errors = [];
|
|
319
|
+
const invalidReasons = [];
|
|
320
|
+
const mismatchReasons = [];
|
|
321
|
+
const warnings = [];
|
|
322
|
+
const rootKey = publicKeyFromPem(trust.rootPem);
|
|
323
|
+
if (rootKey === null) {
|
|
324
|
+
errors.push("trust root public key is not a parseable PEM");
|
|
325
|
+
invalidReasons.push("sig-invalid");
|
|
326
|
+
return { records: [], errors, invalidReasons, mismatchReasons, warnings };
|
|
327
|
+
}
|
|
328
|
+
const pinKeyIds = new Set();
|
|
329
|
+
const knownKeys = new Map();
|
|
330
|
+
const rootKeyId = keyIdFromKeyObject(rootKey);
|
|
331
|
+
knownKeys.set(rootKeyId, rootKey);
|
|
332
|
+
for (const pem of trust.successorPinsPem ?? []) {
|
|
333
|
+
const k = publicKeyFromPem(pem);
|
|
334
|
+
if (k !== null) {
|
|
335
|
+
const id = keyIdFromKeyObject(k);
|
|
336
|
+
pinKeyIds.add(id);
|
|
337
|
+
knownKeys.set(id, k);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// A mixed-vaultId set is rejected wholesale, BEFORE dedup can collapse a
|
|
341
|
+
// replayed foreign record into a same-seq group (cross-vault replay).
|
|
342
|
+
const vaultIds = new Set(records.map((r) => r.vaultId));
|
|
343
|
+
if (vaultIds.size > 1) {
|
|
344
|
+
errors.push(`vault-id-mismatch: anchor set mixes ${vaultIds.size} distinct vaultIds`);
|
|
345
|
+
mismatchReasons.push("vault-id-mismatch");
|
|
346
|
+
}
|
|
347
|
+
const dedup = dedupeAnchorSet(records);
|
|
348
|
+
warnings.push(...dedup.warnings);
|
|
349
|
+
errors.push(...dedup.errors);
|
|
350
|
+
mismatchReasons.push(...dedup.mismatchReasons);
|
|
351
|
+
const ordered = dedup.unique;
|
|
352
|
+
const droppedBySeq = new Map();
|
|
353
|
+
for (const d of dedup.dropped) {
|
|
354
|
+
const list = droppedBySeq.get(d.anchorSeq) ?? [];
|
|
355
|
+
list.push(d);
|
|
356
|
+
droppedBySeq.set(d.anchorSeq, list);
|
|
357
|
+
}
|
|
358
|
+
let epochKeyId = rootKeyId;
|
|
359
|
+
let epochKey = rootKey;
|
|
360
|
+
let prev = null;
|
|
361
|
+
// A partial history may legitimately START in a later key epoch — the
|
|
362
|
+
// documented lone-checkpoint bind (§7.5) fetched after a rotation. When
|
|
363
|
+
// the first record's key is a caller-TRUSTED key (root or successor pin),
|
|
364
|
+
// adopt it as the starting epoch instead of condemning the unprovable
|
|
365
|
+
// prefix; an untrusted key stays fail-closed (sig-invalid below).
|
|
366
|
+
const firstRecord = ordered[0];
|
|
367
|
+
if (firstRecord !== undefined && firstRecord.anchorSeq !== 1 && firstRecord.keyId !== rootKeyId) {
|
|
368
|
+
const pinnedStart = knownKeys.get(firstRecord.keyId);
|
|
369
|
+
if (pinnedStart !== undefined) {
|
|
370
|
+
epochKeyId = firstRecord.keyId;
|
|
371
|
+
epochKey = pinnedStart;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
375
|
+
const r = ordered[i];
|
|
376
|
+
if (i === 0 && r.anchorSeq !== 1) {
|
|
377
|
+
// A supplied history that simply does not START at genesis (e.g. the
|
|
378
|
+
// documented single-checkpoint bind, §7.5) is PARTIAL, not tampered:
|
|
379
|
+
// the missing prefix is unprovable, not proof of deletion, and every
|
|
380
|
+
// present record still binds to the vault content. A gap BETWEEN two
|
|
381
|
+
// present records (checked below) remains a hard MISMATCH.
|
|
382
|
+
warnings.push("partial-history");
|
|
383
|
+
}
|
|
384
|
+
if (prev !== null && r.anchorSeq !== prev.anchorSeq + 1) {
|
|
385
|
+
errors.push(`anchor-chain-gap: anchorSeq ${r.anchorSeq} follows ${prev.anchorSeq}`);
|
|
386
|
+
mismatchReasons.push("anchor-chain-gap");
|
|
387
|
+
}
|
|
388
|
+
if (r.anchorSeq === 1 && r.prevAnchorHash !== GENESIS_HASH) {
|
|
389
|
+
errors.push("anchor-chain-gap: anchorSeq 1 prevAnchorHash must be GENESIS");
|
|
390
|
+
mismatchReasons.push("anchor-chain-gap");
|
|
391
|
+
}
|
|
392
|
+
if (prev !== null && r.anchorSeq === prev.anchorSeq + 1) {
|
|
393
|
+
// The predecessor may exist as several benign committed-equal twins
|
|
394
|
+
// (differing only in timestamp/sig — racing-emitter duplicates), and
|
|
395
|
+
// timestamp IS part of the signing pre-image: the successor
|
|
396
|
+
// legitimately links to WHICHEVER twin its emitter had as the mirror
|
|
397
|
+
// tail, so linkage accepts any twin's payload hash.
|
|
398
|
+
const prevTwins = [prev, ...(droppedBySeq.get(prev.anchorSeq) ?? [])];
|
|
399
|
+
if (!prevTwins.some((t) => r.prevAnchorHash === anchorPayloadHash(t))) {
|
|
400
|
+
errors.push(`anchor-chain-gap: anchorSeq ${r.anchorSeq} prevAnchorHash does not link to its predecessor`);
|
|
401
|
+
mismatchReasons.push("anchor-chain-gap");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (prev !== null && r.treeSize < prev.treeSize) {
|
|
405
|
+
errors.push(`anchor monotonicity violation: treeSize ${r.treeSize} at anchorSeq ${r.anchorSeq} decreases from ${prev.treeSize}`);
|
|
406
|
+
mismatchReasons.push("rollback");
|
|
407
|
+
}
|
|
408
|
+
if (prev !== null && r.vaultId !== prev.vaultId) {
|
|
409
|
+
errors.push(`vault-id-mismatch: mixed vaultId in anchor set at anchorSeq ${r.anchorSeq}`);
|
|
410
|
+
mismatchReasons.push("vault-id-mismatch");
|
|
411
|
+
}
|
|
412
|
+
// Signature under the current epoch key. A cryptographically valid
|
|
413
|
+
// signature under a trusted key that is WRONG for this chain position
|
|
414
|
+
// is MISMATCH (key/rotation continuity); a signature no trusted key
|
|
415
|
+
// verifies is INVALID (spec §7.1 rule).
|
|
416
|
+
let sigOk = false;
|
|
417
|
+
if (r.keyId === epochKeyId && epochKey !== null) {
|
|
418
|
+
if (verifySignatureRaw("ed25519", anchorSigningPreimage(r), epochKey, r.sig)) {
|
|
419
|
+
sigOk = true;
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
errors.push(`sig-invalid: anchorSeq ${r.anchorSeq} signature fails under keyId ${r.keyId}`);
|
|
423
|
+
invalidReasons.push("sig-invalid");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
const candidate = knownKeys.get(r.keyId);
|
|
428
|
+
if (candidate !== undefined &&
|
|
429
|
+
verifySignatureRaw("ed25519", anchorSigningPreimage(r), candidate, r.sig)) {
|
|
430
|
+
errors.push(`rotation-continuity: anchorSeq ${r.anchorSeq} signed by keyId ${r.keyId}, expected epoch key ${epochKeyId}`);
|
|
431
|
+
mismatchReasons.push("rotation-continuity");
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
errors.push(`sig-invalid: anchorSeq ${r.anchorSeq} signed by untrusted or failing keyId ${r.keyId}`);
|
|
435
|
+
invalidReasons.push("sig-invalid");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// A committed-equal twin dropped by dedup must ALSO verify (spec §7.2
|
|
439
|
+
// "both signatures valid") — otherwise record order would decide the
|
|
440
|
+
// verdict. keyId is a committed field, so the twin resolves to the
|
|
441
|
+
// same epoch/known key as its survivor.
|
|
442
|
+
for (const twin of droppedBySeq.get(r.anchorSeq) ?? []) {
|
|
443
|
+
const twinKey = twin.keyId === epochKeyId ? epochKey : (knownKeys.get(twin.keyId) ?? null);
|
|
444
|
+
if (twinKey === null ||
|
|
445
|
+
!verifySignatureRaw("ed25519", anchorSigningPreimage(twin), twinKey, twin.sig)) {
|
|
446
|
+
errors.push(`sig-invalid: duplicate record at anchorSeq ${r.anchorSeq} has a signature that does not verify under keyId ${twin.keyId}`);
|
|
447
|
+
invalidReasons.push("sig-invalid");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Rotation: cross-signed successor introduction. Only a record whose
|
|
451
|
+
// own signature verified in its epoch may advance the epoch — a forged
|
|
452
|
+
// rotation must never install a key.
|
|
453
|
+
if (r.rotation !== undefined && sigOk) {
|
|
454
|
+
const nextKey = publicKeyFromSpkiBase64(r.rotation.nextPublicKeySpki);
|
|
455
|
+
if (nextKey === null || keyIdFromKeyObject(nextKey) !== r.rotation.nextKeyId) {
|
|
456
|
+
errors.push(`malformed-anchor: rotation at anchorSeq ${r.anchorSeq} — nextKeyId does not match nextPublicKeySpki`);
|
|
457
|
+
invalidReasons.push("malformed-anchor");
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
if (pinKeyIds.size > 0 && !pinKeyIds.has(r.rotation.nextKeyId)) {
|
|
461
|
+
errors.push(`rotation-unpinned: rotation at anchorSeq ${r.anchorSeq} to keyId ${r.rotation.nextKeyId} not in the supplied successor pins`);
|
|
462
|
+
mismatchReasons.push("rotation-unpinned");
|
|
463
|
+
}
|
|
464
|
+
else if (pinKeyIds.size === 0) {
|
|
465
|
+
warnings.push("rotation-unpinned");
|
|
466
|
+
}
|
|
467
|
+
knownKeys.set(r.rotation.nextKeyId, nextKey);
|
|
468
|
+
epochKeyId = r.rotation.nextKeyId;
|
|
469
|
+
epochKey = nextKey;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// Consistency binding between consecutive anchored roots (spec §7.2).
|
|
473
|
+
if (eventHashes !== undefined &&
|
|
474
|
+
prev !== null &&
|
|
475
|
+
prev.treeSize >= 1 &&
|
|
476
|
+
r.treeSize <= eventHashes.length &&
|
|
477
|
+
prev.treeSize <= r.treeSize) {
|
|
478
|
+
if (prev.treeSize === r.treeSize) {
|
|
479
|
+
if (prev.merkleRoot !== r.merkleRoot) {
|
|
480
|
+
errors.push(`consistency-failure: anchorSeq ${prev.anchorSeq}->${r.anchorSeq} same treeSize, different roots`);
|
|
481
|
+
mismatchReasons.push("consistency-failure");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
const proof = generateConsistencyProof(prev.treeSize, r.treeSize, [...eventHashes]);
|
|
486
|
+
if (proof.firstRoot !== prev.merkleRoot ||
|
|
487
|
+
proof.secondRoot !== r.merkleRoot ||
|
|
488
|
+
!verifyConsistencyProof(proof)) {
|
|
489
|
+
errors.push(`consistency-failure: anchored root at treeSize ${prev.treeSize} is not a prefix of the tree at ${r.treeSize}`);
|
|
490
|
+
mismatchReasons.push("consistency-failure");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
prev = r;
|
|
495
|
+
}
|
|
496
|
+
return { records: ordered, errors, invalidReasons, mismatchReasons, warnings };
|
|
497
|
+
}
|
|
498
|
+
// ── Vault gathering ──
|
|
499
|
+
/**
|
|
500
|
+
* Gather the globally sequence-ordered stored event hashes of a vault, using
|
|
501
|
+
* the exact segment-gathering and ordering semantics of verifyVault
|
|
502
|
+
* (events.jsonl first, then every other *.jsonl sorted; order by persisted
|
|
503
|
+
* global `sequence` when every event has one, else file order). Malformed
|
|
504
|
+
* lines are skipped here — chain-level errors are verifyVault's job, not the
|
|
505
|
+
* anchor evaluator's.
|
|
506
|
+
*/
|
|
507
|
+
export function gatherOrderedEventHashes(vaultPath) {
|
|
508
|
+
const auditDir = join(vaultPath, "audit");
|
|
509
|
+
const segmentFiles = [];
|
|
510
|
+
const mainLog = join(auditDir, "events.jsonl");
|
|
511
|
+
if (existsSync(mainLog)) {
|
|
512
|
+
segmentFiles.push(mainLog);
|
|
513
|
+
}
|
|
514
|
+
if (existsSync(auditDir)) {
|
|
515
|
+
try {
|
|
516
|
+
for (const entry of readdirSync(auditDir).sort()) {
|
|
517
|
+
if (entry.endsWith(".jsonl") && entry !== "events.jsonl") {
|
|
518
|
+
segmentFiles.push(join(auditDir, entry));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// Directory read failure — non-fatal
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const events = [];
|
|
527
|
+
for (const segmentFile of segmentFiles) {
|
|
528
|
+
let content;
|
|
529
|
+
try {
|
|
530
|
+
content = readFileSync(segmentFile, "utf-8").trim();
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (content === "")
|
|
536
|
+
continue;
|
|
537
|
+
for (const raw of content.split("\n").filter((l) => l.trim())) {
|
|
538
|
+
try {
|
|
539
|
+
const parsed = JSON.parse(raw);
|
|
540
|
+
events.push({
|
|
541
|
+
hash: typeof parsed.hash === "string" ? parsed.hash : "",
|
|
542
|
+
sequence: typeof parsed.sequence === "number" ? parsed.sequence : undefined,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// skip malformed line
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const allHaveSeq = events.every((e) => typeof e.sequence === "number");
|
|
551
|
+
const ordered = allHaveSeq
|
|
552
|
+
? [...events].sort((a, b) => a.sequence - b.sequence)
|
|
553
|
+
: events;
|
|
554
|
+
return ordered.map((e) => e.hash);
|
|
555
|
+
}
|
|
556
|
+
/** Read the vault-local anchor mirror (cache, never a trust root alone). */
|
|
557
|
+
export function readAnchorMirror(vaultPath) {
|
|
558
|
+
const mirrorPath = join(vaultPath, "audit", "anchors", "anchors.jsonl");
|
|
559
|
+
if (!existsSync(mirrorPath))
|
|
560
|
+
return { records: [], errors: [] };
|
|
561
|
+
try {
|
|
562
|
+
return parseAnchorsContent(readFileSync(mirrorPath, "utf-8"));
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return { records: [], errors: ["malformed-anchor: anchor mirror unreadable"] };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// ── State machine ──
|
|
569
|
+
const STATE_SEVERITY = {
|
|
570
|
+
ANCHORED_VERIFIED: 0,
|
|
571
|
+
UNANCHORED: 1,
|
|
572
|
+
ANCHOR_UNVERIFIABLE: 2,
|
|
573
|
+
ANCHOR_STALE: 3,
|
|
574
|
+
ANCHOR_INVALID: 4,
|
|
575
|
+
ANCHOR_MISMATCH: 5,
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* The more severe of two states. Exported so callers layering evidence ON TOP
|
|
579
|
+
* of the state machine (the glue's Rekor receipts) escalate by the SAME
|
|
580
|
+
* severity ordering the machine itself uses, rather than hardcoding one.
|
|
581
|
+
*/
|
|
582
|
+
export function worseAnchorState(a, b) {
|
|
583
|
+
return (STATE_SEVERITY[a] ?? 0) >= (STATE_SEVERITY[b] ?? 0) ? a : b;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* The spec §7.2 state machine. Pure function over gathered inputs so both
|
|
587
|
+
* packages (and the differential test) evaluate identically. Evaluation order
|
|
588
|
+
* per constraints §4.2: discover → trust check → parse/signature → content
|
|
589
|
+
* cross-checks → freshness; worst state wins. The witness-fetch-failure rule
|
|
590
|
+
* (AC-2.4) caps the state below ANCHORED_VERIFIED without discarding
|
|
591
|
+
* caller-supplied artifacts.
|
|
592
|
+
*/
|
|
593
|
+
export function evaluateAnchoredVault(input) {
|
|
594
|
+
const opts = input.opts ?? {};
|
|
595
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
596
|
+
const reasons = [];
|
|
597
|
+
const warnings = [];
|
|
598
|
+
const errors = [];
|
|
599
|
+
const witnessRequested = input.witness.requested;
|
|
600
|
+
const witnessOk = input.witness.ok === true;
|
|
601
|
+
const witnessFailed = witnessRequested && !witnessOk;
|
|
602
|
+
const observed = input.orderedHashes.length;
|
|
603
|
+
const anchorsPresent = input.externalAnchors.length > 0 ||
|
|
604
|
+
input.mirrorAnchors.length > 0 ||
|
|
605
|
+
input.externalErrors.length > 0 ||
|
|
606
|
+
input.mirrorErrors.length > 0;
|
|
607
|
+
// "external" requires at least one PARSED external record (or a parse error
|
|
608
|
+
// to fail on) — a witness that returned 2xx with an empty/zero-record body
|
|
609
|
+
// must not launder vault-mirror-only evidence into "external" and slip past
|
|
610
|
+
// --require-external-anchor (AC-2.4 defense in depth).
|
|
611
|
+
const hasExternal = input.externalAnchors.length > 0 || input.externalErrors.length > 0;
|
|
612
|
+
const anchorSource = hasExternal
|
|
613
|
+
? "external"
|
|
614
|
+
: anchorsPresent
|
|
615
|
+
? "vault-mirror"
|
|
616
|
+
: "none";
|
|
617
|
+
const finish = (state, records) => {
|
|
618
|
+
// Witness-unreachable cap: inconclusive, never a silent downgrade
|
|
619
|
+
// (AC-2.4). MISMATCH/INVALID/STALE from remaining inputs still win;
|
|
620
|
+
// ANCHORED_VERIFIED and UNANCHORED are capped to UNVERIFIABLE.
|
|
621
|
+
let finalState = state;
|
|
622
|
+
if (witnessFailed) {
|
|
623
|
+
reasons.push("witness-unreachable");
|
|
624
|
+
finalState = worseAnchorState(finalState, "ANCHOR_UNVERIFIABLE");
|
|
625
|
+
errors.push(`witness-unreachable: ${input.witness.error ?? "anchor URL fetch failed"}`);
|
|
626
|
+
}
|
|
627
|
+
let witness;
|
|
628
|
+
if (!witnessRequested) {
|
|
629
|
+
witness = { requested: false, status: "not-consulted" };
|
|
630
|
+
}
|
|
631
|
+
else if (!witnessOk) {
|
|
632
|
+
witness =
|
|
633
|
+
input.witness.error !== undefined
|
|
634
|
+
? { requested: true, status: "unreachable", error: input.witness.error }
|
|
635
|
+
: { requested: true, status: "unreachable" };
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
const disagrees = finalState === "ANCHOR_MISMATCH" || finalState === "ANCHOR_INVALID";
|
|
639
|
+
witness = { requested: true, status: disagrees ? "disagrees" : "agrees" };
|
|
640
|
+
}
|
|
641
|
+
const latest = records.length > 0 ? ([...records].sort((a, b) => b.treeSize - a.treeSize)[0] ?? null) : null;
|
|
642
|
+
const tailEvents = latest === null ? observed : Math.max(0, observed - latest.treeSize);
|
|
643
|
+
return {
|
|
644
|
+
anchorState: finalState,
|
|
645
|
+
anchorsValid: finalState !== "ANCHOR_INVALID" && finalState !== "ANCHOR_MISMATCH",
|
|
646
|
+
anchorSource,
|
|
647
|
+
anchorCount: records.length,
|
|
648
|
+
latestAnchor: latest === null
|
|
649
|
+
? null
|
|
650
|
+
: {
|
|
651
|
+
anchorSeq: latest.anchorSeq,
|
|
652
|
+
treeSize: latest.treeSize,
|
|
653
|
+
lastHash: latest.lastHash,
|
|
654
|
+
keyId: latest.keyId,
|
|
655
|
+
timestamp: latest.timestamp,
|
|
656
|
+
},
|
|
657
|
+
unanchoredTail: {
|
|
658
|
+
events: tailEvents,
|
|
659
|
+
sinceTimestampMs: latest === null ? null : nullIfNaN(Date.parse(latest.timestamp)),
|
|
660
|
+
},
|
|
661
|
+
witness,
|
|
662
|
+
reasons: [...new Set(reasons)],
|
|
663
|
+
warnings: [...new Set(warnings)],
|
|
664
|
+
errors,
|
|
665
|
+
};
|
|
666
|
+
};
|
|
667
|
+
// Step 2 — discovery.
|
|
668
|
+
if (!anchorsPresent) {
|
|
669
|
+
return finish("UNANCHORED", []);
|
|
670
|
+
}
|
|
671
|
+
// Step 3 — trust material (before parse escalation, per constraints §4.2).
|
|
672
|
+
if (input.trust === null) {
|
|
673
|
+
reasons.push("no-trust-material");
|
|
674
|
+
errors.push("anchor artifacts present but no trust material supplied (pin the public key)");
|
|
675
|
+
return finish("ANCHOR_UNVERIFIABLE", [...input.externalAnchors, ...input.mirrorAnchors]);
|
|
676
|
+
}
|
|
677
|
+
// Step 4 — parse failures are fail-closed (mirrors the `.meta` precedent).
|
|
678
|
+
for (const e of [...input.externalErrors, ...input.mirrorErrors]) {
|
|
679
|
+
errors.push(e);
|
|
680
|
+
reasons.push(e.includes("range-invalid") ? "range-invalid" : "malformed-anchor");
|
|
681
|
+
}
|
|
682
|
+
// Cross-vault replay: a mixed-vaultId union is rejected BEFORE dedup can
|
|
683
|
+
// collapse a replayed foreign record into a same-seq group.
|
|
684
|
+
const allVaultIds = new Set([...input.externalAnchors, ...input.mirrorAnchors].map((r) => r.vaultId));
|
|
685
|
+
if (allVaultIds.size > 1) {
|
|
686
|
+
errors.push(`vault-id-mismatch: anchor set mixes ${allVaultIds.size} distinct vaultIds`);
|
|
687
|
+
reasons.push("vault-id-mismatch");
|
|
688
|
+
}
|
|
689
|
+
// Dedup within each stream (racing-emitter duplicates live in ONE stream),
|
|
690
|
+
// then merge external over mirror. A mirror copy of an external record is
|
|
691
|
+
// expected, not a duplicate; divergence at one anchorSeq is MISMATCH
|
|
692
|
+
// (AC-1.2 — the external copy governs).
|
|
693
|
+
const ext = dedupeAnchorSet(input.externalAnchors);
|
|
694
|
+
const mir = dedupeAnchorSet(input.mirrorAnchors);
|
|
695
|
+
for (const set of [ext, mir]) {
|
|
696
|
+
warnings.push(...set.warnings);
|
|
697
|
+
errors.push(...set.errors);
|
|
698
|
+
reasons.push(...set.mismatchReasons);
|
|
699
|
+
}
|
|
700
|
+
const extBySeq = new Map(ext.unique.map((r) => [r.anchorSeq, r]));
|
|
701
|
+
const mergedBySeq = new Map(extBySeq);
|
|
702
|
+
for (const m of mir.unique) {
|
|
703
|
+
const e = extBySeq.get(m.anchorSeq);
|
|
704
|
+
if (e === undefined) {
|
|
705
|
+
mergedBySeq.set(m.anchorSeq, m);
|
|
706
|
+
}
|
|
707
|
+
else if (!committedFieldsEqual(e, m)) {
|
|
708
|
+
errors.push(`mirror-disagreement: vault mirror and external copy differ at anchorSeq ${m.anchorSeq} (external governs)`);
|
|
709
|
+
reasons.push("mirror-disagreement");
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const mergedRecords = [...mergedBySeq.values()].sort((a, b) => a.anchorSeq - b.anchorSeq);
|
|
713
|
+
// Steps 4-5 — signatures, chain linkage, consistency binding. Per-stream
|
|
714
|
+
// dropped twins are re-appended so the walk signature-checks them too
|
|
715
|
+
// (spec §7.2 "both signatures valid" — a bad-sig committed-equal twin is
|
|
716
|
+
// ANCHOR_INVALID regardless of record order).
|
|
717
|
+
const chain = verifyAnchorChain([...mergedRecords, ...ext.dropped, ...mir.dropped], input.trust, input.orderedHashes);
|
|
718
|
+
errors.push(...chain.errors);
|
|
719
|
+
reasons.push(...chain.invalidReasons, ...chain.mismatchReasons);
|
|
720
|
+
warnings.push(...chain.warnings);
|
|
721
|
+
const records = chain.records;
|
|
722
|
+
// Step 5 — vault binding per record.
|
|
723
|
+
for (const r of records) {
|
|
724
|
+
if (opts.expectedVaultId !== undefined && r.vaultId !== opts.expectedVaultId) {
|
|
725
|
+
errors.push(`vault-id-mismatch: anchorSeq ${r.anchorSeq} carries vaultId ${r.vaultId}, expected ${opts.expectedVaultId}`);
|
|
726
|
+
reasons.push("vault-id-mismatch");
|
|
727
|
+
}
|
|
728
|
+
if (r.treeSize > observed) {
|
|
729
|
+
if (observed === 0) {
|
|
730
|
+
errors.push(`deletion: anchor anchorSeq ${r.anchorSeq} attests ${r.treeSize} event(s); vault presents 0 (deletion detected)`);
|
|
731
|
+
reasons.push("deletion");
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
errors.push(`rollback: anchor anchorSeq ${r.anchorSeq} attests ${r.treeSize} event(s); vault presents ${observed}`);
|
|
735
|
+
reasons.push("rollback");
|
|
736
|
+
}
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const tree = buildMerkleTree(input.orderedHashes.slice(0, r.treeSize));
|
|
740
|
+
if (tree.root === undefined || tree.root !== r.merkleRoot) {
|
|
741
|
+
errors.push(`root-mismatch: recomputed Merkle root at treeSize ${r.treeSize} does not match anchorSeq ${r.anchorSeq}`);
|
|
742
|
+
reasons.push("root-mismatch");
|
|
743
|
+
}
|
|
744
|
+
if (input.orderedHashes[r.treeSize - 1] !== r.lastHash) {
|
|
745
|
+
errors.push(`head-mismatch: stored hash at sequence ${r.treeSize} does not match anchorSeq ${r.anchorSeq} lastHash`);
|
|
746
|
+
reasons.push("head-mismatch");
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Classify: MISMATCH ≥ INVALID (constraints §4.2 severity).
|
|
750
|
+
const hasMismatch = reasons.some((c) => !INVALID_REASONS.has(c) && !NON_FAIL_REASONS.has(c));
|
|
751
|
+
const hasInvalid = reasons.some((c) => INVALID_REASONS.has(c));
|
|
752
|
+
if (hasMismatch) {
|
|
753
|
+
return finish("ANCHOR_MISMATCH", records);
|
|
754
|
+
}
|
|
755
|
+
if (hasInvalid) {
|
|
756
|
+
return finish("ANCHOR_INVALID", records);
|
|
757
|
+
}
|
|
758
|
+
// Step 6 — freshness (caller-supplied policy only; tail always reported).
|
|
759
|
+
const latest = [...records].sort((a, b) => b.treeSize - a.treeSize)[0];
|
|
760
|
+
if (latest !== undefined) {
|
|
761
|
+
const ts = Date.parse(latest.timestamp);
|
|
762
|
+
// A witness-attested integration time supersedes the operator's claim:
|
|
763
|
+
// the caller verified a transparency-log receipt binding this anchor to a
|
|
764
|
+
// time the vault operator did not choose, which is exactly the input the
|
|
765
|
+
// operator-claimed path lacks.
|
|
766
|
+
const attestedMs = opts.attestedTimeMs !== undefined && Number.isSafeInteger(opts.attestedTimeMs)
|
|
767
|
+
? opts.attestedTimeMs
|
|
768
|
+
: null;
|
|
769
|
+
if (Number.isFinite(ts) && ts > nowMs) {
|
|
770
|
+
// Operator-claimed time is in the auditor's future — clock gaming
|
|
771
|
+
// (buyer-rejection §5.13). Raised even when an attested time supersedes
|
|
772
|
+
// it below: a forward-dated claim is evidence about the operator whether
|
|
773
|
+
// or not a witness also spoke, and suppressing it on the attested path
|
|
774
|
+
// would let a receipt LAUNDER the very signal it was supplied to
|
|
775
|
+
// corroborate. --max-unanchored-events stays the clock-independent control.
|
|
776
|
+
warnings.push("future-timestamp");
|
|
777
|
+
}
|
|
778
|
+
const tail = Math.max(0, observed - latest.treeSize);
|
|
779
|
+
if (opts.maxUnanchoredEvents !== undefined && tail > opts.maxUnanchoredEvents) {
|
|
780
|
+
errors.push(`stale: ${tail} event(s) since the newest anchor exceed the supplied threshold ${opts.maxUnanchoredEvents}`);
|
|
781
|
+
return finish("ANCHOR_STALE", records);
|
|
782
|
+
}
|
|
783
|
+
const ageBasisMs = attestedMs ?? ts;
|
|
784
|
+
if (opts.maxAnchorAgeMs !== undefined &&
|
|
785
|
+
Number.isFinite(ageBasisMs) &&
|
|
786
|
+
nowMs - ageBasisMs > opts.maxAnchorAgeMs &&
|
|
787
|
+
observed > latest.treeSize) {
|
|
788
|
+
errors.push(attestedMs === null
|
|
789
|
+
? "stale: newest anchor is older than the supplied --max-anchor-age (operator-claimed time)"
|
|
790
|
+
: "stale: newest anchor is older than the supplied --max-anchor-age (witness-attested time)");
|
|
791
|
+
return finish("ANCHOR_STALE", records);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return finish("ANCHORED_VERIFIED", records);
|
|
795
|
+
}
|
|
796
|
+
// ── Inclusion against an external anchor (the F1 fix) ──
|
|
797
|
+
/**
|
|
798
|
+
* Verify a Merkle inclusion proof against a SIGNED EXTERNAL anchor record —
|
|
799
|
+
* root and treeSize come from the record, never recomputed from the events
|
|
800
|
+
* under check (spec §7.1; closes F1). The record must verify under the pinned
|
|
801
|
+
* root or a supplied successor pin.
|
|
802
|
+
*/
|
|
803
|
+
export function verifyInclusionAgainstAnchor(proof, record, trust) {
|
|
804
|
+
const sig = verifyAnchorSignature(record, [trust.rootPem, ...(trust.successorPinsPem ?? [])]);
|
|
805
|
+
if (!sig.ok)
|
|
806
|
+
return false;
|
|
807
|
+
return verifyInclusionProof(proof, record.merkleRoot, record.treeSize);
|
|
808
|
+
}
|
|
809
|
+
//# sourceMappingURL=anchor-verify.js.map
|