@mikeargento/bitgraph 1.1.0 → 1.2.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 (195) hide show
  1. package/README.md +49 -40
  2. package/dist/__tests__/audit-anchors.test.d.ts +2 -0
  3. package/dist/__tests__/audit-anchors.test.d.ts.map +1 -0
  4. package/dist/__tests__/audit-anchors.test.js +152 -0
  5. package/dist/__tests__/audit-anchors.test.js.map +1 -0
  6. package/dist/__tests__/audit-anomalies.test.d.ts +2 -0
  7. package/dist/__tests__/audit-anomalies.test.d.ts.map +1 -0
  8. package/dist/__tests__/audit-anomalies.test.js +663 -0
  9. package/dist/__tests__/audit-anomalies.test.js.map +1 -0
  10. package/dist/__tests__/audit-attestation.test.d.ts +2 -0
  11. package/dist/__tests__/audit-attestation.test.d.ts.map +1 -0
  12. package/dist/__tests__/audit-attestation.test.js +403 -0
  13. package/dist/__tests__/audit-attestation.test.js.map +1 -0
  14. package/dist/__tests__/audit-bench-smoke.test.d.ts +2 -0
  15. package/dist/__tests__/audit-bench-smoke.test.d.ts.map +1 -0
  16. package/dist/__tests__/audit-bench-smoke.test.js +87 -0
  17. package/dist/__tests__/audit-bench-smoke.test.js.map +1 -0
  18. package/dist/__tests__/audit-boundary.test.d.ts +20 -0
  19. package/dist/__tests__/audit-boundary.test.d.ts.map +1 -0
  20. package/dist/__tests__/audit-boundary.test.js +84 -0
  21. package/dist/__tests__/audit-boundary.test.js.map +1 -0
  22. package/dist/__tests__/audit-cli.test.d.ts +2 -0
  23. package/dist/__tests__/audit-cli.test.d.ts.map +1 -0
  24. package/dist/__tests__/audit-cli.test.js +239 -0
  25. package/dist/__tests__/audit-cli.test.js.map +1 -0
  26. package/dist/__tests__/audit-dependencies.test.d.ts +2 -0
  27. package/dist/__tests__/audit-dependencies.test.d.ts.map +1 -0
  28. package/dist/__tests__/audit-dependencies.test.js +116 -0
  29. package/dist/__tests__/audit-dependencies.test.js.map +1 -0
  30. package/dist/__tests__/audit-fixtures.d.ts +265 -0
  31. package/dist/__tests__/audit-fixtures.d.ts.map +1 -0
  32. package/dist/__tests__/audit-fixtures.js +511 -0
  33. package/dist/__tests__/audit-fixtures.js.map +1 -0
  34. package/dist/__tests__/audit-ingest.test.d.ts +2 -0
  35. package/dist/__tests__/audit-ingest.test.d.ts.map +1 -0
  36. package/dist/__tests__/audit-ingest.test.js +473 -0
  37. package/dist/__tests__/audit-ingest.test.js.map +1 -0
  38. package/dist/__tests__/audit-memory-ingest.test.d.ts +12 -0
  39. package/dist/__tests__/audit-memory-ingest.test.d.ts.map +1 -0
  40. package/dist/__tests__/audit-memory-ingest.test.js +120 -0
  41. package/dist/__tests__/audit-memory-ingest.test.js.map +1 -0
  42. package/dist/__tests__/audit-property.test.d.ts +2 -0
  43. package/dist/__tests__/audit-property.test.d.ts.map +1 -0
  44. package/dist/__tests__/audit-property.test.js +771 -0
  45. package/dist/__tests__/audit-property.test.js.map +1 -0
  46. package/dist/__tests__/audit-real-fixtures.test.d.ts +2 -0
  47. package/dist/__tests__/audit-real-fixtures.test.d.ts.map +1 -0
  48. package/dist/__tests__/audit-real-fixtures.test.js +132 -0
  49. package/dist/__tests__/audit-real-fixtures.test.js.map +1 -0
  50. package/dist/__tests__/audit-real-live.test.d.ts +2 -0
  51. package/dist/__tests__/audit-real-live.test.d.ts.map +1 -0
  52. package/dist/__tests__/audit-real-live.test.js +107 -0
  53. package/dist/__tests__/audit-real-live.test.js.map +1 -0
  54. package/dist/__tests__/audit-reconstruct.test.d.ts +2 -0
  55. package/dist/__tests__/audit-reconstruct.test.d.ts.map +1 -0
  56. package/dist/__tests__/audit-reconstruct.test.js +287 -0
  57. package/dist/__tests__/audit-reconstruct.test.js.map +1 -0
  58. package/dist/__tests__/audit-report.test.d.ts +2 -0
  59. package/dist/__tests__/audit-report.test.d.ts.map +1 -0
  60. package/dist/__tests__/audit-report.test.js +186 -0
  61. package/dist/__tests__/audit-report.test.js.map +1 -0
  62. package/dist/__tests__/audit-roundtrip.test.d.ts +2 -0
  63. package/dist/__tests__/audit-roundtrip.test.d.ts.map +1 -0
  64. package/dist/__tests__/audit-roundtrip.test.js +368 -0
  65. package/dist/__tests__/audit-roundtrip.test.js.map +1 -0
  66. package/dist/__tests__/audit-temporal.test.d.ts +2 -0
  67. package/dist/__tests__/audit-temporal.test.d.ts.map +1 -0
  68. package/dist/__tests__/audit-temporal.test.js +364 -0
  69. package/dist/__tests__/audit-temporal.test.js.map +1 -0
  70. package/dist/__tests__/audit-tiers.test.d.ts +2 -0
  71. package/dist/__tests__/audit-tiers.test.d.ts.map +1 -0
  72. package/dist/__tests__/audit-tiers.test.js +282 -0
  73. package/dist/__tests__/audit-tiers.test.js.map +1 -0
  74. package/dist/__tests__/audit-witness-real.test.d.ts +19 -0
  75. package/dist/__tests__/audit-witness-real.test.d.ts.map +1 -0
  76. package/dist/__tests__/audit-witness-real.test.js +82 -0
  77. package/dist/__tests__/audit-witness-real.test.js.map +1 -0
  78. package/dist/__tests__/audit-witness.test.d.ts +2 -0
  79. package/dist/__tests__/audit-witness.test.d.ts.map +1 -0
  80. package/dist/__tests__/audit-witness.test.js +308 -0
  81. package/dist/__tests__/audit-witness.test.js.map +1 -0
  82. package/dist/__tests__/chain-hash-anchor.test.d.ts +22 -0
  83. package/dist/__tests__/chain-hash-anchor.test.d.ts.map +1 -0
  84. package/dist/__tests__/chain-hash-anchor.test.js +55 -0
  85. package/dist/__tests__/chain-hash-anchor.test.js.map +1 -0
  86. package/dist/__tests__/declared-proof-fixture.d.ts +66 -0
  87. package/dist/__tests__/declared-proof-fixture.d.ts.map +1 -0
  88. package/dist/__tests__/declared-proof-fixture.js +66 -0
  89. package/dist/__tests__/declared-proof-fixture.js.map +1 -0
  90. package/dist/__tests__/fuse-roundtrip.test.d.ts +2 -0
  91. package/dist/__tests__/fuse-roundtrip.test.d.ts.map +1 -0
  92. package/dist/__tests__/fuse-roundtrip.test.js +172 -0
  93. package/dist/__tests__/fuse-roundtrip.test.js.map +1 -0
  94. package/dist/__tests__/fuse-sdk.test.d.ts +2 -0
  95. package/dist/__tests__/fuse-sdk.test.d.ts.map +1 -0
  96. package/dist/__tests__/fuse-sdk.test.js +117 -0
  97. package/dist/__tests__/fuse-sdk.test.js.map +1 -0
  98. package/dist/__tests__/fuse-vectors.test.d.ts +2 -0
  99. package/dist/__tests__/fuse-vectors.test.d.ts.map +1 -0
  100. package/dist/__tests__/fuse-vectors.test.js +175 -0
  101. package/dist/__tests__/fuse-vectors.test.js.map +1 -0
  102. package/dist/__tests__/fuse-verify.test.d.ts +2 -0
  103. package/dist/__tests__/fuse-verify.test.d.ts.map +1 -0
  104. package/dist/__tests__/fuse-verify.test.js +250 -0
  105. package/dist/__tests__/fuse-verify.test.js.map +1 -0
  106. package/dist/__tests__/mock-proof-fixture.d.ts +15 -0
  107. package/dist/__tests__/mock-proof-fixture.d.ts.map +1 -0
  108. package/dist/__tests__/mock-proof-fixture.js +34 -0
  109. package/dist/__tests__/mock-proof-fixture.js.map +1 -0
  110. package/dist/__tests__/proof-hash-regression.test.js +4 -43
  111. package/dist/__tests__/proof-hash-regression.test.js.map +1 -1
  112. package/dist/__tests__/proof-hash.test.js +3 -21
  113. package/dist/__tests__/proof-hash.test.js.map +1 -1
  114. package/dist/__tests__/proof-integrity.test.d.ts +2 -0
  115. package/dist/__tests__/proof-integrity.test.d.ts.map +1 -0
  116. package/dist/__tests__/proof-integrity.test.js +426 -0
  117. package/dist/__tests__/proof-integrity.test.js.map +1 -0
  118. package/dist/__tests__/realistic-proof-fixture.d.ts +56 -0
  119. package/dist/__tests__/realistic-proof-fixture.d.ts.map +1 -0
  120. package/dist/__tests__/realistic-proof-fixture.js +57 -0
  121. package/dist/__tests__/realistic-proof-fixture.js.map +1 -0
  122. package/dist/__tests__/verifier.test.js +74 -0
  123. package/dist/__tests__/verifier.test.js.map +1 -1
  124. package/dist/fuse-cli.d.ts +3 -0
  125. package/dist/fuse-cli.d.ts.map +1 -0
  126. package/dist/fuse-cli.js +210 -0
  127. package/dist/fuse-cli.js.map +1 -0
  128. package/dist/fuse.d.ts +75 -0
  129. package/dist/fuse.d.ts.map +1 -0
  130. package/dist/fuse.js +273 -0
  131. package/dist/fuse.js.map +1 -0
  132. package/dist/index.d.ts +2 -0
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +4 -0
  135. package/dist/index.js.map +1 -1
  136. package/package.json +19 -9
  137. package/src/__tests__/audit-anchors.test.ts +188 -0
  138. package/src/__tests__/audit-anomalies.test.ts +815 -0
  139. package/src/__tests__/audit-attestation.test.ts +529 -0
  140. package/src/__tests__/audit-bench-smoke.test.ts +108 -0
  141. package/src/__tests__/audit-boundary.test.ts +96 -0
  142. package/src/__tests__/audit-cli.test.ts +288 -0
  143. package/src/__tests__/audit-dependencies.test.ts +141 -0
  144. package/src/__tests__/audit-fixtures.ts +736 -0
  145. package/src/__tests__/audit-ingest.test.ts +575 -0
  146. package/src/__tests__/audit-memory-ingest.test.ts +140 -0
  147. package/src/__tests__/audit-property.test.ts +892 -0
  148. package/src/__tests__/audit-real-fixtures.test.ts +153 -0
  149. package/src/__tests__/audit-real-live.test.ts +128 -0
  150. package/src/__tests__/audit-reconstruct.test.ts +353 -0
  151. package/src/__tests__/audit-report.test.ts +222 -0
  152. package/src/__tests__/audit-roundtrip.test.ts +464 -0
  153. package/src/__tests__/audit-temporal.test.ts +446 -0
  154. package/src/__tests__/audit-tiers.test.ts +343 -0
  155. package/src/__tests__/audit-witness-real.test.ts +92 -0
  156. package/src/__tests__/audit-witness.test.ts +345 -0
  157. package/src/__tests__/chain-hash-anchor.test.ts +67 -0
  158. package/src/__tests__/declared-proof-fixture.ts +65 -0
  159. package/src/__tests__/fuse-roundtrip.test.ts +174 -0
  160. package/src/__tests__/fuse-sdk.test.ts +142 -0
  161. package/src/__tests__/fuse-vectors.test.ts +218 -0
  162. package/src/__tests__/fuse-verify.test.ts +278 -0
  163. package/src/__tests__/mock-proof-fixture.ts +36 -0
  164. package/src/__tests__/proof-hash-regression.test.ts +4 -44
  165. package/src/__tests__/proof-hash.test.ts +3 -22
  166. package/src/__tests__/proof-integrity.test.ts +541 -0
  167. package/src/__tests__/real-fixtures/anchor-after-112762.json +47 -0
  168. package/src/__tests__/real-fixtures/anchor-with-ethereum-112758.json +47 -0
  169. package/src/__tests__/real-fixtures/live-proof-0.json +42 -0
  170. package/src/__tests__/real-fixtures/live-proof-1.json +43 -0
  171. package/src/__tests__/real-fixtures/live-proof-2.json +43 -0
  172. package/src/__tests__/real-fixtures/successor-112760.json +38 -0
  173. package/src/__tests__/real-fixtures/witness-25525146.json +6 -0
  174. package/src/__tests__/real-fixtures/witness-25525147.json +6 -0
  175. package/src/__tests__/realistic-proof-fixture.ts +57 -0
  176. package/src/__tests__/verifier.test.ts +83 -0
  177. package/src/fuse-cli.ts +187 -0
  178. package/src/fuse.ts +356 -0
  179. package/src/index.ts +6 -0
  180. package/dist/canonical.d.ts +0 -54
  181. package/dist/canonical.d.ts.map +0 -1
  182. package/dist/canonical.js +0 -119
  183. package/dist/canonical.js.map +0 -1
  184. package/dist/proof-hash.d.ts +0 -9
  185. package/dist/proof-hash.d.ts.map +0 -1
  186. package/dist/proof-hash.js +0 -75
  187. package/dist/proof-hash.js.map +0 -1
  188. package/dist/types.d.ts +0 -705
  189. package/dist/types.d.ts.map +0 -1
  190. package/dist/types.js +0 -3
  191. package/dist/types.js.map +0 -1
  192. package/dist/verifier.d.ts +0 -27
  193. package/dist/verifier.d.ts.map +0 -1
  194. package/dist/verifier.js +0 -844
  195. package/dist/verifier.js.map +0 -1
