@intyga/verify 0.0.0-bootstrap.0 → 1.1.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,613 @@
1
+ import { verifyAnchorQuorum, } from "./ledger-anchor.js";
2
+ import { AUDIT_PROFILE, leafCountMismatch, supportedEnvelope, } from "./ledger-bundle.js";
3
+ import { verifyAuditSignature, uncheckedSignature, } from "./ledger-signature.js";
4
+ import { chainHash } from "./ledger-chain.js";
5
+ import { leafHash } from "./ledger-leaf.js";
6
+ import { verifyInclusionProof } from "./ledger-proof.js";
7
+ // Multi-entry auditor evidence bundle (date-range export from the console's Audit page). Each entry
8
+ // carries its own two-hop inclusion proof. REDACTED entries (content removed by the retention
9
+ // ladder) carry only the leaf digest: they verify as COMMITMENT-ONLY — "an event with this digest
10
+ // existed at this position under this anchored root" — while unredacted entries additionally bind
11
+ // the displayed content to the leaf (CONTENT-VERIFIED).
12
+ // DEWP canonical kind (docs/DEWP.md §6.3). Producers emit this form and verifiers require it; there
13
+ // is no vendor-prefixed alias, since no bundle has ever been exported under one.
14
+ export const EVIDENCE_BUNDLE_KIND = "dewp.audit.evidence-bundle";
15
+ /**
16
+ * Compare an entry's DISPLAYED fields against the committed preimage.
17
+ *
18
+ * The leaf commits to `canonical`. Every field duplicated next to it — seq, createdAt, type,
19
+ * outcome, signerDid, sigAlg, tenantSeq — is unsigned, and this is the artifact a human, a console
20
+ * or a SIEM actually reads. Without this check a bundle could carry genuine proofs under a real
21
+ * anchored root while displaying `outcome: "SUCCESS"` over a committed `FAILURE`, and still come
22
+ * back fully verified. Returns a reason on mismatch, or null.
23
+ */
24
+ function displayMismatch(event) {
25
+ const c = event.canonical;
26
+ if (!c)
27
+ return null;
28
+ const differs = (label, shown, committed) => shown != null && String(shown) !== String(committed)
29
+ ? `displayed ${label} ("${String(shown)}") does not match the committed value ("${String(committed)}")`
30
+ : null;
31
+ return (differs("seq", event.seq, c.seq) ??
32
+ differs("createdAt", event.createdAt, c.createdAt) ??
33
+ differs("type", event.type, c.event) ??
34
+ differs("outcome", event.outcome, c.outcome) ??
35
+ differs("signerDid", event.signerDid, c.signerDid) ??
36
+ differs("sigAlg", event.sigAlg, c.sigAlg) ??
37
+ differs("tenantSeq", event.tenantSeq, c.tenantSeq));
38
+ }
39
+ export function verifyEvidenceBundle(bundle, opts = {}) {
40
+ const notes = [];
41
+ const failed = [];
42
+ let contentVerified = 0;
43
+ let commitmentOnly = 0;
44
+ /** Entries whose displayed type/outcome rest on the producer's redaction record, not on the log. */
45
+ let redactedDisplayed = 0;
46
+ const signatureChecks = new Map();
47
+ let signaturesVerified = 0;
48
+ let signaturesNotCheckable = 0;
49
+ const signaturesInvalid = [];
50
+ // DEWP §6.5: "a compliant verifier MUST reject any other value." A note let a container of one
51
+ // type be fed to the verifier for another and still come back ok — the caller would be reading a
52
+ // verdict produced under semantics the artifact was never built for.
53
+ if (bundle.kind !== EVIDENCE_BUNDLE_KIND || !supportedEnvelope(bundle)) {
54
+ failed.push({
55
+ seq: "-",
56
+ reason: `refusing bundle kind "${bundle.kind}", protocol, version or algorithm registry (DEWP §7/§12)`,
57
+ });
58
+ }
59
+ // Unknown canonical layout ⇒ leaf binding is not attempted (see verifyBundle for the rationale). That
60
+ // is a check the PRODUCER can switch off, so an entry that ships a preimage under such a profile
61
+ // fails below rather than passing as commitment-only: its content, tenant and tenantSeq would all be
62
+ // unbound, and the contiguity verdict would be computed over counters nothing commits to.
63
+ const unknownProfile = bundle.profile !== undefined && bundle.profile !== AUDIT_PROFILE;
64
+ if (unknownProfile) {
65
+ notes.push(`Unknown canonical profile "${bundle.profile}" — this verifier implements "${AUDIT_PROFILE}", so ` +
66
+ "entry content cannot be bound to its leaf. Entries without a preimage are still checked for " +
67
+ "commitment; an entry that carries one cannot be verified and fails.");
68
+ }
69
+ const bundleTenantId = bundle.tenant?.id ?? null;
70
+ /** DEWP §6.3 redaction, reading the `redaction` object and falling back to the legacy boolean. */
71
+ const isRedacted = (event) => event.redaction ? event.redaction.mode === "COMMITMENT_ONLY" : event.redacted === true;
72
+ /**
73
+ * `tenantSeq` arrives as an untrusted string. Bare `BigInt(x)` THROWS on anything non-numeric, so a
74
+ * malformed counter propagated a SyntaxError out of this function instead of returning a verdict —
75
+ * fail-closed only by accident of the CLI exiting non-zero, and an exception for any library
76
+ * consumer. Oversized digit strings are also superlinear to parse, hence the length bound: a 64-bit
77
+ * counter needs 20 digits.
78
+ */
79
+ const parseCounter = (raw) => {
80
+ if (typeof raw !== "string" || !/^-?\d{1,20}$/.test(raw))
81
+ return null;
82
+ try {
83
+ return BigInt(raw);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ };
89
+ const trustedRecords = opts.trustedCheckpoints ?? [];
90
+ const trusted = opts.trustedRoots || opts.trustedCheckpoints
91
+ ? new Set([...(opts.trustedRoots ?? []), ...trustedRecords.map((r) => r.root)])
92
+ : null;
93
+ /** Root → the caller's own record for it (first wins). */
94
+ const trustedRecordByRoot = new Map();
95
+ for (const r of trustedRecords)
96
+ if (!trustedRecordByRoot.has(r.root))
97
+ trustedRecordByRoot.set(r.root, r);
98
+ if (!trusted) {
99
+ notes.push("No roots supplied — verifying against the roots inside the bundle. This proves internal " +
100
+ "consistency, NOT that the bundle matches the producer's anchored log. Re-run with roots you " +
101
+ "obtained earlier or from the published roots file for a real verdict.");
102
+ }
103
+ // §5.4 chain fields, where the export carries them: the chain hash must recompute from the
104
+ // checkpoint's own fields, or the anchors that bind it would be binding something the bundle does
105
+ // not actually describe.
106
+ for (const cp of bundle.checkpoints) {
107
+ if (cp.chainHash == null)
108
+ continue;
109
+ const complete = typeof cp.prevChainHash === "string" &&
110
+ typeof cp.anchoredAt === "string" &&
111
+ typeof cp.entryCount === "number" &&
112
+ Number.isSafeInteger(cp.entryCount);
113
+ const recomputed = complete
114
+ ? chainHash({
115
+ prevChainHash: cp.prevChainHash,
116
+ root: cp.root,
117
+ seqStart: cp.seqStart,
118
+ seqEnd: cp.seqEnd,
119
+ entryCount: cp.entryCount,
120
+ anchoredAt: cp.anchoredAt,
121
+ })
122
+ : null;
123
+ if (recomputed !== cp.chainHash) {
124
+ failed.push({
125
+ seq: "-",
126
+ reason: complete
127
+ ? `checkpoint ${cp.id} chainHash does not recompute from its root, range, entry count and anchoredAt`
128
+ : `checkpoint ${cp.id} carries a chainHash without the prevChainHash/entryCount/anchoredAt it commits to`,
129
+ });
130
+ }
131
+ }
132
+ // A checkpoint the caller holds its own record for must agree with that record on every field both
133
+ // state. The bundle's copy is the producer's; the caller's is what the roots-file chain verified. A
134
+ // re-dated `anchoredAt` with a self-consistent chainHash over a made-up predecessor recomputes fine
135
+ // above, and is caught only here.
136
+ for (const cp of bundle.checkpoints) {
137
+ const record = trustedRecordByRoot.get(cp.root);
138
+ if (!record)
139
+ continue;
140
+ const pairs = [
141
+ ["seqStart", cp.seqStart, record.seqStart],
142
+ ["seqEnd", cp.seqEnd, record.seqEnd],
143
+ ["entryCount", cp.entryCount, record.entryCount],
144
+ ["anchoredAt", cp.anchoredAt, record.anchoredAt],
145
+ ["chainHash", cp.chainHash, record.chainHash],
146
+ ];
147
+ for (const [field, shown, held] of pairs) {
148
+ if (shown != null && held != null && shown !== held) {
149
+ failed.push({
150
+ seq: "-",
151
+ reason: `checkpoint ${cp.id} ${field} contradicts your trusted checkpoint record for its root`,
152
+ });
153
+ }
154
+ }
155
+ }
156
+ /**
157
+ * The position and time anchors over `root` are held to: the caller's record where it states a
158
+ * field, the bundle's checkpoint otherwise.
159
+ */
160
+ const effectiveCheckpoint = (root, cp) => {
161
+ const record = trustedRecordByRoot.get(root);
162
+ return {
163
+ seqStart: record?.seqStart ?? cp?.seqStart ?? null,
164
+ seqEnd: record?.seqEnd ?? cp?.seqEnd ?? null,
165
+ entryCount: record?.entryCount ?? cp?.entryCount ?? null,
166
+ chainHash: record?.chainHash ?? cp?.chainHash ?? null,
167
+ anchoredAt: record?.anchoredAt ?? cp?.anchoredAt ?? null,
168
+ };
169
+ };
170
+ const knownRoots = new Map();
171
+ /** Root → the checkpoint that states it (first wins), for the anchors' expected position. */
172
+ const checkpointByRoot = new Map();
173
+ const bundleAnchorsByRoot = new Map();
174
+ /** Checkpoint id OR root → root, so the caller may key its anchors by either. */
175
+ const checkpointKeyToRoot = new Map();
176
+ for (const cp of bundle.checkpoints) {
177
+ knownRoots.set(cp.root, cp.anchorRef);
178
+ if (!checkpointByRoot.has(cp.root))
179
+ checkpointByRoot.set(cp.root, cp);
180
+ if (cp.anchors?.length)
181
+ bundleAnchorsByRoot.set(cp.root, cp.anchors);
182
+ if (cp.id)
183
+ checkpointKeyToRoot.set(cp.id, cp.root);
184
+ }
185
+ // Roots last: a checkpoint whose id happens to equal another checkpoint's root must not shadow it.
186
+ for (const cp of bundle.checkpoints)
187
+ checkpointKeyToRoot.set(cp.root, cp.root);
188
+ // Caller-fetched anchors in two views: the flat union is the quorum candidate pool (as before —
189
+ // verifyAnchorQuorum only counts anchors whose dailyRoot IS the root under evaluation), while
190
+ // `callerAnchorsByRoot` records which checkpoint the caller said each one was fetched for. Only the
191
+ // second may feed divergence; see the quorum loop.
192
+ const callerAnchors = [];
193
+ const callerAnchorsByRoot = new Map();
194
+ const callerKeyed = Boolean(opts.anchors) && !Array.isArray(opts.anchors);
195
+ let unattributedKeys = false;
196
+ if (Array.isArray(opts.anchors)) {
197
+ callerAnchors.push(...opts.anchors);
198
+ }
199
+ else if (opts.anchors) {
200
+ for (const [key, list] of Object.entries(opts.anchors)) {
201
+ if (!Array.isArray(list) || list.length === 0)
202
+ continue;
203
+ callerAnchors.push(...list);
204
+ const root = checkpointKeyToRoot.get(key);
205
+ if (root === undefined) {
206
+ // Keyed to a checkpoint this bundle does not contain. Still a quorum candidate, but it
207
+ // cannot be divergence evidence for a checkpoint nobody can identify.
208
+ unattributedKeys = true;
209
+ continue;
210
+ }
211
+ callerAnchorsByRoot.set(root, [...(callerAnchorsByRoot.get(root) ?? []), ...list]);
212
+ }
213
+ }
214
+ // One committed event appears once. A genuine leaf used twice (or two entries claiming one seq) can
215
+ // otherwise fill two holes in the tenant sequence from a single real event.
216
+ const seenLeaves = new Set();
217
+ const seenSeqs = new Set();
218
+ /** Leaf counts are properties of a TREE: every proof into one block/checkpoint must agree on them. */
219
+ const blockLeafCounts = new Map();
220
+ const checkpointLeafCounts = new Map();
221
+ for (const entry of bundle.entries) {
222
+ const seq = entry.event.seq;
223
+ const root = entry.proof.checkpointRoot;
224
+ if (seenLeaves.has(entry.proof.leaf) || seenSeqs.has(seq)) {
225
+ failed.push({ seq, reason: "duplicate entry: this leaf or seq already appears in the bundle" });
226
+ continue;
227
+ }
228
+ seenLeaves.add(entry.proof.leaf);
229
+ seenSeqs.add(seq);
230
+ if (!root) {
231
+ failed.push({
232
+ seq,
233
+ reason: "no checkpoint root (event not committed at export time)",
234
+ });
235
+ continue;
236
+ }
237
+ if (!knownRoots.has(root)) {
238
+ failed.push({
239
+ seq,
240
+ reason: "proof's checkpoint root is not in the bundle's checkpoint list",
241
+ });
242
+ continue;
243
+ }
244
+ if (trusted && !trusted.has(root)) {
245
+ failed.push({
246
+ seq,
247
+ reason: "proof's checkpoint root is not among the supplied trusted roots",
248
+ });
249
+ continue;
250
+ }
251
+ if (!verifyInclusionProof(entry.proof, root)) {
252
+ failed.push({
253
+ seq,
254
+ reason: "inclusion proof does not recompute to the daily root",
255
+ });
256
+ continue;
257
+ }
258
+ // DEWP §17.3: the prover supplies the leaf counts, so an interior node of a larger block can
259
+ // otherwise pass as a leaf of a smaller one. Bind them to each other and to the entry count.
260
+ const priorBlock = blockLeafCounts.get(entry.proof.blockRoot);
261
+ const priorCheckpoint = checkpointLeafCounts.get(root);
262
+ const countBad = (priorBlock !== undefined && priorBlock !== entry.proof.blockLeafCount) ||
263
+ (priorCheckpoint !== undefined && priorCheckpoint !== entry.proof.checkpointLeafCount)
264
+ ? "proofs into the same block or checkpoint disagree on its leaf count"
265
+ : leafCountMismatch(entry.proof, effectiveCheckpoint(root, checkpointByRoot.get(root)).entryCount);
266
+ if (countBad) {
267
+ failed.push({ seq, reason: countBad });
268
+ continue;
269
+ }
270
+ blockLeafCounts.set(entry.proof.blockRoot, entry.proof.blockLeafCount);
271
+ checkpointLeafCounts.set(root, entry.proof.checkpointLeafCount);
272
+ // A redaction marker is read from the bundle, so it must not be able to switch off a check the
273
+ // entry's own data would otherwise satisfy. DEWP §15 says a verifier MUST NOT attempt
274
+ // contentVerified for a COMMITMENT_ONLY entry *because the preimage is intentionally absent* —
275
+ // an entry that ships a preimage AND claims redaction is self-contradictory, and the preimage is
276
+ // the thing that can actually be checked. Bind it. Otherwise a fabricated `canonical` plus
277
+ // `redaction: {mode: "COMMITMENT_ONLY"}` relabels any real committed leaf to anything at all.
278
+ if (isRedacted(entry.event) && !entry.event.canonical) {
279
+ // §15: a COMMITMENT_ONLY entry MUST keep the ORIGINAL leaf hash — it is what still verifies
280
+ // against the anchored root. If the redaction record names a different one, the commitment was
281
+ // rewritten during redaction, which is exactly what redaction must not be able to do.
282
+ const retained = entry.event.redaction?.commitment?.leaf;
283
+ if (retained !== undefined && retained !== entry.proof.leaf) {
284
+ failed.push({
285
+ seq,
286
+ reason: "redaction commitment leaf does not match the proof leaf (commitment was altered)",
287
+ });
288
+ continue;
289
+ }
290
+ redactedDisplayed++;
291
+ commitmentOnly++;
292
+ }
293
+ else if (unknownProfile && entry.event.canonical) {
294
+ // The preimage cannot be bound under a layout this verifier does not implement, and a pass here
295
+ // would let the producer switch leaf binding off (DEWP §4.5/§7.2 rule 1) — mirrors verifyBundle.
296
+ failed.push({
297
+ seq,
298
+ reason: `canonical preimage under unknown profile "${bundle.profile}" cannot be bound to its leaf`,
299
+ });
300
+ }
301
+ else if (unknownProfile) {
302
+ // Commitment verified above; there is no content to bind.
303
+ commitmentOnly++;
304
+ }
305
+ else if (entry.event.canonical) {
306
+ if (leafHash(entry.event.canonical) !== entry.proof.leaf) {
307
+ failed.push({
308
+ seq,
309
+ reason: "leaf hash does not match the event content (leaf binding failed)",
310
+ });
311
+ continue;
312
+ }
313
+ // The leaf commits to `canonical`. Everything ALONGSIDE it on the entry is a display copy that
314
+ // nothing signs, so it has to be checked against the committed value or it is just a caption.
315
+ const bad = displayMismatch(entry.event);
316
+ if (bad) {
317
+ failed.push({ seq, reason: bad });
318
+ continue;
319
+ }
320
+ // The redaction record is unsigned. Where a preimage exists it is not a counter source (§7.2),
321
+ // so one that disagrees with the committed counter is a caption contradicting the evidence.
322
+ const redactionSeq = entry.event.redaction?.commitment?.tenantSeq;
323
+ if (redactionSeq != null && redactionSeq !== entry.event.canonical.tenantSeq) {
324
+ failed.push({
325
+ seq,
326
+ reason: `redaction record tenantSeq ("${redactionSeq}") does not match the committed value ("${String(entry.event.canonical.tenantSeq)}")`,
327
+ });
328
+ continue;
329
+ }
330
+ // The entry belongs to THIS bundle's tenant. canonical.tenantId is committed; without this an
331
+ // entry from another tenant, with a genuine proof under a root the auditor trusts, counts as
332
+ // one of this tenant's own records. A bundle that declares no tenant has none to belong to:
333
+ // skipping the check there let entries of several tenants be spliced into one "contiguous"
334
+ // sequence, since tenantSeq is a per-tenant counter.
335
+ if (entry.event.canonical.tenantId != null && entry.event.canonical.tenantId !== bundleTenantId) {
336
+ failed.push({
337
+ seq,
338
+ reason: bundleTenantId == null
339
+ ? `entry belongs to tenant ${entry.event.canonical.tenantId}, but the bundle declares no tenant`
340
+ : `entry belongs to tenant ${entry.event.canonical.tenantId}, not ${bundleTenantId}`,
341
+ });
342
+ continue;
343
+ }
344
+ // §7.1 signatureVerified. Runs only once the leaf binding above passed, so the signature we
345
+ // check is provably the committed one rather than something the bundle attached.
346
+ const canonical = entry.event.canonical;
347
+ const signature = verifyAuditSignature(canonical, opts.signaturePolicy);
348
+ signatureChecks.set(seq, signature);
349
+ if (signature.status === "verified")
350
+ signaturesVerified++;
351
+ else if (signature.status === "invalid")
352
+ signaturesInvalid.push({ seq });
353
+ else
354
+ signaturesNotCheckable++;
355
+ contentVerified++;
356
+ }
357
+ else {
358
+ failed.push({
359
+ seq,
360
+ reason: "unredacted entry is missing its canonical preimage",
361
+ });
362
+ }
363
+ }
364
+ // Check per-tenant sequence contiguity (completeness / omission detection). DEWP §3 Invariant 4
365
+ // requires rejecting gaps, duplicates AND out-of-order values, so compare against tenantSeq_N + 1
366
+ // exactly rather than merely checking for forward movement.
367
+ //
368
+ // The counter is read from the COMMITTED preimage first. Reading `entry.tenantSeq` first was the
369
+ // bug: that field is a sibling of `canonical` and is covered by nothing — not the leaf, not the
370
+ // root, not any anchor. A producer could omit the incriminating events, keep the honest entries
371
+ // with their genuine proofs, renumber the display counters to close the hole, and the contiguity
372
+ // check would report no gap (verified). A redacted entry has no preimage, so it falls back to the
373
+ // redaction commitment and then to the entry, or the hole lawful redaction leaves would read as an
374
+ // omission — but those values are NOT leaf-bound, which the note below now says plainly.
375
+ //
376
+ // The fallback is for entries WITHOUT a preimage only (§7.2 rule 2). An entry with a preimage whose
377
+ // committed tenantSeq is null has no counter (rule 5): falling through to the unsigned redaction
378
+ // record let a genuine tenantless system leaf, tagged `redaction: {mode: "NONE", commitment:
379
+ // {tenantSeq: "N"}}`, fill tenant T's hole at N while counting as fully content-verified.
380
+ let lastTenantSeq = null;
381
+ let firstTenantSeq = null;
382
+ let sawUncountedEntry = false;
383
+ let sawUnboundCounter = false;
384
+ for (const entry of bundle.entries) {
385
+ const canonical = entry.event.canonical;
386
+ // Under an unknown profile a preimage is not leaf-bound; that entry has already failed above.
387
+ if (canonical && unknownProfile)
388
+ continue;
389
+ const tenantSeqStr = canonical
390
+ ? canonical.tenantSeq
391
+ : (entry.event.redaction?.commitment?.tenantSeq ?? entry.event.tenantSeq);
392
+ if (!canonical && tenantSeqStr != null)
393
+ sawUnboundCounter = true;
394
+ if (tenantSeqStr == null) {
395
+ sawUncountedEntry = true;
396
+ continue;
397
+ }
398
+ const currentTenantSeq = parseCounter(tenantSeqStr);
399
+ if (currentTenantSeq === null) {
400
+ failed.push({
401
+ seq: entry.event.seq,
402
+ reason: `tenantSeq ${JSON.stringify(tenantSeqStr)} is not a valid integer counter`,
403
+ });
404
+ continue;
405
+ }
406
+ if (lastTenantSeq !== null && currentTenantSeq !== lastTenantSeq + 1n) {
407
+ failed.push({
408
+ seq: entry.event.seq,
409
+ reason: currentTenantSeq <= lastTenantSeq
410
+ ? `per-tenant sequence is not strictly increasing: tenantSeq ${currentTenantSeq.toString()} follows ${lastTenantSeq.toString()}`
411
+ : `per-tenant omission detected: sequence gap between tenantSeq ${lastTenantSeq.toString()} and ${currentTenantSeq.toString()}`,
412
+ });
413
+ }
414
+ if (firstTenantSeq === null)
415
+ firstTenantSeq = currentTenantSeq;
416
+ lastTenantSeq = currentTenantSeq;
417
+ }
418
+ if (redactedDisplayed > 0) {
419
+ notes.push(`${redactedDisplayed} entr${redactedDisplayed === 1 ? "y is" : "ies are"} COMMITMENT_ONLY: inclusion ` +
420
+ "is proven against the retained leaf, but the displayed type/outcome/detail are NOT covered by " +
421
+ "it — the preimage they would be checked against is gone. They rest on the producer's redaction " +
422
+ "record (DEWP §15), not on the anchored log.");
423
+ }
424
+ if (sawUncountedEntry) {
425
+ notes.push("Some entries carry no tenantSeq, so gapless completeness could not be checked across them. " +
426
+ "(Bundles exported before redacted entries carried tenantSeq — re-export for a complete check.)");
427
+ }
428
+ if (sawUnboundCounter) {
429
+ notes.push("Some entries carry no canonical preimage (COMMITMENT_ONLY redaction), so their tenantSeq was read " +
430
+ "from the redaction record or the display copy and is NOT covered by the Merkle leaf. " +
431
+ "Gaplessness across those rests on the producer's redaction record, not on the anchored log.");
432
+ }
433
+ // DEWP §6.3: the bundle asserts WHICH contiguous tenantSeq range it covers. Confirm the entries
434
+ // actually span it — contiguity alone only proves the entries present are consecutive, not that the
435
+ // producer did not quietly truncate either end of the range it claimed to export.
436
+ const commitment = bundle.tenantSequenceCommitment;
437
+ if (commitment) {
438
+ const firstSeen = firstTenantSeq;
439
+ const lastSeen = lastTenantSeq;
440
+ if (firstSeen === null || lastSeen === null) {
441
+ notes.push("Bundle declares a tenantSequenceCommitment but no entry carries a tenantSeq to check it against.");
442
+ }
443
+ else {
444
+ const claimedFirst = parseCounter(commitment.firstTenantSeq);
445
+ const claimedLast = parseCounter(commitment.lastTenantSeq);
446
+ if (claimedFirst === null || claimedLast === null) {
447
+ failed.push({
448
+ seq: bundle.entries[0]?.event.seq ?? "?",
449
+ reason: "tenantSequenceCommitment carries a non-numeric firstTenantSeq/lastTenantSeq",
450
+ });
451
+ }
452
+ else {
453
+ if (firstSeen !== claimedFirst)
454
+ failed.push({
455
+ seq: bundle.entries[0]?.event.seq ?? "?",
456
+ reason: `bundle claims it starts at tenantSeq ${commitment.firstTenantSeq} but the first entry is ${firstSeen.toString()}`,
457
+ });
458
+ if (lastSeen !== claimedLast)
459
+ failed.push({
460
+ seq: bundle.entries.at(-1)?.event.seq ?? "?",
461
+ reason: `bundle claims it ends at tenantSeq ${commitment.lastTenantSeq} but the last entry is ${lastSeen.toString()}`,
462
+ });
463
+ }
464
+ if (commitment.tenantId !== bundleTenantId)
465
+ notes.push(`tenantSequenceCommitment names tenant ${commitment.tenantId}, which differs from the bundle's tenant ${String(bundleTenantId)}.`);
466
+ }
467
+ }
468
+ // DEWP §5.3 anchor quorum, per distinct root. Candidate anchors come from the caller when
469
+ // supplied, otherwise from the bundle's own per-checkpoint `anchors` — the §6.3 set the quorum
470
+ // rule is defined over. Either way the check only runs when the caller supplied a policy AND a key
471
+ // resolver: signatures are checked against keys the VERIFIER trusts, so a bundle cannot vouch for
472
+ // itself by shipping anchors it signed with its own key.
473
+ //
474
+ // Bundle-carried anchors may COUNT toward quorum but may never trigger the fatal DIVERGENCE
475
+ // verdict (mirrors verifyBundle): the producer chooses what the bundle carries, so its anchors are
476
+ // no evidence that nothing conflicting exists, and appending a real, publicly available anchor
477
+ // from another checkpoint must never make a valid bundle read as tampering. Only anchors the caller
478
+ // fetched itself, per checkpoint, can establish divergence — and one whose signed seq range names
479
+ // another checkpoint cannot.
480
+ //
481
+ // The same misbinding applies to the caller's own anchors once a bundle spans more than one
482
+ // checkpoint, which a date-range export routinely does: passing the whole flat list to EVERY root
483
+ // read a genuine anchor for checkpoint A as divergence while evaluating checkpoint B, and turned a
484
+ // sound multi-day export into a tamper alarm. So caller anchors are attributed to a checkpoint
485
+ // first. The keyed form is exact. A flat list can only be attributed by elimination — an anchor
486
+ // over ANOTHER checkpoint's root in this same bundle is explainable and is therefore not
487
+ // divergence evidence — which keeps the single-checkpoint behaviour identical and leaves one gap
488
+ // stated in the note below: a producer who appends a decoy checkpoint carrying the real root can
489
+ // absorb the conflicting anchor that way. That downgrades the verdict from DIVERGENCE to "quorum
490
+ // not met" for the checkpoint it forged, never to ok.
491
+ // Gated on the CALLER having asked (policy + resolver), never on candidates existing: a producer
492
+ // who strips `checkpoints[].anchors` must get "quorum not met (0/N)", not a skipped check. Gating
493
+ // on candidates was the same shape as the trap verifyBundle documents — a check the prover can
494
+ // switch off — with the CLI then printing VERIFIED under a policy nobody evaluated.
495
+ const canCheckAnchors = Boolean(opts.anchorPolicy && (opts.resolveAnchorKey || opts.externalKeys));
496
+ const usedBundleAnchors = canCheckAnchors && callerAnchors.length === 0 && bundleAnchorsByRoot.size > 0;
497
+ const roots = [...knownRoots.entries()].map(([root, anchorRef]) => {
498
+ if (!canCheckAnchors || !opts.anchorPolicy) {
499
+ return {
500
+ root,
501
+ anchorRef,
502
+ anchorVerified: null,
503
+ verifiedIssuers: [],
504
+ witnessTimes: {},
505
+ };
506
+ }
507
+ // Anchors bind a POSITION: they count for this root only if their signed range, chain hash and
508
+ // time are this checkpoint's own.
509
+ const expected = effectiveCheckpoint(root, checkpointByRoot.get(root));
510
+ // §5.3/§6.3: an anchor is held to its checkpoint's chain hash and claimed time. A checkpoint that
511
+ // states neither (and has no caller record to supply them) cannot hold anything to anything, and
512
+ // skipping it silently is what let a producer strip `anchoredAt`/`chainHash` to turn off the time
513
+ // bound. So it never counts as anchored — divergence is still evaluated, so stripping the fields
514
+ // cannot also turn a tamper alarm into a mere "not anchored".
515
+ const positionUnknown = expected.chainHash == null || expected.anchoredAt == null;
516
+ if (positionUnknown) {
517
+ notes.push(`Root ${root.slice(0, 16)}…: its checkpoint carries no chainHash/anchoredAt and no trusted checkpoint ` +
518
+ "record supplies them, so its anchors cannot be held to a position and time (DEWP §5.3) — not anchored.");
519
+ }
520
+ const candidates = callerAnchors.length ? callerAnchors : (bundleAnchorsByRoot.get(root) ?? []);
521
+ const divergenceAnchors = callerKeyed
522
+ ? (callerAnchorsByRoot.get(root) ?? [])
523
+ : callerAnchors.filter((a) => a.dailyRoot === root || !knownRoots.has(a.dailyRoot));
524
+ const q = verifyAnchorQuorum(candidates, root, opts.anchorPolicy, opts.resolveAnchorKey ?? (() => null), {
525
+ divergenceAnchors,
526
+ externalKeys: opts.externalKeys,
527
+ checkpoint: {
528
+ seqStart: expected.seqStart,
529
+ seqEnd: expected.seqEnd,
530
+ chainHash: expected.chainHash,
531
+ anchoredAt: expected.anchoredAt,
532
+ },
533
+ });
534
+ if (q.divergence) {
535
+ failed.push({ seq: "-", reason: `ANCHOR DIVERGENCE for root ${root.slice(0, 16)}…: ${q.reason}` });
536
+ }
537
+ else if (!q.ok && q.reason) {
538
+ notes.push(`Root ${root.slice(0, 16)}…: ${q.reason}`);
539
+ }
540
+ // Report TSA evidence that could not be verified under the caller's configuration.
541
+ if (q.note)
542
+ notes.push(`Root ${root.slice(0, 16)}…: ${q.note}`);
543
+ return {
544
+ root,
545
+ anchorRef,
546
+ anchorVerified: q.ok && !positionUnknown,
547
+ verifiedIssuers: positionUnknown ? [] : q.verifiedIssuers,
548
+ witnessTimes: q.witnessTimes,
549
+ };
550
+ });
551
+ if (usedBundleAnchors) {
552
+ notes.push("Anchor quorum was evaluated over the anchors carried in the bundle's checkpoints. They verify " +
553
+ "only under keys YOU trust, so the result is sound — but divergence detection needs anchors " +
554
+ "you fetched per checkpoint yourself (pass `anchors`).");
555
+ }
556
+ if (canCheckAnchors && callerAnchors.length > 0 && !callerKeyed && knownRoots.size > 1) {
557
+ notes.push(`The anchors you supplied came as one flat list while this bundle spans ${knownRoots.size} checkpoints, ` +
558
+ "so each one could only be attributed by its own root. Divergence is therefore reported only for " +
559
+ "anchors over a root this bundle does not claim at all. Key them by checkpoint id or root " +
560
+ "(`anchors: { [checkpointId]: [...] }`) for a per-checkpoint divergence verdict.");
561
+ }
562
+ if (unattributedKeys) {
563
+ notes.push("Some supplied anchors are keyed to a checkpoint id/root this bundle does not contain. They still " +
564
+ "count toward quorum for a root they sign, but they cannot establish divergence — check the keys " +
565
+ "against `checkpoints[]`, because a producer renaming a checkpoint would look exactly like this.");
566
+ }
567
+ if (!canCheckAnchors && !(opts.anchorPolicy || opts.resolveAnchorKey)) {
568
+ notes.push("No anchor policy supplied — the roots above were compared, but no independent signature over " +
569
+ "them was checked. Pass anchorPolicy + resolveAnchorKey (and optionally your own anchors) " +
570
+ "for a DEWP §5.3 verdict.");
571
+ }
572
+ else if (!canCheckAnchors) {
573
+ notes.push("Anchor policy incomplete — anchorPolicy and either resolveAnchorKey or externalKeys are required for a DEWP " +
574
+ "§5.3 verdict; neither alone can check a signature.");
575
+ }
576
+ // When a policy WAS supplied, every root must reach quorum; a bundle resting on an unanchored root
577
+ // is not independently attested no matter how well its proofs verify.
578
+ const allAnchored = !opts.anchorPolicy || (canCheckAnchors && roots.every((r) => r.anchorVerified === true));
579
+ const ok = failed.length === 0 && bundle.entries.length > 0 && !!trusted && allAnchored;
580
+ if (!trusted && failed.length === 0 && bundle.entries.length > 0) {
581
+ notes.push("All entries internally consistent; supply --roots for an independent verdict.");
582
+ }
583
+ if (signaturesInvalid.length > 0) {
584
+ notes.push(`${signaturesInvalid.length} entr${signaturesInvalid.length === 1 ? "y" : "ies"} carr${signaturesInvalid.length === 1 ? "ies" : "y"} ES256 proof material that does NOT verify (seq ${signaturesInvalid
585
+ .map((s) => s.seq)
586
+ .join(", ")}). The signature bytes are themselves committed, so this is not bundle tampering — ` +
587
+ "it means the producer anchored a signature that does not check out.");
588
+ }
589
+ const checks = bundle.entries.map((e) => ({
590
+ seq: e.event.seq,
591
+ ...(signatureChecks.get(e.event.seq) ?? uncheckedSignature()),
592
+ }));
593
+ if (opts.requireSignatures)
594
+ for (const check of checks) {
595
+ if (check.status !== "verified" || !check.trusted)
596
+ failed.push({ seq: check.seq, reason: `Required trusted signature: ${check.reason}` });
597
+ }
598
+ return {
599
+ ok: ok && failed.length === 0,
600
+ total: bundle.entries.length,
601
+ contentVerified,
602
+ commitmentOnly,
603
+ failed,
604
+ roots,
605
+ signatures: {
606
+ checks,
607
+ verified: signaturesVerified,
608
+ invalid: signaturesInvalid,
609
+ notCheckable: signaturesNotCheckable,
610
+ },
611
+ notes,
612
+ };
613
+ }
@@ -0,0 +1,25 @@
1
+ export interface AuditLeaf {
2
+ seq: string;
3
+ tenantSeq?: string | null;
4
+ createdAt: string;
5
+ event: string;
6
+ outcome: string;
7
+ detail: string | null;
8
+ metadata: unknown;
9
+ signerDid: string | null;
10
+ signerPublicKey: string | null;
11
+ signedPayload: string | null;
12
+ signature: string | null;
13
+ sigAlg: string | null;
14
+ isBillable: boolean;
15
+ tenantId: string | null;
16
+ actorNodeId: string | null;
17
+ subjectNodeId: string | null;
18
+ edgeId: string | null;
19
+ challengeId: string | null;
20
+ }
21
+ /** The 18-field ordered array that gets JSON-stringified into the leaf preimage. Keep in lockstep
22
+ * with the producer's `leafHash` (packages/db/src/checkpoint.ts). */
23
+ export declare function canonicalPreimage(row: AuditLeaf): string;
24
+ /** Domain-separated leaf digest over the full event content. */
25
+ export declare function leafHash(row: AuditLeaf): string;