@mikeargento/bitgraph-audit 0.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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/dist/anchors.d.ts +31 -0
  4. package/dist/anchors.d.ts.map +1 -0
  5. package/dist/anchors.js +116 -0
  6. package/dist/anchors.js.map +1 -0
  7. package/dist/anomalies.d.ts +42 -0
  8. package/dist/anomalies.d.ts.map +1 -0
  9. package/dist/anomalies.js +642 -0
  10. package/dist/anomalies.js.map +1 -0
  11. package/dist/attestation.d.ts +20 -0
  12. package/dist/attestation.d.ts.map +1 -0
  13. package/dist/attestation.js +795 -0
  14. package/dist/attestation.js.map +1 -0
  15. package/dist/audit.d.ts +19 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +116 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/authority.d.ts +37 -0
  20. package/dist/authority.d.ts.map +1 -0
  21. package/dist/authority.js +126 -0
  22. package/dist/authority.js.map +1 -0
  23. package/dist/aws-nitro-root-ca.d.ts +24 -0
  24. package/dist/aws-nitro-root-ca.d.ts.map +1 -0
  25. package/dist/aws-nitro-root-ca.js +44 -0
  26. package/dist/aws-nitro-root-ca.js.map +1 -0
  27. package/dist/cli.d.ts +3 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +257 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/contents-hash.d.ts +30 -0
  32. package/dist/contents-hash.d.ts.map +1 -0
  33. package/dist/contents-hash.js +73 -0
  34. package/dist/contents-hash.js.map +1 -0
  35. package/dist/export.d.ts +64 -0
  36. package/dist/export.d.ts.map +1 -0
  37. package/dist/export.js +192 -0
  38. package/dist/export.js.map +1 -0
  39. package/dist/index.d.ts +31 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +17 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ingest.d.ts +36 -0
  44. package/dist/ingest.d.ts.map +1 -0
  45. package/dist/ingest.js +828 -0
  46. package/dist/ingest.js.map +1 -0
  47. package/dist/reconstruct.d.ts +40 -0
  48. package/dist/reconstruct.d.ts.map +1 -0
  49. package/dist/reconstruct.js +400 -0
  50. package/dist/reconstruct.js.map +1 -0
  51. package/dist/report-json.d.ts +3 -0
  52. package/dist/report-json.d.ts.map +1 -0
  53. package/dist/report-json.js +290 -0
  54. package/dist/report-json.js.map +1 -0
  55. package/dist/report-md.d.ts +3 -0
  56. package/dist/report-md.d.ts.map +1 -0
  57. package/dist/report-md.js +853 -0
  58. package/dist/report-md.js.map +1 -0
  59. package/dist/rlp.d.ts +37 -0
  60. package/dist/rlp.d.ts.map +1 -0
  61. package/dist/rlp.js +120 -0
  62. package/dist/rlp.js.map +1 -0
  63. package/dist/tar.d.ts +47 -0
  64. package/dist/tar.d.ts.map +1 -0
  65. package/dist/tar.js +483 -0
  66. package/dist/tar.js.map +1 -0
  67. package/dist/temporal.d.ts +55 -0
  68. package/dist/temporal.d.ts.map +1 -0
  69. package/dist/temporal.js +461 -0
  70. package/dist/temporal.js.map +1 -0
  71. package/dist/types.d.ts +1389 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +3 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/validity.d.ts +21 -0
  76. package/dist/validity.d.ts.map +1 -0
  77. package/dist/validity.js +86 -0
  78. package/dist/validity.js.map +1 -0
  79. package/dist/verify-tiers.d.ts +11 -0
  80. package/dist/verify-tiers.d.ts.map +1 -0
  81. package/dist/verify-tiers.js +120 -0
  82. package/dist/verify-tiers.js.map +1 -0
  83. package/dist/witness.d.ts +25 -0
  84. package/dist/witness.d.ts.map +1 -0
  85. package/dist/witness.js +308 -0
  86. package/dist/witness.js.map +1 -0
  87. package/package.json +42 -0
  88. package/src/anchors.ts +168 -0
  89. package/src/anomalies.ts +773 -0
  90. package/src/attestation.ts +878 -0
  91. package/src/audit.ts +136 -0
  92. package/src/authority.ts +165 -0
  93. package/src/aws-nitro-root-ca.ts +47 -0
  94. package/src/cli.ts +284 -0
  95. package/src/contents-hash.ts +95 -0
  96. package/src/export.ts +278 -0
  97. package/src/index.ts +122 -0
  98. package/src/ingest.ts +967 -0
  99. package/src/reconstruct.ts +491 -0
  100. package/src/report-json.ts +331 -0
  101. package/src/report-md.ts +1067 -0
  102. package/src/rlp.ts +142 -0
  103. package/src/tar.ts +587 -0
  104. package/src/temporal.ts +629 -0
  105. package/src/types.ts +1528 -0
  106. package/src/validity.ts +90 -0
  107. package/src/verify-tiers.ts +137 -0
  108. package/src/witness.ts +407 -0
