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.
@@ -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