@@ -0,0 +1,345 @@
1
+ // Copyright (c) Mike Argento. All rights reserved. See LICENSE.
2
+
3
+ /**
4
+ * Anchor witness verification tests (bundle spec section 10.3).
5
+ *
6
+ * The synthetic header is a 20-item RLP list with the block number at
7
+ * index 8 and the timestamp at index 11; its Keccak-256 hash is the
8
+ * "block hash", the anchor's signed attribution.message is that hash,
9
+ * and the anchor's artifact digest is SHA-256 over the hash STRING,
10
+ * exactly like bitcoin-anchor.ts. Full verification yields the header
11
+ * timestamp; every single-point corruption fails with its own stable
12
+ * reason and confers nothing.
13
+ */
14
+
15
+ import { describe, it } from "node:test";
16
+ import * as assert from "node:assert/strict";
17
+ import { keccak_256 } from "@noble/hashes/sha3";
18
+ import { sha256 } from "@noble/hashes/sha256";
19
+ import {
20
+ identifyAnchors,
21
+ ingestBundle,
22
+ verifyAnchorWitness,
23
+ verifyAnchorWitnesses,
24
+ verifyObservedProofs,
25
+ } from "@mikeargento/bitgraph-audit";
26
+ import type { AnchorIdentification, IngestResult } from "@mikeargento/bitgraph-audit";
27
+ import {
28
+ b64,
29
+ makeAnchorProof,
30
+ makeEthereumHeader,
31
+ makeTempDir,
32
+ proofJson,
33
+ utf8,
34
+ witnessJson,
35
+ writeBundleDir,
36
+ } from "./audit-fixtures.js";
37
+
38
+ const BLOCK_NUMBER = 24800448;
39
+ const TIMESTAMP = 1750000000;
40
+
41
+ const HEADER = makeEthereumHeader({ blockNumber: BLOCK_NUMBER, timestamp: TIMESTAMP });
42
+ const BLOCK_HASH = `0x${Buffer.from(keccak_256(HEADER.headerBytes)).toString("hex")}`;
43
+
44
+ async function pipeline(
45
+ files: Record<string, string | Uint8Array>
46
+ ): Promise<{ ingest: IngestResult; anchors: AnchorIdentification }> {
47
+ const dir = await makeTempDir("bg-audit-witness-");
48
+ await writeBundleDir(dir, files);
49
+ const ingest = await ingestBundle(dir);
50
+ await verifyObservedProofs(ingest);
51
+ return { ingest, anchors: identifyAnchors(ingest) };
52
+ }
53
+
54
+ function goodWitness(): string {
55
+ return witnessJson({
56
+ headerRlpHex: HEADER.headerRlpHex,
57
+ blockNumber: BLOCK_NUMBER,
58
+ blockHash: BLOCK_HASH,
59
+ network: "ethereum-mainnet",
60
+ });
61
+ }
62
+
63
+ describe("audit witness: full verification", () => {
64
+ it("passes every step and yields the header timestamp as the only wall-clock evidence", async () => {
65
+ const anchor = await makeAnchorProof({
66
+ blockHash: BLOCK_HASH,
67
+ blockNumber: String(BLOCK_NUMBER),
68
+ epochId: "E-W1",
69
+ counter: "4",
70
+ slotCounter: "3",
71
+ });
72
+ const { ingest, anchors } = await pipeline({
73
+ "proofs/anchor.json": proofJson(anchor.proof),
74
+ "witnesses/w.json": goodWitness(),
75
+ });
76
+
77
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
78
+ assert.equal(analysis.outcomes.length, 1);
79
+ const outcome = analysis.outcomes[0]!;
80
+ assert.equal(outcome.verified, true);
81
+ assert.equal(outcome.anchorProofHash, anchor.proofHash);
82
+ assert.equal(outcome.computedBlockHash, BLOCK_HASH);
83
+ assert.equal(outcome.blockNumber, String(BLOCK_NUMBER));
84
+ assert.equal(outcome.timestamp, TIMESTAMP);
85
+ assert.equal(outcome.reason, undefined);
86
+ assert.equal(analysis.findings.length, 0);
87
+ });
88
+
89
+ it("compares the hash case-insensitively but binds the digest to the exact signed string", async () => {
90
+ // The anchor signed an UPPERCASE hash string; the digest covers that
91
+ // exact string. Step 3 lowercases for comparison, step 4 does not.
92
+ const upper = `0x${BLOCK_HASH.slice(2).toUpperCase()}`;
93
+ const anchor = await makeAnchorProof({
94
+ blockHash: upper,
95
+ blockNumber: String(BLOCK_NUMBER),
96
+ epochId: "E-W2",
97
+ counter: "2",
98
+ });
99
+ const { ingest, anchors } = await pipeline({
100
+ "proofs/anchor.json": proofJson(anchor.proof),
101
+ "witnesses/w.json": goodWitness(),
102
+ });
103
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
104
+ assert.equal(analysis.outcomes.length, 1);
105
+ assert.equal(analysis.outcomes[0]!.verified, true);
106
+ assert.equal(analysis.outcomes[0]!.timestamp, TIMESTAMP);
107
+ });
108
+ });
109
+
110
+ describe("audit witness: single-point corruptions fail with distinct stable reasons", () => {
111
+ it("wrong claimed blockHash: witness-claimed-hash-mismatch", async () => {
112
+ const anchor = await makeAnchorProof({
113
+ blockHash: BLOCK_HASH,
114
+ blockNumber: String(BLOCK_NUMBER),
115
+ epochId: "E-W3",
116
+ counter: "2",
117
+ });
118
+ const wrongClaim = `0x${"ab".repeat(32)}`;
119
+ const { ingest, anchors } = await pipeline({
120
+ "proofs/anchor.json": proofJson(anchor.proof),
121
+ "witnesses/w.json": witnessJson({
122
+ headerRlpHex: HEADER.headerRlpHex,
123
+ blockNumber: BLOCK_NUMBER,
124
+ blockHash: wrongClaim,
125
+ }),
126
+ });
127
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
128
+ const outcome = analysis.outcomes[0]!;
129
+ assert.equal(outcome.verified, false);
130
+ assert.equal(outcome.reason, "witness-claimed-hash-mismatch");
131
+ assert.equal(outcome.timestamp, undefined, "a rejected witness confers nothing");
132
+ assert.ok(analysis.findings.some((f) => f.code === "witness-claimed-hash-mismatch"));
133
+ });
134
+
135
+ it("wrong artifact digest: witness-digest-mismatch", async () => {
136
+ // Validly signed anchor whose digest covers a different string, so the
137
+ // proof itself verifies but the message-to-digest binding fails.
138
+ const anchor = await makeAnchorProof({
139
+ blockHash: BLOCK_HASH,
140
+ blockNumber: String(BLOCK_NUMBER),
141
+ epochId: "E-W4",
142
+ counter: "2",
143
+ digestB64: b64(sha256(utf8("some other committed artifact"))),
144
+ });
145
+ const { ingest, anchors } = await pipeline({
146
+ "proofs/anchor.json": proofJson(anchor.proof),
147
+ "witnesses/w.json": goodWitness(),
148
+ });
149
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
150
+ const outcome = analysis.outcomes[0]!;
151
+ assert.equal(outcome.verified, false);
152
+ assert.equal(outcome.reason, "witness-digest-mismatch");
153
+ assert.equal(outcome.timestamp, undefined);
154
+ });
155
+
156
+ it("wrong claimed blockNumber: witness-block-number-mismatch", async () => {
157
+ const anchor = await makeAnchorProof({
158
+ blockHash: BLOCK_HASH,
159
+ blockNumber: String(BLOCK_NUMBER),
160
+ epochId: "E-W5",
161
+ counter: "2",
162
+ });
163
+ const { ingest, anchors } = await pipeline({
164
+ "proofs/anchor.json": proofJson(anchor.proof),
165
+ "witnesses/w.json": witnessJson({
166
+ headerRlpHex: HEADER.headerRlpHex,
167
+ blockNumber: BLOCK_NUMBER + 1,
168
+ blockHash: BLOCK_HASH,
169
+ }),
170
+ });
171
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
172
+ const outcome = analysis.outcomes[0]!;
173
+ assert.equal(outcome.verified, false);
174
+ assert.equal(outcome.reason, "witness-block-number-mismatch");
175
+ assert.match(outcome.detail as string, /witness claims blockNumber/);
176
+ });
177
+
178
+ it("signed Etherscan URL disagreeing with the header: witness-block-number-mismatch", async () => {
179
+ const anchor = await makeAnchorProof({
180
+ blockHash: BLOCK_HASH,
181
+ blockNumber: String(BLOCK_NUMBER + 7),
182
+ epochId: "E-W6",
183
+ counter: "2",
184
+ });
185
+ const { ingest, anchors } = await pipeline({
186
+ "proofs/anchor.json": proofJson(anchor.proof),
187
+ "witnesses/w.json": goodWitness(),
188
+ });
189
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
190
+ const outcome = analysis.outcomes[0]!;
191
+ assert.equal(outcome.verified, false);
192
+ assert.equal(outcome.reason, "witness-block-number-mismatch");
193
+ assert.match(outcome.detail as string, /Etherscan URL/);
194
+ });
195
+
196
+ it("tampered RLP header bytes: witness-hash-mismatch", async () => {
197
+ const anchor = await makeAnchorProof({
198
+ blockHash: BLOCK_HASH,
199
+ blockNumber: String(BLOCK_NUMBER),
200
+ epochId: "E-W7",
201
+ counter: "2",
202
+ });
203
+ // Flip content bytes in the middle: still well-formed RLP, different
204
+ // Keccak hash. The witness still reaches its intended anchor through
205
+ // the claimed hash and fails loudly at step 3.
206
+ const tampered =
207
+ HEADER.headerRlpHex.slice(0, 40) +
208
+ (HEADER.headerRlpHex[40] === "f" ? "e" : "f") +
209
+ HEADER.headerRlpHex.slice(41);
210
+ const { ingest, anchors } = await pipeline({
211
+ "proofs/anchor.json": proofJson(anchor.proof),
212
+ "witnesses/w.json": witnessJson({
213
+ headerRlpHex: tampered,
214
+ blockNumber: BLOCK_NUMBER,
215
+ blockHash: BLOCK_HASH,
216
+ }),
217
+ });
218
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
219
+ const outcome = analysis.outcomes[0]!;
220
+ assert.equal(outcome.verified, false);
221
+ assert.equal(outcome.reason, "witness-hash-mismatch");
222
+ assert.equal(outcome.timestamp, undefined);
223
+ });
224
+ });
225
+
226
+ describe("audit witness: malformed and unusable witnesses", () => {
227
+ it("field violations fail with witness-malformed", async () => {
228
+ const anchor = await makeAnchorProof({
229
+ blockHash: BLOCK_HASH,
230
+ blockNumber: String(BLOCK_NUMBER),
231
+ epochId: "E-W8",
232
+ counter: "2",
233
+ });
234
+ const { ingest, anchors } = await pipeline({
235
+ "proofs/anchor.json": proofJson(anchor.proof),
236
+ "witnesses/no-header.json": witnessJson({
237
+ blockNumber: BLOCK_NUMBER,
238
+ blockHash: BLOCK_HASH,
239
+ omit: ["headerRlpHex"],
240
+ }),
241
+ "witnesses/string-number.json": witnessJson({
242
+ headerRlpHex: HEADER.headerRlpHex,
243
+ blockNumber: String(BLOCK_NUMBER),
244
+ blockHash: BLOCK_HASH,
245
+ }),
246
+ "witnesses/bad-hash-shape.json": witnessJson({
247
+ headerRlpHex: HEADER.headerRlpHex,
248
+ blockNumber: BLOCK_NUMBER,
249
+ blockHash: "28ed3639",
250
+ }),
251
+ });
252
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
253
+ assert.equal(analysis.outcomes.length, 3);
254
+ for (const outcome of analysis.outcomes) {
255
+ assert.equal(outcome.verified, false);
256
+ assert.equal(outcome.reason, "witness-malformed");
257
+ assert.equal(outcome.timestamp, undefined);
258
+ }
259
+ });
260
+
261
+ it("structurally invalid RLP fails with witness-rlp-invalid", async () => {
262
+ const anchor = await makeAnchorProof({
263
+ blockHash: BLOCK_HASH,
264
+ blockNumber: String(BLOCK_NUMBER),
265
+ epochId: "E-W9",
266
+ counter: "2",
267
+ });
268
+ const { ingest, anchors } = await pipeline({
269
+ "proofs/anchor.json": proofJson(anchor.proof),
270
+ // 0xf9 declares a long list with a 2-byte length that never arrives.
271
+ "witnesses/w.json": witnessJson({
272
+ headerRlpHex: "0xf901",
273
+ blockNumber: BLOCK_NUMBER,
274
+ blockHash: BLOCK_HASH,
275
+ }),
276
+ });
277
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
278
+ assert.equal(analysis.outcomes[0]!.reason, "witness-rlp-invalid");
279
+ });
280
+
281
+ it("a witness matching no anchor is reported witness-unmatched", async () => {
282
+ const otherHeader = makeEthereumHeader({ blockNumber: 5, timestamp: 6 });
283
+ const otherHash = `0x${Buffer.from(keccak_256(otherHeader.headerBytes)).toString("hex")}`;
284
+ const anchor = await makeAnchorProof({
285
+ blockHash: BLOCK_HASH,
286
+ blockNumber: String(BLOCK_NUMBER),
287
+ epochId: "E-W10",
288
+ counter: "2",
289
+ });
290
+ const { ingest, anchors } = await pipeline({
291
+ "proofs/anchor.json": proofJson(anchor.proof),
292
+ "witnesses/w.json": witnessJson({
293
+ headerRlpHex: otherHeader.headerRlpHex,
294
+ blockNumber: 5,
295
+ blockHash: otherHash,
296
+ }),
297
+ });
298
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
299
+ const outcome = analysis.outcomes[0]!;
300
+ assert.equal(outcome.verified, false);
301
+ assert.equal(outcome.reason, "witness-unmatched");
302
+ assert.equal(outcome.anchorProofHash, undefined);
303
+ });
304
+
305
+ it("a witness cannot rescue an invalid anchor: witness-anchor-invalid", async () => {
306
+ const anchor = await makeAnchorProof({
307
+ blockHash: BLOCK_HASH,
308
+ blockNumber: String(BLOCK_NUMBER),
309
+ epochId: "E-W11",
310
+ counter: "2",
311
+ });
312
+ // Tamper the Ed25519 signature: the canonical identity is unchanged
313
+ // (the signature is outside the signed body) but the proof is invalid.
314
+ anchor.proof.signer.signatureB64 = `AA${anchor.proof.signer.signatureB64.slice(2)}`;
315
+ const { ingest, anchors } = await pipeline({
316
+ "proofs/anchor.json": proofJson(anchor.proof),
317
+ "witnesses/w.json": goodWitness(),
318
+ });
319
+ const analysis = await verifyAnchorWitnesses(ingest, anchors);
320
+ const outcome = analysis.outcomes[0]!;
321
+ assert.equal(outcome.verified, false);
322
+ assert.equal(outcome.reason, "witness-anchor-invalid");
323
+ assert.equal(outcome.timestamp, undefined);
324
+ });
325
+ });
326
+
327
+ describe("audit witness: direct single-pair API", () => {
328
+ it("verifyAnchorWitness runs the full procedure against one anchor", async () => {
329
+ const anchor = await makeAnchorProof({
330
+ blockHash: BLOCK_HASH,
331
+ blockNumber: String(BLOCK_NUMBER),
332
+ epochId: "E-W12",
333
+ counter: "2",
334
+ });
335
+ const { ingest } = await pipeline({
336
+ "proofs/anchor.json": proofJson(anchor.proof),
337
+ "witnesses/w.json": goodWitness(),
338
+ });
339
+ const observed = ingest.proofs.find((p) => p.proofHash === anchor.proofHash)!;
340
+ const witnessFile = ingest.witnesses[0]!;
341
+ const outcome = await verifyAnchorWitness(witnessFile, observed);
342
+ assert.equal(outcome.verified, true);
343
+ assert.equal(outcome.timestamp, TIMESTAMP);
344
+ });
345
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Regression: computeChainHash must exclude the anchor service's `ethereum`
3
+ * field so that an Ethereum anchor can be resolved as a chain predecessor.
4
+ *
5
+ * Real-data fixture. `anchor-with-ethereum-112758.json` is a live Ethereum
6
+ * anchor (commit 112758) exactly as it ships in an export bundle: it carries a
7
+ * top-level `ethereum` { blockNumber, blockHash } field that the anchor service
8
+ * appended AFTER the enclave signed it. `successor-112760.json` is the next
9
+ * proof on the chain (the BitGraph whitepaper, commit 112760); its
10
+ * `commit.prevB64` was computed by the enclave over the anchor as the enclave
11
+ * saw it — before the `ethereum` field existed.
12
+ *
13
+ * The bug (latent since the anchor service began stamping `ethereum` on
14
+ * 2026-04-01, exposed once the audit tool resolved prevB64 via computeChainHash
15
+ * on 2026-07-10): computeChainHash hashed the whole proof minus only
16
+ * `proofHash`, so the appended `ethereum` field poisoned the hash and no
17
+ * successor's prevB64 could ever match. The anchor "vanished" from the chain
18
+ * even though it was present in the bundle. The fix excludes `ethereum` (and
19
+ * any ledger/service-added field) from the chain hash.
20
+ */
21
+
22
+ import { describe, it } from "node:test";
23
+ import * as assert from "node:assert/strict";
24
+ import { readFileSync } from "node:fs";
25
+ import { fileURLToPath } from "node:url";
26
+ import { computeChainHash, type BitGraphProof } from "@mikeargento/bitgraph-verify";
27
+
28
+ function loadFixture(name: string): Record<string, unknown> {
29
+ const url = new URL(`../../src/__tests__/real-fixtures/${name}`, import.meta.url);
30
+ return JSON.parse(readFileSync(fileURLToPath(url), "utf8"));
31
+ }
32
+
33
+ describe("computeChainHash: Ethereum anchor chain link (ethereum-field regression)", () => {
34
+ const anchor = loadFixture("anchor-with-ethereum-112758.json");
35
+ const successor = loadFixture("successor-112760.json");
36
+ const expectedPrev = (successor.commit as { prevB64: string }).prevB64;
37
+
38
+ it("the fixture is the real shape: anchor carries a top-level ethereum field", () => {
39
+ assert.ok(Object.prototype.hasOwnProperty.call(anchor, "ethereum"));
40
+ assert.equal((anchor.commit as { counter: string }).counter, "112758");
41
+ assert.equal((successor.commit as { counter: string }).counter, "112760");
42
+ });
43
+
44
+ it("resolves the successor's prevB64 to the anchor, as exported (with ethereum)", () => {
45
+ // The whole point: the anchor is exported WITH the ethereum field, and its
46
+ // chain hash must still equal the successor's prevB64.
47
+ assert.equal(computeChainHash(anchor as unknown as BitGraphProof), expectedPrev);
48
+ });
49
+
50
+ it("ignores the ethereum field entirely (with === without)", () => {
51
+ const withoutEth: Record<string, unknown> = { ...anchor };
52
+ delete withoutEth.ethereum;
53
+ assert.equal(
54
+ computeChainHash(anchor as unknown as BitGraphProof),
55
+ computeChainHash(withoutEth as unknown as BitGraphProof)
56
+ );
57
+ });
58
+
59
+ it("also ignores the ledger-added proofHash field (with === without)", () => {
60
+ const withoutHash: Record<string, unknown> = { ...anchor };
61
+ delete withoutHash.proofHash;
62
+ assert.equal(
63
+ computeChainHash(anchor as unknown as BitGraphProof),
64
+ computeChainHash(withoutHash as unknown as BitGraphProof)
65
+ );
66
+ });
67
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The first declared recording ever made (ledger #12,010, 2026-08-18), kept
3
+ * as a fixture because its absence is what let two verifier bugs through that
4
+ * day (see dc26b5fd). A real WebAuthn assertion: origin https://bitgraph.ing,
5
+ * rpIdHash = SHA-256("bitgraph.ing"), flags UP|UV|BE|BS (a synced passkey).
6
+ * The artifact bytes are not here, so it is checked with verifyProofIntegrity.
7
+ * Same bytes as packages/player/src/__tests__/fixtures/declared-12010/proof.json.
8
+ */
9
+ export const DECLARED_12010 = {
10
+ "version": "bitgraph/1",
11
+ "artifact": {
12
+ "hashAlg": "sha256",
13
+ "digestB64": "szEkIOHMuIaOXlqVG9uqKvaKtmnYmklfzOjEldzFP2E="
14
+ },
15
+ "commit": {
16
+ "nonceB64": "05pT2TUMA2mJ0iBsQPkOaKFIUa0i4Ta6wdNkqS3jef4=",
17
+ "counter": "12010",
18
+ "slotCounter": "12009",
19
+ "slotHashB64": "VbOoREc4tcmNwFFYmtfenUZ04LNkjP02wQN2okIi81Y=",
20
+ "epochId": "plJ+9/nhhVEvDE4X4rCjJI+zys0fIov3XT114+mQIpk=",
21
+ "prevB64": "3TzjHBZWqTMte6FE0BZ0SM7hrcRhFv29Sl4NMf/ZvBs=",
22
+ "chainId": "bitgraph:main"
23
+ },
24
+ "signer": {
25
+ "publicKeyB64": "tmp21VjPay+EhaMwmrFLfdjZo2KIjjT7VDqjmm27wHI=",
26
+ "signatureB64": "96dTfH4EYq1pRvS7zAhVZuiCLsfrlur0QkA4dfkRQZtbkQ8dgebEQn85TOdvvPtk2YYh9pP0k46HWrZuOOYlBg=="
27
+ },
28
+ "environment": {
29
+ "enforcement": "measured-tee",
30
+ "measurement": "6483cedffed74680ffb287507744a398b288c3fb943eb3f2e4fe889f8b60b3d575ad8942350360b69a1bd7bf713df27f",
31
+ "attestation": {
32
+ "format": "aws-nitro",
33
+ "reportB64": "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"
34
+ }
35
+ },
36
+ "slotAllocation": {
37
+ "version": "bitgraph/slot/1",
38
+ "nonceB64": "05pT2TUMA2mJ0iBsQPkOaKFIUa0i4Ta6wdNkqS3jef4=",
39
+ "counter": "12009",
40
+ "epochId": "plJ+9/nhhVEvDE4X4rCjJI+zys0fIov3XT114+mQIpk=",
41
+ "publicKeyB64": "tmp21VjPay+EhaMwmrFLfdjZo2KIjjT7VDqjmm27wHI=",
42
+ "chainId": "bitgraph:main",
43
+ "signatureB64": "LlqN1udFVXP9IFxsjwdF6//NMJZbm/IvBmlzDWJ7sfDAqCHD9fSH24w5AM4thFtf0YxMT/n5FedunYYwUQRgCA=="
44
+ },
45
+ "agency": {
46
+ "actor": {
47
+ "keyId": "ee0c65174625e065c18e732a226b649a138625ba49c3d7dde026957082035cde",
48
+ "publicKeyB64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVXEkwZppHxU13Ew9Ap+BQnxQcNXuMvH6KPepQe3t8t1Jh2/aEguB0Ttrzxw9WGxmB8a3AdVVv+ybchgrL8XIRg==",
49
+ "algorithm": "ES256",
50
+ "provider": "passkey"
51
+ },
52
+ "authorization": {
53
+ "purpose": "bitgraph/commit-authorize/v1",
54
+ "format": "webauthn",
55
+ "actorKeyId": "ee0c65174625e065c18e732a226b649a138625ba49c3d7dde026957082035cde",
56
+ "artifactHash": "szEkIOHMuIaOXlqVG9uqKvaKtmnYmklfzOjEldzFP2E=",
57
+ "challenge": "WzE2FoHLFBQhiwQCpdR8xXA0g1FfOcRCcTEG+1iOJa4=",
58
+ "timestamp": 1787086799013,
59
+ "authenticatorDataB64": "Gv0914qYXymsoynDxplniUKsvF16m3HSkeyxwvtTjnsdAAAAAA==",
60
+ "clientDataJSON": "{\"type\":\"webauthn.get\",\"challenge\":\"WzE2FoHLFBQhiwQCpdR8xXA0g1FfOcRCcTEG-1iOJa4\",\"origin\":\"https://bitgraph.ing\",\"crossOrigin\":false}",
61
+ "signatureB64": "MEUCIQDBAxBn+pAZcq9YTCVnGcoV6TX+dC1sR1R/0ufz5GSf8gIgMrlaLEPP8f4uFLfTXHT0EA+m85x1LcQNDYqf3557Ktk="
62
+ }
63
+ },
64
+ "proofHash": "gpbq9mzjkjy9xz7wZAsV+eLk1kwTeOtHogysF5rYS2w="
65
+ } as const;
@@ -0,0 +1,174 @@
1
+ // Copyright (c) Mike Argento. All rights reserved. See LICENSE.
2
+
3
+ /**
4
+ * BitGraph Fuse: reconstruction round trips (spec 15.3). A placement enters
5
+ * the registry only with this test passing: original plus proof rebuilds
6
+ * bytes whose hash equals the artifact digest, from fixed vectors.
7
+ */
8
+
9
+ import { describe, test } from "node:test";
10
+ import * as assert from "node:assert/strict";
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { sha256 } from "@noble/hashes/sha256";
14
+ import { PLACEMENTS, getPlacement, computeSlotCommitment, bytesToBase64, buildFrame, parseFrame, readFrameMarker, readFuseAttribution, fuseAttribution } from "@mikeargento/bitgraph-verify";
15
+ import type { BitGraphProof } from "@mikeargento/bitgraph-verify";
16
+
17
+ const FIX = fileURLToPath(new URL("../../src/__tests__/fuse-fixtures/", import.meta.url));
18
+ const bytes = (name: string) => new Uint8Array(readFileSync(FIX + name));
19
+ const proofOf = (name: string) => JSON.parse(readFileSync(FIX + name, "utf8")) as BitGraphProof;
20
+
21
+ describe("registry", () => {
22
+ test("fixed order and ids", () => {
23
+ assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "produced/1"]);
24
+ assert.deepEqual(PLACEMENTS.map((p) => p.byteExact), [true, true, false]);
25
+ assert.equal(getPlacement("xmp/9"), undefined);
26
+ assert.equal(getPlacement(""), undefined);
27
+ });
28
+ });
29
+
30
+ describe("trailer/1", () => {
31
+ const p = getPlacement("trailer/1")!;
32
+ test("original + proof rebuilds the committed bytes", () => {
33
+ const proof = proofOf("trailer.proof.json");
34
+ const rebuilt = p.build({ original: bytes("original.txt"), commitment: computeSlotCommitment(proof.slotAllocation!) });
35
+ assert.deepEqual(rebuilt, bytes("fused-trailer.bin"));
36
+ assert.equal(bytesToBase64(sha256(rebuilt)), proof.artifact.digestB64);
37
+ });
38
+ test("locate returns the commitment and the exact original prefix", () => {
39
+ const proof = proofOf("trailer.proof.json");
40
+ const l = p.locate(bytes("fused-trailer.bin"))!;
41
+ assert.deepEqual(l.commitment, computeSlotCommitment(proof.slotAllocation!));
42
+ assert.deepEqual(l.originalBytes, bytes("original.txt"));
43
+ assert.equal(l.originDigest, undefined, "trailer/1 writes no origin digest");
44
+ });
45
+ test("locate refuses short input, wrong magic, non-zero reserved bytes", () => {
46
+ assert.equal(p.locate(new Uint8Array(47)), null);
47
+ const f = bytes("fused-trailer.bin");
48
+ const magic = f.slice(); magic[magic.length - 48] = 0x58;
49
+ assert.equal(p.locate(magic), null);
50
+ const reserved = f.slice(); reserved[reserved.length - 40] = 1;
51
+ assert.equal(p.locate(reserved), null);
52
+ assert.equal(p.locate(bytes("original.txt")), null, "an unfused file has no trailer");
53
+ });
54
+ test("build refuses a bad commitment length and a missing original", () => {
55
+ assert.throws(() => p.build({ commitment: new Uint8Array(32) }), /original/);
56
+ assert.throws(() => p.build({ original: new Uint8Array(1), commitment: new Uint8Array(31) }), /32 bytes/);
57
+ });
58
+ });
59
+
60
+ describe("container/1", () => {
61
+ const p = getPlacement("container/1")!;
62
+ test("original + proof rebuilds the committed archive byte for byte, twice", () => {
63
+ const proof = proofOf("container.proof.json");
64
+ const c = computeSlotCommitment(proof.slotAllocation!);
65
+ const a = p.build({ original: bytes("image.png"), commitment: c });
66
+ const b = p.build({ original: bytes("image.png"), commitment: c });
67
+ assert.deepEqual(a, b, "deterministic");
68
+ assert.deepEqual(a, bytes("fused-container.tar"));
69
+ assert.equal(bytesToBase64(sha256(a)), proof.artifact.digestB64);
70
+ });
71
+ test("locate returns commitment, origin digest and the original bytes", () => {
72
+ const proof = proofOf("container.proof.json");
73
+ const l = p.locate(bytes("fused-container.tar"))!;
74
+ assert.deepEqual(l.commitment, computeSlotCommitment(proof.slotAllocation!));
75
+ assert.deepEqual(l.originDigest, sha256(bytes("image.png")));
76
+ assert.deepEqual(l.originalBytes, bytes("image.png"));
77
+ });
78
+ test("locate refuses any deviation from the fixed layout", () => {
79
+ const f = bytes("fused-container.tar");
80
+ const mtime = f.slice(); mtime[136] = 0x31; // manifest header mtime
81
+ assert.equal(p.locate(mtime), null, "non-zero mtime");
82
+ const uid = f.slice(); uid[108] = 0x31;
83
+ assert.equal(p.locate(uid), null, "uid");
84
+ const truncated = f.subarray(0, f.length - 512);
85
+ assert.equal(p.locate(truncated), null, "missing end block");
86
+ const extra = new Uint8Array(f.length + 512); extra.set(f);
87
+ assert.equal(p.locate(extra), null, "extra trailing block");
88
+ // Swap entry order: rebuild with the original first is not this placement.
89
+ const swapped = f.slice(); swapped[0] = 0x78; // corrupt the manifest name
90
+ assert.equal(p.locate(swapped), null, "wrong entry name");
91
+ assert.equal(p.locate(bytes("image.png")), null, "not a tar");
92
+ assert.equal(p.locate(bytes("fused-trailer.bin")), null);
93
+ });
94
+ test("an archive with a wrong manifest origin does not locate", () => {
95
+ const proof = proofOf("container.proof.json");
96
+ const c = computeSlotCommitment(proof.slotAllocation!);
97
+ const lying = p.build({ original: bytes("image.png"), originDigest: sha256(bytes("original.txt")), commitment: c });
98
+ // build accepts a caller-supplied origin digest; locate returns what is written,
99
+ // and the verifier compares it against the marker and the original's hash.
100
+ const l = p.locate(lying)!;
101
+ assert.deepEqual(l.originDigest, sha256(bytes("original.txt")));
102
+ assert.notDeepEqual(l.originDigest, sha256(l.originalBytes!));
103
+ });
104
+ });
105
+
106
+ describe("produced/1", () => {
107
+ const p = getPlacement("produced/1")!;
108
+ test("build and locate agree, with and without an origin", () => {
109
+ for (const [name, proofName] of [["produced-origin.json", "produced-origin.proof.json"], ["produced-bare.json", "produced-bare.proof.json"]] as const) {
110
+ const proof = proofOf(proofName);
111
+ const l = p.locate(bytes(name))!;
112
+ assert.deepEqual(l.commitment, computeSlotCommitment(proof.slotAllocation!));
113
+ assert.equal(bytesToBase64(sha256(bytes(name))), proof.artifact.digestB64);
114
+ }
115
+ assert.equal(p.locate(bytes("original.txt")), null);
116
+ assert.throws(() => p.build({ original: new Uint8Array(1), commitment: new Uint8Array(32) }), /no original/);
117
+ });
118
+ });
119
+
120
+ describe("legacy files that carry a nonce line", () => {
121
+ test("a file with a 'nonce:' line and no trailer, container or payload locates nothing", () => {
122
+ // ~/BitGraph holds recordings whose bytes begin with a test label and a line
123
+ // "nonce: <hex>" (the proto pattern, minted 2026-06-27 and 2026-08-06). That
124
+ // line is content, not a commitment: every registered placement must miss.
125
+ const legacy = new TextEncoder().encode("BitGraph test subject folder-02 file-02\nnonce: e317096692576a308263bc283d66b42e\nuoJjpYb8igrEhhSlczKKTKDyw6guO2iU5iHdBRF+mPELQqBfjrO2WZYrSWyHEuKK\n");
126
+ for (const p of PLACEMENTS) assert.equal(p.locate(legacy), null, p.id);
127
+ });
128
+ });
129
+
130
+ describe("attribution and Frame", () => {
131
+ test("fused attribution carries placement in title and origin in message (standard base64)", () => {
132
+ const origin = sha256(bytes("original.txt"));
133
+ const a = fuseAttribution("trailer/1", origin);
134
+ assert.deepEqual(a, { name: "bitgraph-fuse/1", title: "trailer/1", message: bytesToBase64(origin) });
135
+ assert.deepEqual(fuseAttribution("produced/1"), { name: "bitgraph-fuse/1", title: "produced/1" });
136
+ const proof = proofOf("trailer.proof.json");
137
+ const m = readFuseAttribution(proof)!;
138
+ assert.equal(m.placement, "trailer/1");
139
+ assert.equal(m.placementSource, "attribution");
140
+ assert.equal(m.source, "attribution");
141
+ assert.deepEqual(m.originDigest, origin);
142
+ assert.equal(m.originSource, "attribution");
143
+ const bare = readFuseAttribution(proofOf("trailer-undeclared.proof.json"))!;
144
+ assert.equal(bare.originDigest, undefined);
145
+ assert.equal(bare.originSource, undefined);
146
+ assert.equal(readFuseAttribution(proofOf("recorded.proof.json")), null, "an ordinary proof has no marker");
147
+ });
148
+ test("a Frame parses regardless of outer formatting and the nested proof is untouched", () => {
149
+ const text = readFileSync(FIX + "trailer.bitgraph-fuse.json", "utf8");
150
+ const a = parseFrame(text)!;
151
+ const b = parseFrame(JSON.stringify(JSON.parse(text)))!;
152
+ assert.deepEqual(a, b);
153
+ assert.equal(a.manifest.placement, "trailer/1");
154
+ assert.deepEqual(a.proof, proofOf("trailer.proof.json"));
155
+ const marker = readFrameMarker(a);
156
+ assert.equal(marker.source, "manifest");
157
+ assert.equal(marker.placementSource, "manifest");
158
+ assert.deepEqual(marker.originDigest, sha256(bytes("original.txt")));
159
+ const undeclared = parseFrame({ type: "bitgraph-fuse/1", manifest: { artifact: { algorithm: "sha256", digest: "00".repeat(32) }, fusedFile: null }, proof: proofOf("trailer.proof.json") })!;
160
+ assert.equal(readFrameMarker(undeclared).placement, null, "a manifest may leave the placement undeclared");
161
+ assert.equal(parseFrame(proofOf("trailer.proof.json")), null, "a bare proof is not a Frame");
162
+ assert.equal(parseFrame("{not json"), null);
163
+ assert.equal(parseFrame({ type: "bitgraph-fuse/1", manifest: { placement: "trailer/1", artifact: { algorithm: "sha256", digest: "zz" }, fusedFile: null }, proof: {} }), null);
164
+ });
165
+ test("a Frame is never proof-shaped", () => {
166
+ const f = JSON.parse(readFileSync(FIX + "produced-origin.bitgraph-fuse.json", "utf8")) as Record<string, unknown>;
167
+ assert.equal(f.version, undefined);
168
+ assert.equal(f.artifact, undefined);
169
+ assert.equal(f.signer, undefined);
170
+ assert.equal(f.type, "bitgraph-fuse/1");
171
+ assert.ok(f.fusePayload, "Form C frames carry the parsed payload view");
172
+ assert.throws(() => buildFrame({ proof: proofOf("produced-origin.proof.json"), placement: "produced/1", artifactDigest: new Uint8Array(32), fusedFile: null, fusePayload: new Uint8Array([1]) }), /canonical/);
173
+ });
174
+ });