@@ -0,0 +1,773 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit anomaly classification
5
+ *
6
+ * Classifies the reconstructed record with stable machine-readable codes
7
+ * and builds divergence records for every conflict between valid proofs.
8
+ *
9
+ * The G2 two-position rule governs the gap logic: every proof consumes TWO
10
+ * counter positions, its slotCounter N and its commit counter M with
11
+ * N < M (usually M = N + 1, but the enclave guarantees only the strict
12
+ * inequality, and concurrent slot allocation can interleave). A position
13
+ * within a partition's observed [min, max] range is EXPLAINED when it
14
+ * appears as any observed proof's commit counter OR any observed proof's
15
+ * slotCounter. Only unexplained positions are reported, always as
16
+ * unexplained from the supplied evidence (a proof absent from the bundle
17
+ * OR a slot that was allocated but never committed, indistinguishable
18
+ * offline), never as asserted authority failure. Anchor proofs are
19
+ * ordinary chain members; their positions count normally.
20
+ *
21
+ * Dimension discipline: verification status and chain topology stay
22
+ * separate throughout. Topology anomalies are computed over all observed
23
+ * members regardless of verification outcome (with statuses reported
24
+ * alongside), a verifier failure is never reinterpreted as a chain
25
+ * anomaly, and a chain observation never changes a verification result.
26
+ * Divergence parties, however, must be valid: conflicts are declared
27
+ * between valid proofs only (per their definitions), and cryptographically
28
+ * invalid proofs appear as observed context, never as competing branches.
29
+ *
30
+ * Run after verifyObservedProofs and reconstructChains. Isolated validity
31
+ * rechecks reset the verify package's module-level epoch link state (see
32
+ * validity.ts).
33
+ */
34
+
35
+ import type {
36
+ AnomalyReport,
37
+ ChainAnomaly,
38
+ ChainPartition,
39
+ DivergenceParty,
40
+ DivergenceRecord,
41
+ EpochLineageEdge,
42
+ IngestResult,
43
+ ObservedProof,
44
+ PartitionKey,
45
+ ReconstructionResult,
46
+ UnexplainedPositionsDetail,
47
+ } from "./types.js";
48
+ import { byCounterThenHash, isIntrinsicallyValid, parseCounter, pushMap } from "./validity.js";
49
+
50
+ /**
51
+ * Cap on the flat unexplained-position list. Ranges and the total count
52
+ * are always complete; only the flat list truncates, with the truncated
53
+ * flag set.
54
+ */
55
+ const MAX_LISTED_POSITIONS = 10_000;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Public API
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Classify anomalies and build divergence records over a reconstructed
63
+ * bundle. Read-only over its inputs. Deterministic: anomalies follow
64
+ * partition order, then a fixed per-partition analysis order (gaps,
65
+ * counter collisions, slot collisions, cross-kind position reuse,
66
+ * predecessor reuse, chain breaks, multiple genesis, slot order), then
67
+ * epoch link anomalies.
68
+ */
69
+ export async function classifyAnomalies(
70
+ ingest: IngestResult,
71
+ reconstruction: ReconstructionResult
72
+ ): Promise<AnomalyReport> {
73
+ const anomalies: ChainAnomaly[] = [];
74
+ const divergences: DivergenceRecord[] = [];
75
+
76
+ const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
77
+ const partitionOf = new Map<string, PartitionKey>();
78
+ for (const partition of reconstruction.partitions) {
79
+ for (const hash of partition.memberProofHashes) partitionOf.set(hash, partition.key);
80
+ }
81
+
82
+ for (const partition of reconstruction.partitions) {
83
+ const members = partition.memberProofHashes.map((h) => byHash.get(h) as ObservedProof);
84
+ analyzeGaps(partition, members, anomalies);
85
+ await analyzeCollisions(partition, members, "counter", anomalies, divergences);
86
+ await analyzeCollisions(partition, members, "slot", anomalies, divergences);
87
+ await analyzeCrossKindPositionReuse(partition, members, anomalies, divergences);
88
+ await analyzePredecessorReuse(partition, members, byHash, anomalies, divergences);
89
+ analyzeChainBreaks(partition, members, byHash, partitionOf, anomalies);
90
+ await analyzeMultipleGenesis(partition, members, anomalies, divergences);
91
+ analyzeSlotOrder(partition, members, anomalies);
92
+ }
93
+
94
+ await analyzeEpochLinks(reconstruction.epochRelationships.edges, byHash, anomalies, divergences);
95
+
96
+ return { anomalies, divergences };
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // G2 gap logic
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function analyzeGaps(
104
+ partition: ChainPartition,
105
+ members: ObservedProof[],
106
+ anomalies: ChainAnomaly[]
107
+ ): void {
108
+ // Explained positions: every observed commit counter and every observed
109
+ // slot counter (two positions per proof, never deduplicated against
110
+ // each other).
111
+ const explained = new Set<bigint>();
112
+ for (const m of members) {
113
+ const commit = parseCounter(m.counter);
114
+ if (commit !== undefined) explained.add(commit);
115
+ const slot = parseCounter(m.slotCounter);
116
+ if (slot !== undefined) explained.add(slot);
117
+ }
118
+ if (explained.size < 2) return; // no interior positions can exist
119
+
120
+ const sorted = [...explained].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
121
+ const ranges: Array<{ start: string; end: string }> = [];
122
+ const positions: string[] = [];
123
+ let count = 0n;
124
+ for (let i = 1; i < sorted.length; i++) {
125
+ const low = (sorted[i - 1] as bigint) + 1n;
126
+ const high = (sorted[i] as bigint) - 1n;
127
+ if (low > high) continue;
128
+ ranges.push({ start: String(low), end: String(high) });
129
+ count += high - low + 1n;
130
+ for (let p = low; p <= high && positions.length < MAX_LISTED_POSITIONS; p++) {
131
+ positions.push(String(p));
132
+ }
133
+ }
134
+ if (ranges.length === 0) return;
135
+
136
+ const detail: UnexplainedPositionsDetail = {
137
+ count: String(count),
138
+ ranges,
139
+ positions,
140
+ truncated: count > BigInt(positions.length),
141
+ };
142
+ const min = String(sorted[0] as bigint);
143
+ const max = String(sorted[sorted.length - 1] as bigint);
144
+ anomalies.push({
145
+ code: "unexplained-counter-positions",
146
+ partition: partition.key,
147
+ proofHashes: [],
148
+ message:
149
+ `${count} counter position${count === 1n ? "" : "s"} within the observed range [${min}, ${max}] ` +
150
+ `of this partition ${count === 1n ? "is" : "are"} neither a commit position nor a referenced slot position ` +
151
+ `in the supplied bundle. Such ${count === 1n ? "a position" : "positions"} may mean a proof is absent ` +
152
+ `from the bundle, or a slot that was allocated but never committed (a routine, benign occurrence); ` +
153
+ `this offline audit cannot distinguish the two. It does not, by itself, establish that the authority ` +
154
+ `failed to create or withheld any proof.`,
155
+ details: detail as unknown as Record<string, unknown>,
156
+ });
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Collisions
161
+ // ---------------------------------------------------------------------------
162
+
163
+ async function analyzeCollisions(
164
+ partition: ChainPartition,
165
+ members: ObservedProof[],
166
+ kind: "counter" | "slot",
167
+ anomalies: ChainAnomaly[],
168
+ divergences: DivergenceRecord[]
169
+ ): Promise<void> {
170
+ const groups = new Map<string, ObservedProof[]>();
171
+ for (const m of members) {
172
+ const raw = kind === "counter" ? m.counter : m.slotCounter;
173
+ if (raw === undefined) continue;
174
+ const parsed = parseCounter(raw);
175
+ pushMap(groups, parsed !== undefined ? String(parsed) : `raw:${raw}`, m);
176
+ }
177
+
178
+ for (const [groupKey, group] of sortGroupKeys(groups)) {
179
+ if (group.length < 2) continue;
180
+ const { parties, context } = await splitByValidity(group);
181
+ // Collisions are defined between valid non-identical proofs. Invalid
182
+ // proofs sharing a position stay on the verification dimension.
183
+ if (parties.length < 2) continue;
184
+
185
+ const position = groupKey.startsWith("raw:") ? groupKey.slice(4) : groupKey;
186
+ const code = kind === "counter" ? "counter-collision" : "slot-collision";
187
+ anomalies.push({
188
+ code,
189
+ partition: partition.key,
190
+ proofHashes: group.map((m) => m.proofHash),
191
+ message:
192
+ kind === "counter"
193
+ ? `${parties.length} distinct valid proofs claim commit counter ${position} in one partition. ` +
194
+ `The audit does not choose between them; all parties are preserved for adjudication.`
195
+ : `${parties.length} distinct valid proofs reference slot counter ${position} in one partition. ` +
196
+ `A slot position is consumed exactly once; all parties are preserved for adjudication.`,
197
+ details: {
198
+ position,
199
+ validParties: parties.map((m) => m.proofHash),
200
+ invalidObserved: context.map((m) => m.proofHash),
201
+ },
202
+ });
203
+ divergences.push({
204
+ kind: code,
205
+ partition: partition.key,
206
+ contested: kind === "counter" ? { counter: position } : { slotCounter: position },
207
+ parties: parties.map(toParty),
208
+ invalidContext: context.map(toParty),
209
+ explanation:
210
+ kind === "counter"
211
+ ? `Two or more independently valid proof objects claim commit counter ${position} in the same ` +
212
+ `signer, epoch, and chain partition. A commit counter position can be consumed only once, so at ` +
213
+ `most one of these can belong to the authoritative sequence. The audit tool does not choose ` +
214
+ `between them; every party and its predecessor relationship is preserved for adjudication.`
215
+ : `Two or more independently valid proof objects reference slot counter ${position} in the same ` +
216
+ `signer, epoch, and chain partition. A slot position is allocated once and consumed by one ` +
217
+ `commit, so at most one of these can belong to the authoritative sequence. The audit tool does ` +
218
+ `not choose between them.`,
219
+ });
220
+ }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Cross-kind position reuse (one causal position double-allocated)
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * A single causal position is consumed exactly once: reserved as a slot and
229
+ * later consumed by exactly one commit. The same-kind collision checks
230
+ * above catch two commits at one counter or two slots at one counter. This
231
+ * catches the cross-kind case the collision checks miss: one proof's commit
232
+ * counter equal to a DIFFERENT proof's slotCounter in the same partition,
233
+ * a double-allocation of one position that only enclave malfunction,
234
+ * replay, or compromise can produce. A single proof whose own commit and
235
+ * slot share a position is slot-order-violation, handled separately, and is
236
+ * never flagged here. Only intrinsically valid, distinct proofs are
237
+ * declared parties, consistent with the module's other divergences.
238
+ */
239
+ async function analyzeCrossKindPositionReuse(
240
+ partition: ChainPartition,
241
+ members: ObservedProof[],
242
+ anomalies: ChainAnomaly[],
243
+ divergences: DivergenceRecord[]
244
+ ): Promise<void> {
245
+ const commitAt = new Map<string, ObservedProof[]>();
246
+ const slotAt = new Map<string, ObservedProof[]>();
247
+ for (const m of members) {
248
+ const commit = parseCounter(m.counter);
249
+ if (commit !== undefined) pushMap(commitAt, String(commit), m);
250
+ const slot = parseCounter(m.slotCounter);
251
+ if (slot !== undefined) pushMap(slotAt, String(slot), m);
252
+ }
253
+
254
+ const positions = [...commitAt.keys()]
255
+ .filter((p) => slotAt.has(p))
256
+ .sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0));
257
+
258
+ for (const position of positions) {
259
+ const committers = commitAt.get(position) as ObservedProof[];
260
+ const slotters = slotAt.get(position) as ObservedProof[];
261
+
262
+ // Divergence parties must be intrinsically valid, as everywhere in this
263
+ // module. Invalid proofs sharing the position stay on the verification
264
+ // dimension and appear only as context.
265
+ const validCommitters: ObservedProof[] = [];
266
+ for (const m of committers) if (await isIntrinsicallyValid(m)) validCommitters.push(m);
267
+ const validSlotters: ObservedProof[] = [];
268
+ for (const m of slotters) if (await isIntrinsicallyValid(m)) validSlotters.push(m);
269
+
270
+ // A double-allocation requires a valid commit-side proof and a valid
271
+ // slot-side proof that are DIFFERENT objects. One proof committing and
272
+ // reserving the same position is slot-order-violation, not this.
273
+ const distinctCrossPair = validCommitters.some((c) =>
274
+ validSlotters.some((s) => s.proofHash !== c.proofHash)
275
+ );
276
+ if (!distinctCrossPair) continue;
277
+
278
+ const partyMap = new Map<string, ObservedProof>();
279
+ for (const m of [...validCommitters, ...validSlotters]) partyMap.set(m.proofHash, m);
280
+ const parties = [...partyMap.values()];
281
+
282
+ const involvedMap = new Map<string, ObservedProof>();
283
+ for (const m of [...committers, ...slotters]) involvedMap.set(m.proofHash, m);
284
+ const context = [...involvedMap.values()].filter((m) => !partyMap.has(m.proofHash));
285
+
286
+ anomalies.push({
287
+ code: "cross-kind-position-reuse",
288
+ partition: partition.key,
289
+ proofHashes: [...involvedMap.keys()],
290
+ message:
291
+ `Counter position ${position} is committed by one proof and reserved as a slot by a ` +
292
+ `different proof in the same partition. A single causal position is consumed once, as a ` +
293
+ `slot or as a commit, never both across distinct proofs. The audit does not choose between ` +
294
+ `them; all parties are preserved for adjudication.`,
295
+ details: {
296
+ position,
297
+ commitParties: validCommitters.map((m) => m.proofHash),
298
+ slotParties: validSlotters.map((m) => m.proofHash),
299
+ validParties: parties.map((m) => m.proofHash),
300
+ invalidObserved: context.map((m) => m.proofHash),
301
+ },
302
+ });
303
+ divergences.push({
304
+ kind: "cross-kind-position-reuse",
305
+ partition: partition.key,
306
+ contested: { position },
307
+ parties: parties.map(toParty),
308
+ invalidContext: context.map(toParty),
309
+ explanation:
310
+ `Two or more independently valid proof objects allocate counter position ${position} in the ` +
311
+ `same signer, epoch, and chain partition through different roles: at least one commits at it ` +
312
+ `and at least one reserves it as a slot. Each position in a chain is consumed exactly once, so ` +
313
+ `at most one of these allocations can belong to the authoritative sequence. This pattern only ` +
314
+ `arises through enclave malfunction, replay, or compromise. The audit tool does not choose ` +
315
+ `between them; every party is preserved for adjudication.`,
316
+ });
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Predecessor reuse (forks)
322
+ // ---------------------------------------------------------------------------
323
+
324
+ async function analyzePredecessorReuse(
325
+ partition: ChainPartition,
326
+ members: ObservedProof[],
327
+ byHash: Map<string, ObservedProof>,
328
+ anomalies: ChainAnomaly[],
329
+ divergences: DivergenceRecord[]
330
+ ): Promise<void> {
331
+ const byPrev = new Map<string, ObservedProof[]>();
332
+ for (const m of members) {
333
+ if (m.prevB64 === undefined) continue;
334
+ pushMap(byPrev, m.prevB64, m);
335
+ }
336
+
337
+ for (const [prevB64, claimants] of sortGroupKeys(byPrev)) {
338
+ if (claimants.length < 2) continue;
339
+ const { parties, context } = await splitByValidity(claimants);
340
+ if (parties.length < 2) continue;
341
+
342
+ const predecessorObserved = byHash.has(prevB64);
343
+ anomalies.push({
344
+ code: "predecessor-reuse",
345
+ partition: partition.key,
346
+ proofHashes: claimants.map((m) => m.proofHash),
347
+ message:
348
+ `${parties.length} distinct valid proofs claim the same predecessor hash: a detectable fork. ` +
349
+ `All branches are preserved; the audit does not choose between them.`,
350
+ details: {
351
+ prevB64,
352
+ predecessorObserved,
353
+ validParties: parties.map((m) => m.proofHash),
354
+ invalidObserved: context.map((m) => m.proofHash),
355
+ },
356
+ });
357
+ divergences.push({
358
+ kind: "predecessor-reuse",
359
+ partition: partition.key,
360
+ contested: { prevB64 },
361
+ parties: parties.map(toParty),
362
+ invalidContext: context.map(toParty),
363
+ explanation:
364
+ `Two or more independently valid proof objects name the same predecessor proof in their prevB64 ` +
365
+ `hash link. Each proof has exactly one successor in an intact chain, so this is a fork: the ` +
366
+ `history diverges into multiple branches at that predecessor` +
367
+ `${predecessorObserved ? ", which is itself observed in the bundle" : ", which is absent from the bundle"}. ` +
368
+ `All branches are shown; the audit tool does not select one.`,
369
+ });
370
+ }
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Chain breaks
375
+ // ---------------------------------------------------------------------------
376
+
377
+ function analyzeChainBreaks(
378
+ partition: ChainPartition,
379
+ members: ObservedProof[],
380
+ byHash: Map<string, ObservedProof>,
381
+ partitionOf: Map<string, PartitionKey>,
382
+ anomalies: ChainAnomaly[]
383
+ ): void {
384
+ const memberHashes = new Set(members.map((m) => m.proofHash));
385
+ for (const m of [...members].sort(byCounterThenHash)) {
386
+ if (m.prevB64 === undefined) continue;
387
+ if (memberHashes.has(m.prevB64)) continue; // resolved in-partition
388
+
389
+ if (!isStrict32ByteBase64(m.prevB64)) {
390
+ anomalies.push({
391
+ code: "chain-break-malformed",
392
+ partition: partition.key,
393
+ proofHashes: [m.proofHash],
394
+ message:
395
+ "commit.prevB64 is not standard base64 of 32 bytes, so it can never match a canonical proof " +
396
+ "hash. The predecessor link is unusable as reconstruction evidence.",
397
+ details: { prevB64: m.prevB64 },
398
+ });
399
+ continue;
400
+ }
401
+
402
+ const elsewhere = byHash.get(m.prevB64);
403
+ if (elsewhere !== undefined) {
404
+ const otherKey = partitionOf.get(elsewhere.proofHash);
405
+ anomalies.push({
406
+ code: "chain-break-cross-partition",
407
+ partition: partition.key,
408
+ proofHashes: [m.proofHash, elsewhere.proofHash],
409
+ message:
410
+ "commit.prevB64 resolves to an observed proof outside this proof's own signer, epoch, and " +
411
+ "chain partition. prevB64 never bridges partitions (each epoch and chain keeps its own hash " +
412
+ "chain), so this link does not join the two.",
413
+ details: {
414
+ prevB64: m.prevB64,
415
+ predecessorProofHash: elsewhere.proofHash,
416
+ predecessorPartition:
417
+ otherKey !== undefined
418
+ ? {
419
+ publicKeyB64: otherKey.publicKeyB64,
420
+ ...(otherKey.epochId !== undefined ? { epochId: otherKey.epochId } : {}),
421
+ chainId: otherKey.chainId,
422
+ }
423
+ : "unchained-or-unpartitioned",
424
+ },
425
+ });
426
+ continue;
427
+ }
428
+
429
+ anomalies.push({
430
+ code: "chain-break-missing",
431
+ partition: partition.key,
432
+ proofHashes: [m.proofHash],
433
+ message:
434
+ "commit.prevB64 references a predecessor proof that is absent from the supplied bundle. The " +
435
+ "chain cannot be reconstructed across this link from the supplied evidence; this does not, by " +
436
+ "itself, establish that the predecessor never existed.",
437
+ details: { prevB64: m.prevB64 },
438
+ });
439
+ }
440
+ }
441
+
442
+ function isStrict32ByteBase64(value: string): boolean {
443
+ if (value.length === 0) return false;
444
+ const decoded = Buffer.from(value, "base64");
445
+ return decoded.length === 32 && decoded.toString("base64") === value;
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Multiple genesis
450
+ // ---------------------------------------------------------------------------
451
+
452
+ async function analyzeMultipleGenesis(
453
+ partition: ChainPartition,
454
+ members: ObservedProof[],
455
+ anomalies: ChainAnomaly[],
456
+ divergences: DivergenceRecord[]
457
+ ): Promise<void> {
458
+ // A single proof without prevB64 is the epoch genesis: normal per G1,
459
+ // never an anomaly.
460
+ const genesis = [...members.filter((m) => m.prevB64 === undefined)].sort(byCounterThenHash);
461
+ if (genesis.length < 2) return;
462
+
463
+ const { parties, context } = await splitByValidity(genesis);
464
+ anomalies.push({
465
+ code: "multiple-genesis",
466
+ partition: partition.key,
467
+ proofHashes: genesis.map((m) => m.proofHash),
468
+ message:
469
+ `${genesis.length} proofs in one partition carry no prevB64 link. An epoch has exactly one first ` +
470
+ `proof, so at most one of these can be the genesis of this partition's chain.`,
471
+ details: {
472
+ validParties: parties.map((m) => m.proofHash),
473
+ invalidObserved: context.map((m) => m.proofHash),
474
+ },
475
+ });
476
+
477
+ if (parties.length < 2) return;
478
+ divergences.push({
479
+ kind: "multiple-genesis",
480
+ partition: partition.key,
481
+ contested: {
482
+ ...(partition.key.epochId !== undefined ? { epochId: partition.key.epochId } : {}),
483
+ chainId: partition.key.chainId,
484
+ },
485
+ parties: parties.map(toParty),
486
+ invalidContext: context.map(toParty),
487
+ explanation:
488
+ "Two or more independently valid proof objects in the same signer, epoch, and chain partition " +
489
+ "carry no predecessor link, each presenting itself as the first proof of the chain. An epoch has " +
490
+ "exactly one first proof, so at most one of these can be the genesis. The audit tool does not " +
491
+ "choose between them.",
492
+ });
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Slot ordering
497
+ // ---------------------------------------------------------------------------
498
+
499
+ function analyzeSlotOrder(
500
+ partition: ChainPartition,
501
+ members: ObservedProof[],
502
+ anomalies: ChainAnomaly[]
503
+ ): void {
504
+ for (const m of [...members].sort(byCounterThenHash)) {
505
+ const slot = parseCounter(m.slotCounter);
506
+ const commit = parseCounter(m.counter);
507
+ if (slot === undefined || commit === undefined) continue;
508
+ if (slot < commit) continue;
509
+ anomalies.push({
510
+ code: "slot-order-violation",
511
+ partition: partition.key,
512
+ proofHashes: [m.proofHash],
513
+ message:
514
+ "commit.slotCounter is not strictly less than commit.counter. The slot must be allocated " +
515
+ "before the commit that consumes it, so this proof's declared positions contradict the " +
516
+ "nonce-first construction order.",
517
+ details: {
518
+ slotCounter: m.slotCounter as string,
519
+ counter: m.counter as string,
520
+ },
521
+ });
522
+ }
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // Epoch link anomalies
527
+ // ---------------------------------------------------------------------------
528
+
529
+ async function analyzeEpochLinks(
530
+ edges: EpochLineageEdge[],
531
+ byHash: Map<string, ObservedProof>,
532
+ anomalies: ChainAnomaly[],
533
+ divergences: DivergenceRecord[]
534
+ ): Promise<void> {
535
+ // Per-edge resolutions, in edge (observation) order.
536
+ for (const edge of edges) {
537
+ if (edge.resolution === "terminal-missing") {
538
+ anomalies.push({
539
+ code: "epochlink-terminal-missing",
540
+ proofHashes: [edge.viaProofHash],
541
+ message:
542
+ "commit.epochLink references a prior epoch that is observed in the bundle, but the referenced " +
543
+ "terminal proof is absent from the bundle. The lineage claim cannot be confirmed from the " +
544
+ "supplied evidence; this does not, by itself, establish that the terminal never existed.",
545
+ details: { link: { ...edge.link } },
546
+ });
547
+ } else if (edge.resolution === "dangling") {
548
+ anomalies.push({
549
+ code: "epochlink-dangling",
550
+ proofHashes: [edge.viaProofHash],
551
+ message:
552
+ "commit.epochLink references a prior epoch and terminal proof, neither of which is observed " +
553
+ "in the bundle. The lineage claim dangles into unobserved history; it cannot be confirmed from " +
554
+ "the supplied evidence.",
555
+ details: { link: { ...edge.link } },
556
+ });
557
+ } else if (edge.metadataConsistent === false) {
558
+ anomalies.push({
559
+ code: "epochlink-mismatch",
560
+ proofHashes: [
561
+ edge.viaProofHash,
562
+ ...(edge.predecessorProofHash !== undefined ? [edge.predecessorProofHash] : []),
563
+ ],
564
+ message:
565
+ "commit.epochLink's prevProofHashB64 matches an observed proof, but the link's declared " +
566
+ "epoch, signer key, or counter disagrees with that proof. The hash reference and the declared " +
567
+ "metadata cannot both be right.",
568
+ details: {
569
+ link: { ...edge.link },
570
+ inconsistencies: edge.inconsistencies ?? [],
571
+ },
572
+ });
573
+ }
574
+ }
575
+
576
+ // Forks: one predecessor tuple consumed by two or more distinct
577
+ // successor epochs (the canonical verifier's single-successor
578
+ // invariant, applied to the observed record).
579
+ const byTuple = new Map<string, EpochLineageEdge[]>();
580
+ for (const edge of edges) {
581
+ pushMap(
582
+ byTuple,
583
+ `${edge.link.prevEpochId}|${edge.link.prevCounter}|${edge.link.prevProofHashB64}`,
584
+ edge
585
+ );
586
+ }
587
+ for (const [, group] of sortGroupKeys(byTuple)) {
588
+ const successorEpochs = new Set(group.map((e) => e.link.toEpochId));
589
+ if (successorEpochs.size < 2) continue;
590
+
591
+ const viaProofs = group.map((e) => byHash.get(e.viaProofHash) as ObservedProof);
592
+ const { parties, context } = await splitByValidity(viaProofs);
593
+ const sample = (group[0] as EpochLineageEdge).link;
594
+ anomalies.push({
595
+ code: "epochlink-fork",
596
+ proofHashes: group.map((e) => e.viaProofHash),
597
+ message:
598
+ `The same predecessor terminal (epoch ${sample.prevEpochId}, counter ${sample.prevCounter}) is ` +
599
+ `consumed by genesis proofs of ${successorEpochs.size} distinct successor epochs. A predecessor ` +
600
+ `may be consumed by at most one successor epoch, so this is a detectable fork at the epoch ` +
601
+ `boundary. All branches are preserved.`,
602
+ details: {
603
+ prevEpochId: sample.prevEpochId,
604
+ prevCounter: sample.prevCounter,
605
+ prevProofHashB64: sample.prevProofHashB64,
606
+ successorEpochIds: [...successorEpochs].sort(),
607
+ validParties: parties.map((m) => m.proofHash),
608
+ invalidObserved: context.map((m) => m.proofHash),
609
+ },
610
+ });
611
+
612
+ if (parties.length < 2) continue;
613
+ divergences.push({
614
+ kind: "epochlink-fork",
615
+ contested: {
616
+ prevEpochId: sample.prevEpochId,
617
+ prevCounter: sample.prevCounter,
618
+ prevProofHashB64: sample.prevProofHashB64,
619
+ },
620
+ parties: parties.map(toParty),
621
+ invalidContext: context.map(toParty),
622
+ explanation:
623
+ "Two or more independently valid epoch-genesis proofs consume the same terminal proof of a " +
624
+ "prior epoch through their epochLink. The single-successor rule allows a terminal to be " +
625
+ "consumed by exactly one successor epoch, so at most one of these lineages can be the " +
626
+ "authoritative continuation. Note that whichever party verified second in this run carries a " +
627
+ "fork-detection failure from the canonical verifier; that ordering is an artifact of " +
628
+ "verification order, not evidence of which branch is authoritative. The audit tool does not " +
629
+ "choose between them.",
630
+ });
631
+ }
632
+
633
+ // Cycles: the claimed lineage graph must be acyclic; a cycle means the
634
+ // epochs' ordering claims contradict each other. Detected over all
635
+ // matched edges regardless of validity, because the contradiction is a
636
+ // property of the observed claims.
637
+ const adjacency = new Map<string, Set<string>>();
638
+ for (const edge of edges) {
639
+ if (edge.resolution !== "matched") continue;
640
+ const set = adjacency.get(edge.fromEpochId);
641
+ if (set === undefined) adjacency.set(edge.fromEpochId, new Set([edge.toEpochId]));
642
+ else set.add(edge.toEpochId);
643
+ }
644
+ for (const cycle of findCycles(adjacency)) {
645
+ const involved = edges
646
+ .filter(
647
+ (e) =>
648
+ e.resolution === "matched" &&
649
+ cycle.includes(e.fromEpochId) &&
650
+ cycle.includes(e.toEpochId)
651
+ )
652
+ .map((e) => e.viaProofHash);
653
+ anomalies.push({
654
+ code: "epochlink-cycle",
655
+ proofHashes: involved,
656
+ message:
657
+ `Epoch lineage links form a cycle among ${cycle.length} epochs. Each link claims its successor ` +
658
+ `epoch came after its predecessor, so a cycle means these ordering claims contradict each ` +
659
+ `other; no ordering is asserted for the epochs involved.`,
660
+ details: { epochIds: cycle },
661
+ });
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Find simple cycles in the epoch lineage graph via iterative DFS with
667
+ * an explicit path stack. Returns each cycle once, rotated so the
668
+ * lexicographically smallest epochId leads, sorted deterministically.
669
+ */
670
+ function findCycles(adjacency: Map<string, Set<string>>): string[][] {
671
+ const cycles = new Map<string, string[]>();
672
+ const visited = new Set<string>();
673
+
674
+ const nodes = [...adjacency.keys()].sort();
675
+ for (const start of nodes) {
676
+ if (visited.has(start)) continue;
677
+ // Iterative DFS tracking the current path.
678
+ const path: string[] = [];
679
+ const onPath = new Set<string>();
680
+ const iterators = new Map<string, string[]>();
681
+ const stack: string[] = [start];
682
+ while (stack.length > 0) {
683
+ const node = stack[stack.length - 1] as string;
684
+ if (!onPath.has(node)) {
685
+ onPath.add(node);
686
+ path.push(node);
687
+ iterators.set(node, [...(adjacency.get(node) ?? [])].sort());
688
+ }
689
+ const pending = iterators.get(node) as string[];
690
+ const next = pending.shift();
691
+ if (next === undefined) {
692
+ stack.pop();
693
+ onPath.delete(node);
694
+ path.pop();
695
+ visited.add(node);
696
+ continue;
697
+ }
698
+ if (onPath.has(next)) {
699
+ // Back edge: extract the cycle from the path.
700
+ const fromIndex = path.indexOf(next);
701
+ const cycle = path.slice(fromIndex);
702
+ const canonical = canonicalizeCycle(cycle);
703
+ cycles.set(canonical.join("|"), canonical);
704
+ continue;
705
+ }
706
+ if (!visited.has(next)) stack.push(next);
707
+ }
708
+ }
709
+
710
+ return [...cycles.values()].sort((a, b) => {
711
+ const ja = a.join("|");
712
+ const jb = b.join("|");
713
+ return ja < jb ? -1 : ja > jb ? 1 : 0;
714
+ });
715
+ }
716
+
717
+ function canonicalizeCycle(cycle: string[]): string[] {
718
+ let minIndex = 0;
719
+ for (let i = 1; i < cycle.length; i++) {
720
+ if ((cycle[i] as string) < (cycle[minIndex] as string)) minIndex = i;
721
+ }
722
+ return [...cycle.slice(minIndex), ...cycle.slice(0, minIndex)];
723
+ }
724
+
725
+ // ---------------------------------------------------------------------------
726
+ // Shared helpers
727
+ // ---------------------------------------------------------------------------
728
+
729
+ async function splitByValidity(
730
+ group: ObservedProof[]
731
+ ): Promise<{ parties: ObservedProof[]; context: ObservedProof[] }> {
732
+ const parties: ObservedProof[] = [];
733
+ const context: ObservedProof[] = [];
734
+ for (const m of group) {
735
+ if (await isIntrinsicallyValid(m)) parties.push(m);
736
+ else context.push(m);
737
+ }
738
+ return { parties, context };
739
+ }
740
+
741
+ function toParty(m: ObservedProof): DivergenceParty {
742
+ const verification = m.verification;
743
+ return {
744
+ proofHash: m.proofHash,
745
+ sourcePaths: m.sources.map((s) => s.path),
746
+ ...(verification !== undefined
747
+ ? {
748
+ verificationTier: verification.tier,
749
+ verificationStatus: verification.status,
750
+ ...(verification.reason !== undefined ? { verificationReason: verification.reason } : {}),
751
+ }
752
+ : {}),
753
+ ...(m.counter !== undefined ? { counter: m.counter } : {}),
754
+ ...(m.slotCounter !== undefined ? { slotCounter: m.slotCounter } : {}),
755
+ ...(m.prevB64 !== undefined ? { prevB64: m.prevB64 } : {}),
756
+ ...(m.publicKeyB64 !== undefined ? { publicKeyB64: m.publicKeyB64 } : {}),
757
+ ...(m.epochId !== undefined ? { epochId: m.epochId } : {}),
758
+ chainId: m.chainId,
759
+ ...(m.measurement !== undefined ? { measurement: m.measurement } : {}),
760
+ };
761
+ }
762
+
763
+ /** Deterministic group iteration: numeric keys ascending, then raw keys. */
764
+ function sortGroupKeys<V>(groups: Map<string, V>): Array<[string, V]> {
765
+ return [...groups.entries()].sort(([a], [b]) => {
766
+ const na = /^[0-9]+$/.test(a) ? BigInt(a) : undefined;
767
+ const nb = /^[0-9]+$/.test(b) ? BigInt(b) : undefined;
768
+ if (na !== undefined && nb !== undefined && na !== nb) return na < nb ? -1 : 1;
769
+ if (na !== undefined && nb === undefined) return -1;
770
+ if (na === undefined && nb !== undefined) return 1;
771
+ return a < b ? -1 : a > b ? 1 : 0;
772
+ });
773
+ }