verifyhash 0.1.0 → 0.1.1

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 (63) hide show
  1. package/README.md +5 -3
  2. package/cli/agent-hook.js +431 -0
  3. package/docs/ADOPT.md +15 -5
  4. package/docs/AGENT-HOOK.md +111 -0
  5. package/docs/PUBLISH-VERIFY-VH.md +45 -0
  6. package/examples/README.md +185 -0
  7. package/examples/policy.lenient.json +5 -0
  8. package/examples/policy.strict.json +6 -0
  9. package/examples/run.js +366 -0
  10. package/examples/sample-dataset/README.txt +10 -0
  11. package/examples/sample-dataset/corpus/cc-by-poem.txt +8 -0
  12. package/examples/sample-dataset/corpus/mit-notes.txt +4 -0
  13. package/examples/sample-dataset/data/unlabeled.txt +5 -0
  14. package/examples/sample-dataset/vendored/gpl-snippet.txt +5 -0
  15. package/examples/sample-dataset.hints.json +7 -0
  16. package/examples/sample-parcel/data/manifest-of-contents.txt +7 -0
  17. package/examples/sample-parcel/data/records.csv +4 -0
  18. package/examples/sample-parcel/delivery-note.txt +9 -0
  19. package/package.json +25 -3
  20. package/verifier/README.md +555 -0
  21. package/verifier/action/README.md +87 -0
  22. package/verifier/action/action.yml +146 -0
  23. package/verifier/build-standalone-html.js +1287 -0
  24. package/verifier/build-standalone.js +989 -0
  25. package/verifier/ci/journal.generic.sh +96 -0
  26. package/verifier/ci/journal.github-actions.yml +99 -0
  27. package/verifier/ci/reproduce-vh.generic.sh +59 -0
  28. package/verifier/ci/reproduce-vh.github-actions.yml +49 -0
  29. package/verifier/ci/verify-service.generic.sh +96 -0
  30. package/verifier/ci/verify-service.github-actions.yml +88 -0
  31. package/verifier/ci/verify-vh.generic.sh +75 -0
  32. package/verifier/ci/verify-vh.github-actions.yml +56 -0
  33. package/verifier/dist/BUILD-PROVENANCE.json +210 -0
  34. package/verifier/dist/seal-vh-standalone.js +876 -0
  35. package/verifier/dist/seal-vh-standalone.js.sha256 +1 -0
  36. package/verifier/dist/verify-vh-standalone.html +3373 -0
  37. package/verifier/dist/verify-vh-standalone.html.sha256 +1 -0
  38. package/verifier/dist/verify-vh-standalone.js +4121 -0
  39. package/verifier/dist/verify-vh-standalone.js.sha256 +1 -0
  40. package/verifier/lib/canonical.js +141 -0
  41. package/verifier/lib/keccak.js +30 -0
  42. package/verifier/lib/keccak256-vendored.js +206 -0
  43. package/verifier/lib/merkle.js +145 -0
  44. package/verifier/lib/revocation-core.js +606 -0
  45. package/verifier/lib/revocation.js +200 -0
  46. package/verifier/lib/seal-cli.js +374 -0
  47. package/verifier/lib/seal-evidence.js +237 -0
  48. package/verifier/lib/secp256k1-recover.js +249 -0
  49. package/verifier/package.json +39 -0
  50. package/verifier/verify-vh.js +2374 -0
  51. package/docs/ADOPTION.json +0 -11
  52. package/docs/AUDIT.md +0 -55
  53. package/docs/DECIDE.md +0 -47
  54. package/docs/DECISIONS-PENDING.md +0 -27
  55. package/docs/DEPLOY-PUBLIC-SITE.md +0 -301
  56. package/docs/ENGINE-LEDGER.json +0 -12
  57. package/docs/LOOP-AUDIT-2026-07-03.json +0 -580
  58. package/docs/LOOP-HARDENING-PLAN.md +0 -44
  59. package/docs/METRICS.jsonl +0 -31
  60. package/docs/MORNING.md +0 -204
  61. package/docs/STRATEGY-ARCHIVE.md +0 -5055
  62. package/docs/SUPERVISOR-RUNBOOK.md +0 -52
  63. package/docs/USAGE-BUDGET.json +0 -121
@@ -0,0 +1,4121 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // verify-vh-standalone.js — the SINGLE-FILE, ZERO-DEPENDENCY, OFFLINE verifyhash verifier.
5
+ //
6
+ // SPDX-License-Identifier: Apache-2.0
7
+ // Copyright 2026 verifyhash.com — https://verifyhash.com
8
+ //
9
+ // GENERATED by verifier/build-standalone.js from the in-tree verifier — DO NOT EDIT BY HAND.
10
+ // Re-generate with: node verifier/build-standalone.js (the build is deterministic; see that file.)
11
+ //
12
+ // HOW TO USE IT (no clone, no `npm install`, no node_modules, no package.json):
13
+ // 1. Save THIS one file somewhere next to the sealed artifact you were handed.
14
+ // 2. Run: node verify-vh-standalone.js <artifact> [--vendor <0xaddr>] [--dir <d>] [--json]
15
+ // Exit codes: 0 ok / 3 rejected / 2 usage / 1 IO. It is READ-ONLY and opens NO network.
16
+ //
17
+ // SELF-DESCRIBING (needs NO second file): this bundle carries its OWN build-provenance.
18
+ // node verify-vh-standalone.js --self-attest # confirm THIS file's bytes are intact (0 ok / 1 modified)
19
+ // node verify-vh-standalone.js --provenance # print the ordered source modules + sha256 it was built from
20
+ //
21
+ // It RE-DERIVES the keccak Merkle root from the bytes YOU hold and recovers the signer with a
22
+ // pure-JS secp256k1 routine — it never trusts the artifact's own stored hashes, and it requires
23
+ // NOTHING outside Node core. This is the in-tree verifier inlined verbatim, with keccak256 swapped
24
+ // for a byte-identical pure-JS implementation (cross-checked against js-sha3 AND ethers).
25
+
26
+ // ---- minimal CommonJS module shim (so the inlined modules keep their require() structure) --------
27
+ var __modules = Object.create(null);
28
+ var __cache = Object.create(null);
29
+ function __require(id) {
30
+ if (id in __cache) return __cache[id].exports;
31
+ var factory = __modules[id];
32
+ if (!factory) throw new Error('standalone bundle: unknown module: ' + id);
33
+ var module = { exports: {} };
34
+ __cache[id] = module;
35
+ factory(module, module.exports, __require);
36
+ return module.exports;
37
+ }
38
+
39
+ // ===== module: keccak256-vendored (from verifier/lib/keccak256-vendored.js) =====
40
+ __modules["keccak256-vendored"] = function (module, exports, __require) {
41
+ "use strict";
42
+
43
+ // verifier/lib/keccak256-vendored.js — a PURE-JS, ZERO-DEPENDENCY keccak256 (T-35.1).
44
+ //
45
+ // WHY THIS FILE EXISTS
46
+ // The whole point of the free verifier is "save ONE file, run it with `node`, no `npm install`, audit it
47
+ // in one sitting." The in-tree verifier core takes keccak256 from `js-sha3` (verifier/lib/keccak.js) — an
48
+ // audited, dependency-free package that is already a project dependency — which is correct for the
49
+ // IN-TREE path. But `js-sha3` is still a RUNTIME dependency: a third party handed a single sealed packet
50
+ // would have to `npm install` it. This module is the LAST piece needed to inline the verifier into one
51
+ // self-contained file: a from-scratch keccak256 that `require`s NOTHING (no `js-sha3`, no Node core, no
52
+ // relative module). It is ADDITIVE — keccak.js and verifier/package.json's `dependencies: ["js-sha3"]`
53
+ // are deliberately left UNCHANGED so the existing tree + isolation test stay green.
54
+ //
55
+ // CORRECTNESS, NOT NOVELTY
56
+ // keccak256 is the FIXED, standardized Keccak[c=512] sponge over the Keccak-f[1600] permutation with the
57
+ // ORIGINAL Keccak padding (a single 0x01 domain byte, NOT SHA3's 0x06) and a 256-bit squeeze — exactly
58
+ // what Ethereum/ethers and `js-sha3.keccak256` compute. This is a textbook implementation of FIPS-202's
59
+ // Keccak-f (theta, rho, pi, chi, iota) done with 32-bit lane halves (lo/hi) so it runs on plain JS numbers
60
+ // with no BigInt and no 64-bit-int dependency. test/verifier.keccak-vendored.test.js proves byte-identical
61
+ // output vs BOTH `js-sha3` AND the production `ethers` keccak path across the empty input, the known
62
+ // vectors, and ≥500 random buffers — a single mismatch FAILS. So this is independent CODE but never an
63
+ // independent ALGORITHM: it cannot silently diverge from the standard.
64
+ //
65
+ // REQUIRES NOTHING: a grep of this source finds no CommonJS require call and no bare-name import.
66
+ // (Intentional — this property is asserted by test/verifier.keccak-vendored.test.js.)
67
+
68
+ // ---- Keccak-f[1600] round constants, split into 32-bit (hi, lo) halves --------------------------------
69
+ // The 24 RC[i] are the canonical Keccak iota constants; here each 64-bit constant is pre-split so we never
70
+ // need a 64-bit integer type. RC_HI[i] is bits 63..32, RC_LO[i] is bits 31..0.
71
+ const RC_HI = [
72
+ 0x00000000, 0x00000000, 0x80000000, 0x80000000, 0x00000000, 0x00000000, 0x80000000, 0x80000000,
73
+ 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x80000000, 0x80000000, 0x80000000,
74
+ 0x80000000, 0x80000000, 0x00000000, 0x80000000, 0x80000000, 0x80000000, 0x00000000, 0x80000000,
75
+ ];
76
+ const RC_LO = [
77
+ 0x00000001, 0x00008082, 0x0000808a, 0x80008000, 0x0000808b, 0x80000001, 0x80008081, 0x00008009,
78
+ 0x0000008a, 0x00000088, 0x80008009, 0x8000000a, 0x8000808b, 0x0000008b, 0x00008089, 0x00008003,
79
+ 0x00008002, 0x00000080, 0x0000800a, 0x8000000a, 0x80008081, 0x00008080, 0x80000001, 0x80008008,
80
+ ];
81
+
82
+ // Rotation offsets r[x,y] for the rho step, indexed by lane number (x + 5*y). Lane 0 is never rotated.
83
+ const RHO = [
84
+ 0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14,
85
+ ];
86
+ // pi permutation: destination lane for each source lane. pi maps (x,y) -> (y, 2x+3y), so source lane
87
+ // (x + 5y) is written to lane (y + 5*((2x+3y) mod 5)); PI[src] = dst.
88
+ const PI = [
89
+ 0, 10, 20, 5, 15, 16, 1, 11, 21, 6, 7, 17, 2, 12, 22, 23, 8, 18, 3, 13, 14, 24, 9, 19, 4,
90
+ ];
91
+
92
+ // The state is 25 lanes; we hold each lane as two 32-bit halves in parallel arrays sLo/sHi (index = lane).
93
+
94
+ // Keccak-f[1600] permutation, in place, on (sLo, sHi). 24 rounds of theta, rho+pi, chi, iota.
95
+ function keccakF(sLo, sHi) {
96
+ const bcLo = new Array(5);
97
+ const bcHi = new Array(5);
98
+ const tLo = new Array(25);
99
+ const tHi = new Array(25);
100
+
101
+ for (let round = 0; round < 24; round++) {
102
+ // --- theta ---
103
+ for (let x = 0; x < 5; x++) {
104
+ bcLo[x] = sLo[x] ^ sLo[x + 5] ^ sLo[x + 10] ^ sLo[x + 15] ^ sLo[x + 20];
105
+ bcHi[x] = sHi[x] ^ sHi[x + 5] ^ sHi[x + 10] ^ sHi[x + 15] ^ sHi[x + 20];
106
+ }
107
+ for (let x = 0; x < 5; x++) {
108
+ // d = bc[x-1] XOR rotl1(bc[x+1])
109
+ const x1 = (x + 1) % 5;
110
+ const x4 = (x + 4) % 5;
111
+ const rotLo = ((bcLo[x1] << 1) | (bcHi[x1] >>> 31)) >>> 0;
112
+ const rotHi = ((bcHi[x1] << 1) | (bcLo[x1] >>> 31)) >>> 0;
113
+ const dLo = (bcLo[x4] ^ rotLo) >>> 0;
114
+ const dHi = (bcHi[x4] ^ rotHi) >>> 0;
115
+ for (let y = 0; y < 25; y += 5) {
116
+ sLo[x + y] = (sLo[x + y] ^ dLo) >>> 0;
117
+ sHi[x + y] = (sHi[x + y] ^ dHi) >>> 0;
118
+ }
119
+ }
120
+
121
+ // --- rho + pi --- (write permuted, rotated lanes into t)
122
+ for (let i = 0; i < 25; i++) {
123
+ const r = RHO[i];
124
+ const dest = PI[i];
125
+ let outLo, outHi;
126
+ if (r === 0) {
127
+ outLo = sLo[i];
128
+ outHi = sHi[i];
129
+ } else if (r < 32) {
130
+ outLo = ((sLo[i] << r) | (sHi[i] >>> (32 - r))) >>> 0;
131
+ outHi = ((sHi[i] << r) | (sLo[i] >>> (32 - r))) >>> 0;
132
+ } else if (r === 32) {
133
+ outLo = sHi[i];
134
+ outHi = sLo[i];
135
+ } else {
136
+ const rr = r - 32;
137
+ outLo = ((sHi[i] << rr) | (sLo[i] >>> (32 - rr))) >>> 0;
138
+ outHi = ((sLo[i] << rr) | (sHi[i] >>> (32 - rr))) >>> 0;
139
+ }
140
+ tLo[dest] = outLo;
141
+ tHi[dest] = outHi;
142
+ }
143
+
144
+ // --- chi --- a[x] = t[x] XOR ((NOT t[x+1]) AND t[x+2]), per row
145
+ for (let y = 0; y < 25; y += 5) {
146
+ for (let x = 0; x < 5; x++) {
147
+ const x1 = y + ((x + 1) % 5);
148
+ const x2 = y + ((x + 2) % 5);
149
+ sLo[y + x] = (tLo[y + x] ^ (~tLo[x1] & tLo[x2])) >>> 0;
150
+ sHi[y + x] = (tHi[y + x] ^ (~tHi[x1] & tHi[x2])) >>> 0;
151
+ }
152
+ }
153
+
154
+ // --- iota ---
155
+ sLo[0] = (sLo[0] ^ RC_LO[round]) >>> 0;
156
+ sHi[0] = (sHi[0] ^ RC_HI[round]) >>> 0;
157
+ }
158
+ }
159
+
160
+ // keccak256 over `bytes` (a Uint8Array/Buffer or array of byte values), returning a 32-byte Uint8Array.
161
+ // Rate r = 1088 bits = 136 bytes (c = 512), original Keccak padding (0x01 .. 0x80), 256-bit output.
162
+ function keccak256Bytes(bytes) {
163
+ const RATE = 136; // bytes absorbed per permutation
164
+ const sLo = new Array(25).fill(0);
165
+ const sHi = new Array(25).fill(0);
166
+
167
+ // Build the padded message: append a single 0x01 domain/pad start byte, zero-fill, set the high bit
168
+ // (0x80) of the final rate block. (If the 0x01 lands on the last byte of a block, it merges to 0x81.)
169
+ const inLen = bytes.length;
170
+ const padLen = RATE - (inLen % RATE); // 1..RATE, guarantees room for the 0x01 and 0x80 markers
171
+ const total = inLen + padLen;
172
+ const msg = new Uint8Array(total);
173
+ for (let i = 0; i < inLen; i++) msg[i] = bytes[i] & 0xff;
174
+ msg[inLen] = 0x01; // start of the original-Keccak pad (NOT SHA3's 0x06)
175
+ msg[total - 1] = (msg[total - 1] | 0x80) & 0xff; // final-block high bit
176
+
177
+ // Absorb: XOR each RATE-byte block into the state (little-endian lanes) and permute.
178
+ for (let off = 0; off < total; off += RATE) {
179
+ for (let i = 0; i < RATE; i += 8) {
180
+ const lane = i >> 3; // lane index within the rate region (0..16), block-relative
181
+ const b = off + i;
182
+ const lo =
183
+ ((msg[b] | (msg[b + 1] << 8) | (msg[b + 2] << 16) | (msg[b + 3] << 24)) >>> 0);
184
+ const hi =
185
+ ((msg[b + 4] | (msg[b + 5] << 8) | (msg[b + 6] << 16) | (msg[b + 7] << 24)) >>> 0);
186
+ sLo[lane] = (sLo[lane] ^ lo) >>> 0;
187
+ sHi[lane] = (sHi[lane] ^ hi) >>> 0;
188
+ }
189
+ keccakF(sLo, sHi);
190
+ }
191
+
192
+ // Squeeze 256 bits = 32 bytes = the first 4 lanes (little-endian), no further permutation needed.
193
+ const out = new Uint8Array(32);
194
+ for (let lane = 0; lane < 4; lane++) {
195
+ const lo = sLo[lane];
196
+ const hi = sHi[lane];
197
+ const base = lane * 8;
198
+ out[base] = lo & 0xff;
199
+ out[base + 1] = (lo >>> 8) & 0xff;
200
+ out[base + 2] = (lo >>> 16) & 0xff;
201
+ out[base + 3] = (lo >>> 24) & 0xff;
202
+ out[base + 4] = hi & 0xff;
203
+ out[base + 5] = (hi >>> 8) & 0xff;
204
+ out[base + 6] = (hi >>> 16) & 0xff;
205
+ out[base + 7] = (hi >>> 24) & 0xff;
206
+ }
207
+ return out;
208
+ }
209
+
210
+ // Lowercase hex (no 0x prefix) of a byte array — used by the hex-string entry point.
211
+ function toHex(bytes) {
212
+ let s = "";
213
+ for (let i = 0; i < bytes.length; i++) {
214
+ const b = bytes[i] & 0xff;
215
+ s += (b < 16 ? "0" : "") + b.toString(16);
216
+ }
217
+ return s;
218
+ }
219
+
220
+ /**
221
+ * keccak256 over a byte buffer.
222
+ * @param {Uint8Array|Buffer|number[]} bytes input bytes
223
+ * @returns {Uint8Array} the 32-byte digest
224
+ */
225
+ function keccak256(bytes) {
226
+ if (
227
+ !(bytes instanceof Uint8Array) &&
228
+ !Array.isArray(bytes) &&
229
+ !(typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(bytes))
230
+ ) {
231
+ throw new TypeError("keccak256 requires a Uint8Array/Buffer/byte-array of input bytes");
232
+ }
233
+ return keccak256Bytes(bytes);
234
+ }
235
+
236
+ /**
237
+ * keccak256 over a byte buffer, returned as a lowercase hex string WITHOUT a 0x prefix
238
+ * (matching `js-sha3`'s keccak256().hex() output, for drop-in cross-checking).
239
+ * @param {Uint8Array|Buffer|number[]} bytes input bytes
240
+ * @returns {string} 64-char lowercase hex
241
+ */
242
+ function keccak256Hex(bytes) {
243
+ return toHex(keccak256(bytes));
244
+ }
245
+
246
+ module.exports = { keccak256, keccak256Hex };
247
+
248
+ };
249
+
250
+ // ===== module: keccak (from verifier/lib/keccak.js) =====
251
+ __modules["keccak"] = function (module, exports, __require) {
252
+ "use strict";
253
+ // Inlined keccak provider for the standalone bundle: the SAME `keccak256(bytes) -> Buffer` surface as
254
+ // verifier/lib/keccak.js, but backed by the PURE-JS, zero-dependency vendored implementation
255
+ // (verifier/lib/keccak256-vendored.js) instead of js-sha3 — so the bundle requires nothing external.
256
+ var vendored = __require("keccak256-vendored");
257
+ function keccak256(bytes) {
258
+ if (!(bytes instanceof Uint8Array) && !Buffer.isBuffer(bytes)) {
259
+ throw new TypeError("keccak256 requires a Buffer/Uint8Array of input bytes");
260
+ }
261
+ // The vendored routine returns a Uint8Array; wrap it as a Buffer so downstream `.slice(...).toString
262
+ // ("hex")` and `Buffer.concat([...])` callers behave exactly as they do with the js-sha3-backed shim.
263
+ return Buffer.from(vendored.keccak256(bytes));
264
+ }
265
+ module.exports = { keccak256 };
266
+ };
267
+
268
+ // ===== module: merkle (from verifier/lib/merkle.js) =====
269
+ __modules["merkle"] = function (module, exports, __require) {
270
+ "use strict";
271
+
272
+ // verifier/lib/merkle.js — INDEPENDENT re-derivation of the family's path-bound, domain-separated
273
+ // Merkle convention, using ONLY ./keccak (js-sha3). NO ethers, NO hardhat, NO require back into cli/.
274
+ //
275
+ // WHY THIS EXISTS
276
+ // To verify an evidence seal / reconciliation seal / proof bundle OFFLINE without the producer stack,
277
+ // the independent verifier must RE-DERIVE the same per-file leaves and the same Merkle root the
278
+ // producer (cli/hash.js) computes. cli/hash.js uses `ethers` (keccak256/concat/toUtf8Bytes), which the
279
+ // verifier explicitly refuses to depend on. So this file reproduces the EXACT byte composition of
280
+ // pathLeaf / leafHash / nodeHash / buildTree from first principles — and test/verifier.cli.test.js
281
+ // cross-checks the result is byte-identical to the producer's. The two can never silently diverge.
282
+ //
283
+ // THE CONVENTION (must match cli/hash.js VERBATIM)
284
+ // * content digest c = keccak256(file bytes)
285
+ // * DIR_LEAF_DOMAIN = keccak256("verifyhash/dir-leaf/v1") (a fixed 32-byte prefix)
286
+ // * path-bound leaf pathLeaf = keccak256(DIR_LEAF_DOMAIN ++ utf8(relPath) ++ 0x00 ++ c)
287
+ // * tagged leaf leafHash = keccak256(0x00 ++ leaf)
288
+ // * interior node nodeHash = keccak256(0x01 ++ min(a,b) ++ max(a,b)) (sorted 32-byte pair)
289
+ // * tree sorted-leaf, "duplicate the lone odd node" pairing (OpenZeppelin style)
290
+ // relPath is normalized with no leading "./", exactly as the producer's toPosixRel does. CRUCIALLY
291
+ // this must be BYTE-FOR-BYTE the producer's normalization (cli/hash.js#toPosixRel) — see toPosixRel
292
+ // below — or the verifier would re-derive a DIFFERENT root than the producer sealed for some input
293
+ // class and would either falsely reject a genuine artifact or falsely accept the wrong one.
294
+
295
+ const { keccak256 } = __require("keccak");
296
+
297
+ // Domain tags, byte-identical to ContributionRegistry / cli/hash.js LEAF_TAG / NODE_TAG.
298
+ const LEAF_TAG = Buffer.from([0x00]);
299
+ const NODE_TAG = Buffer.from([0x01]);
300
+ const PATH_SEP = Buffer.from([0x00]);
301
+
302
+ // The fixed, versioned domain prefix for path-bound directory leaves: keccak256 of the ASCII tag.
303
+ const DIR_LEAF_DOMAIN_STR = "verifyhash/dir-leaf/v1";
304
+ const DIR_LEAF_DOMAIN = keccak256(Buffer.from(DIR_LEAF_DOMAIN_STR, "utf8")); // 32-byte Buffer
305
+
306
+ const HEX32_RE = /^0x[0-9a-fA-F]{64}$/;
307
+
308
+ // 0x-hex string (no 0x, lowercase) <-> 32-byte Buffer.
309
+ function hexToBuf32(hex) {
310
+ if (typeof hex !== "string" || !HEX32_RE.test(hex)) {
311
+ throw new Error(`expected a 0x-prefixed 32-byte hex string, got: ${String(hex)}`);
312
+ }
313
+ return Buffer.from(hex.slice(2), "hex");
314
+ }
315
+ function bufToHex(buf) {
316
+ return "0x" + Buffer.from(buf).toString("hex");
317
+ }
318
+
319
+ /** keccak256 of raw bytes, returned as a 0x-prefixed 32-byte hex string (matches cli/hash.js hashBytes). */
320
+ function hashBytes(bytes) {
321
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
322
+ return bufToHex(keccak256(buf));
323
+ }
324
+
325
+ /**
326
+ * Normalize a relPath EXACTLY as the producer (cli/hash.js#toPosixRel) does, so the verifier
327
+ * re-derives the IDENTICAL root the producer sealed. The producer is `split(path.sep).join("/")`
328
+ * then `.replace(/^\.\//, "")`. The artifacts the verifier reads carry relPaths the producer wrote,
329
+ * and those are produced on POSIX hosts (cli/evidence.js#loadDirEntries does the same `path.sep`
330
+ * split) — where `path.sep === "/"`, so the split/join is a no-op and a literal backslash byte is a
331
+ * CONTENT byte that survives into the hash. We therefore must NOT collapse backslashes: a previous
332
+ * version unconditionally mapped "\\"->"/", which made the verifier hash `a/b.txt` while the producer
333
+ * hashed `a\b.txt` — a silent root divergence that could falsely REJECT a genuine backslash-named
334
+ * directory or falsely ACCEPT one where `a/b.txt` and `a\b.txt` collide. All we strip is the leading
335
+ * "./", which the producer also strips on every host. (Windows-authored relPaths, if ever needed,
336
+ * must be converted to "/" on BOTH the producer and verifier sides identically — not only here.)
337
+ */
338
+ function toPosixRel(relPath) {
339
+ return String(relPath).replace(/^\.\//, "");
340
+ }
341
+
342
+ /**
343
+ * pathLeaf(relPath, contentDigest) = keccak256(DIR_LEAF_DOMAIN ++ utf8(relPath) ++ 0x00 ++ c).
344
+ * @param {string} relPath
345
+ * @param {string} contentDigest 0x bytes32
346
+ * @returns {string} 0x bytes32
347
+ */
348
+ function pathLeaf(relPath, contentDigest) {
349
+ const relBytes = Buffer.from(toPosixRel(relPath), "utf8");
350
+ const c = hexToBuf32(contentDigest);
351
+ return bufToHex(keccak256(Buffer.concat([DIR_LEAF_DOMAIN, relBytes, PATH_SEP, c])));
352
+ }
353
+
354
+ /** leafHash(c) = keccak256(LEAF_TAG ++ c). */
355
+ function leafHash(c) {
356
+ return bufToHex(keccak256(Buffer.concat([LEAF_TAG, hexToBuf32(c)])));
357
+ }
358
+
359
+ /** nodeHash(a,b) = keccak256(NODE_TAG ++ min(a,b) ++ max(a,b)) comparing as 32-byte big-endian values. */
360
+ function nodeHash(a, b) {
361
+ const A = hexToBuf32(a);
362
+ const B = hexToBuf32(b);
363
+ const [lo, hi] = Buffer.compare(A, B) <= 0 ? [A, B] : [B, A];
364
+ return bufToHex(keccak256(Buffer.concat([NODE_TAG, lo, hi])));
365
+ }
366
+
367
+ /**
368
+ * Build the sorted-leaf, domain-separated Merkle root from an array of per-file PATH-BOUND leaves
369
+ * (the same values pathLeaf produces). Leaves are sorted ascending by their 32-byte value, tagged via
370
+ * leafHash, then folded with nodeHash, pairing a lone odd node with itself — byte-identical to
371
+ * cli/hash.js buildTree's root.
372
+ * @param {string[]} leaves array of 0x bytes32 path-bound leaves
373
+ * @returns {string} the 0x bytes32 root
374
+ */
375
+ function rootFromLeaves(leaves) {
376
+ if (!Array.isArray(leaves) || leaves.length === 0) {
377
+ throw new Error("cannot build a Merkle tree from zero leaves");
378
+ }
379
+ const sorted = leaves
380
+ .slice()
381
+ .sort((a, b) => Buffer.compare(hexToBuf32(a), hexToBuf32(b)));
382
+ let layer = sorted.map((c) => leafHash(c));
383
+ while (layer.length > 1) {
384
+ const next = [];
385
+ for (let i = 0; i < layer.length; i += 2) {
386
+ const right = i + 1 < layer.length ? layer[i + 1] : layer[i];
387
+ next.push(nodeHash(layer[i], right));
388
+ }
389
+ layer = next;
390
+ }
391
+ return layer[0];
392
+ }
393
+
394
+ /**
395
+ * Re-derive the top-level root from a flat list of { relPath, contentHash } — the SAME computation the
396
+ * seal cores use: pathLeaf each, then rootFromLeaves. PURE.
397
+ * @param {{relPath:string, contentHash:string}[]} flat
398
+ * @returns {string} 0x bytes32 root
399
+ */
400
+ function rootFromFlat(flat) {
401
+ return rootFromLeaves(flat.map((e) => pathLeaf(e.relPath, e.contentHash)));
402
+ }
403
+
404
+ module.exports = {
405
+ HEX32_RE,
406
+ DIR_LEAF_DOMAIN_STR,
407
+ hashBytes,
408
+ toPosixRel,
409
+ pathLeaf,
410
+ leafHash,
411
+ nodeHash,
412
+ rootFromLeaves,
413
+ rootFromFlat,
414
+ };
415
+
416
+ };
417
+
418
+ // ===== module: canonical (from verifier/lib/canonical.js) =====
419
+ __modules["canonical"] = function (module, exports, __require) {
420
+ "use strict";
421
+
422
+ // verifier/lib/canonical.js — INDEPENDENT canonical UNSIGNED serialization.
423
+ //
424
+ // WHY THIS EXISTS
425
+ // For the independent `verifier/` to sign/hash over BYTE-IDENTICAL input to the production path, it must
426
+ // reproduce the family's canonical UNSIGNED serialization itself — WITHOUT importing the producer code in
427
+ // `cli/`. If the verifier imported `cli/dataset.js`'s `serializeAttestation`, a cross-check would be
428
+ // circular (it would be comparing a function to itself). So this file re-derives the SAME byte string,
429
+ // from first principles, and the cross-check test asserts it equals what the producer emits.
430
+ //
431
+ // THE CANONICAL CONVENTION (must match cli/core/attestation.js + cli/dataset.js#serializeAttestation)
432
+ // * A FIXED key order — NOT JSON.stringify's insertion order by accident, but an EXPLICIT ordered key
433
+ // list per object shape. We emit keys in that exact order.
434
+ // * NO insignificant whitespace (separators ",", ":").
435
+ // * A SINGLE trailing newline ("\n") terminating the document.
436
+ // The result is byte-deterministic: the same logical value always serializes to the same bytes.
437
+
438
+ /**
439
+ * Serialize a value to canonical JSON with an EXPLICIT key order, no insignificant whitespace, and NO
440
+ * trailing newline (the newline is the document-level convention added by the envelope serializers below).
441
+ *
442
+ * Key order: when `keyOrder[<path-or-shape>]` is provided we use it; otherwise keys are emitted in the
443
+ * object's own insertion order (matching the producer's explicit object literals, which V8 preserves).
444
+ * Because the producers always build their canonical objects via explicit ordered literals, reproducing
445
+ * that same ordered literal here yields byte-identical output WITHOUT a generic key-sorting pass.
446
+ *
447
+ * This is a minimal, dependency-free JSON emitter that matches JSON.stringify's escaping for the value
448
+ * shapes this family uses (strings, integers, booleans, null, nested objects/arrays).
449
+ *
450
+ * @param {*} value
451
+ * @returns {string} canonical JSON (no trailing newline)
452
+ */
453
+ function canonicalJson(value) {
454
+ // JSON.stringify with no spacing already emits ","/":" separators and standard string escaping with no
455
+ // insignificant whitespace. The ONLY thing it does not do for us is reorder keys — but the family's
456
+ // canonical objects are built as explicit ordered literals, so insertion order IS the canonical order.
457
+ // We therefore use JSON.stringify directly on a value whose keys are already in canonical order. This is
458
+ // intentionally the SAME primitive the producer uses, but driven from an INDEPENDENTLY constructed,
459
+ // explicitly-ordered object here (so the bytes are reproduced, not imported).
460
+ return JSON.stringify(value);
461
+ }
462
+
463
+ /**
464
+ * Reproduce the canonical UNSIGNED dataset-attestation bytes, byte-for-byte identical to
465
+ * `cli/dataset.js#serializeAttestation` — WITHOUT importing it.
466
+ *
467
+ * Canonical top-level key order (from the producer's explicit object literal):
468
+ * kind, schemaVersion, note, root, fileCount, manifestDigest, signed, signature
469
+ * then a single trailing newline.
470
+ *
471
+ * @param {object} env a validated UNSIGNED attestation envelope
472
+ * @returns {string} the canonical serialization (newline-terminated)
473
+ */
474
+ function serializeUnsignedDatasetAttestation(env) {
475
+ if (env == null || typeof env !== "object" || Array.isArray(env)) {
476
+ throw new Error("serializeUnsignedDatasetAttestation requires an attestation envelope object");
477
+ }
478
+ // Build the canonical object via an EXPLICIT ordered literal — independently of the producer.
479
+ const canonical = {
480
+ kind: env.kind,
481
+ schemaVersion: env.schemaVersion,
482
+ note: env.note,
483
+ root: env.root,
484
+ fileCount: env.fileCount,
485
+ manifestDigest: env.manifestDigest,
486
+ signed: env.signed,
487
+ signature: env.signature,
488
+ };
489
+ return canonicalJson(canonical) + "\n";
490
+ }
491
+
492
+ /**
493
+ * Generic canonical envelope serializer for the family's signed-attestation containers, reproducing
494
+ * `cli/core/attestation.js#serializeSignedAttestation` byte-for-byte WITHOUT importing it.
495
+ *
496
+ * Canonical key order: kind, schemaVersion, note, attestation, signature{scheme,signer,signature}
497
+ * then a single trailing newline.
498
+ *
499
+ * @param {object} container a signed-attestation container
500
+ * @returns {string} the canonical serialization (newline-terminated)
501
+ */
502
+ function serializeSignedContainer(container) {
503
+ if (container == null || typeof container !== "object" || Array.isArray(container)) {
504
+ throw new Error("serializeSignedContainer requires a signed-attestation container object");
505
+ }
506
+ const sig = container.signature || {};
507
+ const canonical = {
508
+ kind: container.kind,
509
+ schemaVersion: container.schemaVersion,
510
+ note: container.note,
511
+ attestation: container.attestation,
512
+ signature: {
513
+ scheme: sig.scheme,
514
+ signer: sig.signer,
515
+ signature: sig.signature,
516
+ },
517
+ };
518
+ return canonicalJson(canonical) + "\n";
519
+ }
520
+
521
+ // The reserved relPath of the synthetic HEADER leaf a reconciliation seal binds its verdict + input
522
+ // role partition into, byte-identical to trustledger/seal.js#SEAL_HEADER_RELPATH. A real file may not
523
+ // occupy it; the verifier folds the header content in under this relPath when re-deriving the root.
524
+ const TRUST_SEAL_HEADER_RELPATH = "__trustledger.seal-header__v1";
525
+
526
+ /**
527
+ * Reproduce the canonical "content" bytes of a reconciliation seal's verdict/role HEADER entry, byte-for-
528
+ * byte identical to trustledger/seal.js#_headerBytes — WITHOUT importing it. The header binds the recorded
529
+ * verdict (pass/reportDate/period) + each input's logical role→relPath into the SAME committed Merkle
530
+ * root as the files, so a verdict/role edit changes the header content -> its leaf -> the root.
531
+ *
532
+ * Canonical layout (FIXED key order, no insignificant whitespace, roles sorted by role):
533
+ * { v: 1, verdict: { pass, reportDate, period }, roles: [{ role, relPath }, ...] }
534
+ *
535
+ * @param {object} verdict { pass, reportDate, period }
536
+ * @param {{role:string, relPath:string}[]} inputs the seal's input role bindings
537
+ * @returns {Buffer} the canonical UTF-8 header content
538
+ */
539
+ function trustSealHeaderBytes(verdict, inputs) {
540
+ const canonical = {
541
+ v: 1,
542
+ verdict: {
543
+ pass: verdict.pass,
544
+ reportDate: verdict.reportDate,
545
+ period: verdict.period == null ? null : String(verdict.period),
546
+ },
547
+ roles: inputs
548
+ .map((i) => ({ role: i.role, relPath: i.relPath }))
549
+ .sort((a, b) => (a.role < b.role ? -1 : a.role > b.role ? 1 : 0)),
550
+ };
551
+ return Buffer.from(JSON.stringify(canonical), "utf8");
552
+ }
553
+
554
+ module.exports = {
555
+ canonicalJson,
556
+ serializeUnsignedDatasetAttestation,
557
+ serializeSignedContainer,
558
+ TRUST_SEAL_HEADER_RELPATH,
559
+ trustSealHeaderBytes,
560
+ };
561
+
562
+ };
563
+
564
+ // ===== module: secp256k1-recover (from verifier/lib/secp256k1-recover.js) =====
565
+ __modules["secp256k1-recover"] = function (module, exports, __require) {
566
+ "use strict";
567
+
568
+ // verifier/lib/secp256k1-recover.js — INDEPENDENT EIP-191 personal_sign signer recovery.
569
+ //
570
+ // WHY THIS EXISTS
571
+ // `verifier/` is a near-zero-dependency, SECOND implementation of the family's signature recovery, kept
572
+ // deliberately separate from the production ethers path so the two can be CROSS-CHECKED and can never
573
+ // silently drift (the anti-divergence guard in test/verifier.crypto.test.js). This file recovers the
574
+ // secp256k1 signer ADDRESS from an `eip191-personal-sign` 65-byte (r||s||v) signature over a message,
575
+ // using ONLY:
576
+ // * `js-sha3` (via ./keccak) for keccak256, and
577
+ // * a single, tiny, vendored elliptic-curve routine (below) for the secp256k1 public-key RECOVERY.
578
+ // It does NOT require `ethers`, `hardhat`, `cli/`, or `trustledger/`.
579
+ //
580
+ // THE secp256k1 ROUTINE (vendored, audited, standard)
581
+ // Public-key recovery from an ECDSA signature is textbook curve math over the secp256k1 group
582
+ // (SEC 1, §4.1.6 / §2.3). We implement exactly that with Node BigInt: affine point add/double on
583
+ // y^2 = x^3 + 7 (mod p), a constant-time-agnostic double-and-add scalar multiply, and a Tonelli-Shanks
584
+ // style square root (p ≡ 3 mod 4, so √a = a^((p+1)/4)). No randomness, no secrets — recovery is a PUBLIC
585
+ // computation over the signature + message hash, so timing/side-channels are irrelevant here. The curve
586
+ // constants are the canonical secp256k1 domain parameters.
587
+
588
+ const { keccak256 } = __require("keccak");
589
+
590
+ // ---- secp256k1 domain parameters (canonical) -------------------------------------------------------
591
+ const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; // field prime
592
+ const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; // group order
593
+ const A = 0n; // curve a
594
+ const B = 7n; // curve b
595
+ const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
596
+ const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
597
+
598
+ // ---- modular arithmetic helpers --------------------------------------------------------------------
599
+ function mod(a, m) {
600
+ const r = a % m;
601
+ return r >= 0n ? r : r + m;
602
+ }
603
+
604
+ // Modular inverse via the extended Euclidean algorithm (m is prime here, so a is invertible unless 0).
605
+ function invmod(a, m) {
606
+ a = mod(a, m);
607
+ if (a === 0n) throw new Error("secp256k1: inverse of zero");
608
+ let [old_r, r] = [a, m];
609
+ let [old_s, s] = [1n, 0n];
610
+ while (r !== 0n) {
611
+ const q = old_r / r;
612
+ [old_r, r] = [r, old_r - q * r];
613
+ [old_s, s] = [s, old_s - q * s];
614
+ }
615
+ return mod(old_s, m);
616
+ }
617
+
618
+ // Modular exponentiation (square-and-multiply).
619
+ function powmod(base, exp, m) {
620
+ base = mod(base, m);
621
+ let result = 1n;
622
+ while (exp > 0n) {
623
+ if (exp & 1n) result = mod(result * base, m);
624
+ base = mod(base * base, m);
625
+ exp >>= 1n;
626
+ }
627
+ return result;
628
+ }
629
+
630
+ // Square root mod p. secp256k1's p ≡ 3 (mod 4), so √a = a^((p+1)/4) mod p (when a is a QR).
631
+ function sqrtmod(a) {
632
+ const r = powmod(a, (P + 1n) / 4n, P);
633
+ if (mod(r * r, P) !== mod(a, P)) throw new Error("secp256k1: no square root (x not on curve)");
634
+ return r;
635
+ }
636
+
637
+ // ---- elliptic-curve point arithmetic (affine; null = point at infinity) ---------------------------
638
+ const INF = null;
639
+
640
+ function isInf(Pt) {
641
+ return Pt === INF;
642
+ }
643
+
644
+ function pointAdd(p1, p2) {
645
+ if (isInf(p1)) return p2;
646
+ if (isInf(p2)) return p1;
647
+ const [x1, y1] = p1;
648
+ const [x2, y2] = p2;
649
+ if (x1 === x2 && mod(y1 + y2, P) === 0n) return INF; // p1 = -p2
650
+ let m;
651
+ if (x1 === x2 && y1 === y2) {
652
+ // doubling: m = (3x^2 + a) / (2y)
653
+ m = mod((3n * x1 * x1 + A) * invmod(2n * y1, P), P);
654
+ } else {
655
+ m = mod((y2 - y1) * invmod(x2 - x1, P), P);
656
+ }
657
+ const x3 = mod(m * m - x1 - x2, P);
658
+ const y3 = mod(m * (x1 - x3) - y1, P);
659
+ return [x3, y3];
660
+ }
661
+
662
+ function scalarMul(k, point) {
663
+ k = mod(k, N);
664
+ let result = INF;
665
+ let addend = point;
666
+ while (k > 0n) {
667
+ if (k & 1n) result = pointAdd(result, addend);
668
+ addend = pointAdd(addend, addend);
669
+ k >>= 1n;
670
+ }
671
+ return result;
672
+ }
673
+
674
+ const G = [GX, GY];
675
+
676
+ // Decompress the curve point with x-coordinate `x` and the given y-parity (0 = even, 1 = odd).
677
+ function liftX(x, yParity) {
678
+ const alpha = mod(x * x * x + A * x + B, P); // y^2
679
+ let y = sqrtmod(alpha);
680
+ if ((y & 1n) !== BigInt(yParity)) y = mod(P - y, P);
681
+ return [x, y];
682
+ }
683
+
684
+ // ---- big-endian buffer <-> BigInt ------------------------------------------------------------------
685
+ function bufToBig(buf) {
686
+ let n = 0n;
687
+ for (const b of buf) n = (n << 8n) | BigInt(b);
688
+ return n;
689
+ }
690
+
691
+ function bigTo32(n) {
692
+ const out = Buffer.alloc(32);
693
+ for (let i = 31; i >= 0; i--) {
694
+ out[i] = Number(n & 0xffn);
695
+ n >>= 8n;
696
+ }
697
+ return out;
698
+ }
699
+
700
+ /**
701
+ * Recover the secp256k1 PUBLIC KEY from an ECDSA signature + 32-byte message hash (SEC 1, §4.1.6).
702
+ * @param {Buffer} msgHash 32-byte hash that was signed
703
+ * @param {bigint} r signature r
704
+ * @param {bigint} s signature s
705
+ * @param {number} recId recovery id 0..3 (derived from v)
706
+ * @returns {{x: bigint, y: bigint}} the recovered public-key point
707
+ */
708
+ function recoverPublicKey(msgHash, r, s, recId) {
709
+ if (r <= 0n || r >= N) throw new Error("secp256k1: r out of range");
710
+ if (s <= 0n || s >= N) throw new Error("secp256k1: s out of range");
711
+ if (recId < 0 || recId > 3) throw new Error("secp256k1: invalid recovery id");
712
+
713
+ // x = r + (recId >> 1) * N (the high bit of recId says whether r overflowed the field by one order)
714
+ const x = r + (recId >> 1 ? N : 0n);
715
+ if (x >= P) throw new Error("secp256k1: recovered x not in field");
716
+
717
+ // R = point with x-coordinate x and y-parity = (recId & 1).
718
+ const R = liftX(x, recId & 1);
719
+
720
+ // Q = r^-1 (s*R - e*G), where e = msgHash mod N.
721
+ const e = mod(bufToBig(msgHash), N);
722
+ const rInv = invmod(r, N);
723
+ const sR = scalarMul(s, R);
724
+ const eG = scalarMul(e, G);
725
+ const negEG = isInf(eG) ? INF : [eG[0], mod(P - eG[1], P)];
726
+ const Q = scalarMul(rInv, pointAdd(sR, negEG));
727
+ if (isInf(Q)) throw new Error("secp256k1: recovered point at infinity");
728
+ return { x: Q[0], y: Q[1] };
729
+ }
730
+
731
+ /**
732
+ * Derive the lowercase 0x Ethereum address from a recovered public-key point.
733
+ * address = "0x" + last 20 bytes of keccak256( X(32) || Y(32) ).
734
+ */
735
+ function pubKeyToAddress(pub) {
736
+ const raw = Buffer.concat([bigTo32(pub.x), bigTo32(pub.y)]); // 64-byte uncompressed (no 0x04 prefix)
737
+ const hash = keccak256(raw);
738
+ return "0x" + hash.slice(12).toString("hex");
739
+ }
740
+
741
+ /**
742
+ * Build the EIP-191 personal_sign pre-image for a message and return its keccak256 digest.
743
+ *
744
+ * EIP-191 personal_sign: keccak256( "\x19Ethereum Signed Message:\n" + <decimal byte length> + <message> ),
745
+ * where <message> is the EXACT canonical UTF-8 bytes (here, the canonical attestation string including its
746
+ * single trailing newline). This reproduces, byte-for-byte, what `cli/core/attestation.js` documents and
747
+ * what ethers' personal_sign hashes.
748
+ *
749
+ * @param {Buffer|Uint8Array|string} message UTF-8 message (string is encoded as UTF-8)
750
+ * @returns {Buffer} the 32-byte EIP-191 digest
751
+ */
752
+ function eip191Hash(message) {
753
+ const msgBytes = Buffer.isBuffer(message)
754
+ ? message
755
+ : message instanceof Uint8Array
756
+ ? Buffer.from(message)
757
+ : Buffer.from(String(message), "utf8");
758
+ const prefix = Buffer.from("\x19Ethereum Signed Message:\n" + msgBytes.length, "utf8");
759
+ return keccak256(Buffer.concat([prefix, msgBytes]));
760
+ }
761
+
762
+ /**
763
+ * Recover the lowercase 0x signer ADDRESS from an `eip191-personal-sign` 65-byte (r||s||v) signature over
764
+ * `message`. INDEPENDENT of ethers/hardhat — only ./keccak (js-sha3) + the vendored secp256k1 above.
765
+ *
766
+ * @param {Buffer|Uint8Array|string} message the EXACT canonical UTF-8 bytes that were signed
767
+ * @param {string|Buffer|Uint8Array} signature 65-byte r(32)||s(32)||v(1), as 0x-hex or raw bytes
768
+ * @returns {string} the recovered signer address, 0x-prefixed lowercase
769
+ */
770
+ function recoverPersonalSignAddress(message, signature) {
771
+ const sig = normalizeSig(signature);
772
+ const r = bufToBig(sig.subarray(0, 32));
773
+ const s = bufToBig(sig.subarray(32, 64));
774
+ let v = sig[64];
775
+ // Accept v in {0,1} or {27,28} (and EIP-155-ish higher v reduced to parity). recId is v's low bit.
776
+ if (v >= 27) v -= 27;
777
+ if (v !== 0 && v !== 1) {
778
+ // Fall back to parity for any non-canonical encoding; reject only the wildly invalid.
779
+ v = v & 1;
780
+ }
781
+ const digest = eip191Hash(message);
782
+ const pub = recoverPublicKey(digest, r, s, v);
783
+ return pubKeyToAddress(pub);
784
+ }
785
+
786
+ function normalizeSig(signature) {
787
+ let buf;
788
+ if (Buffer.isBuffer(signature)) {
789
+ buf = signature;
790
+ } else if (signature instanceof Uint8Array) {
791
+ buf = Buffer.from(signature);
792
+ } else if (typeof signature === "string") {
793
+ const hex = signature.startsWith("0x") || signature.startsWith("0X") ? signature.slice(2) : signature;
794
+ if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) {
795
+ throw new Error("secp256k1: signature must be 0x-hex (even length)");
796
+ }
797
+ buf = Buffer.from(hex, "hex");
798
+ } else {
799
+ throw new TypeError("secp256k1: signature must be a 0x-hex string or byte buffer");
800
+ }
801
+ if (buf.length !== 65) {
802
+ throw new Error(`secp256k1: eip191-personal-sign signature must be 65 bytes (r||s||v), got ${buf.length}`);
803
+ }
804
+ return buf;
805
+ }
806
+
807
+ module.exports = {
808
+ recoverPersonalSignAddress,
809
+ eip191Hash,
810
+ recoverPublicKey,
811
+ pubKeyToAddress,
812
+ // exported for tests/audit:
813
+ _internal: { mod, invmod, powmod, sqrtmod, pointAdd, scalarMul, liftX, G, N, P },
814
+ };
815
+
816
+ };
817
+
818
+ // ===== module: revocation-core (from verifier/lib/revocation-core.js) =====
819
+ __modules["revocation-core"] = function (module, exports, __require) {
820
+ "use strict";
821
+
822
+ // verifier/lib/revocation-core.js — the PURE (I/O-FREE, fs-FREE, path-FREE, clock-FREE) half of the
823
+ // stack-free recipient-side KEY-REVOCATION reader + as-of decision (EPIC-51 / T-51.4, split out by T-66.1).
824
+ //
825
+ // WHY THIS FILE IS SEPARATE
826
+ // T-66.1 gives the independent verifier an IN-MEMORY file-source seam (`verifyArtifactFromBytes`) whose
827
+ // whole code path must be portable off Node (a browser page, a vm sandbox): NO `fs`, NO `path`, NO `os`,
828
+ // NO `process` may be reachable from it — statically, at module scope (the same discipline
829
+ // trustledger/lib/policy-bundled-loader.js established for the TrustLedger browser core). The revocation
830
+ // DECISION (validate + recover + classify + as-of fold) was always pure, but it lived in the same module
831
+ // as the FILE/DIR reader, which requires `fs` + `path` at module scope. This file is the decision, alone:
832
+ // every function here is a pure function of its arguments; requiring this module touches NOTHING impure.
833
+ // verifier/lib/revocation.js re-exports everything from here VERBATIM (the same function objects) and adds
834
+ // the two — and only two — fs-backed conveniences (`readRevocationsFromPath`, `loadAndApply`), so every
835
+ // existing caller keeps its exact surface and byte-identical behavior.
836
+ //
837
+ // EVERYTHING BELOW IS MOVED VERBATIM from verifier/lib/revocation.js (no semantic edit) — see that file's
838
+ // header for the full design rationale (the load-bearing self-control invariant, subject scoping, and the
839
+ // gate-for-gate parity with the producer stack's cli/core/trust-asof.js + cli/core/revocation.js).
840
+
841
+ const { recoverPersonalSignAddress } = __require("secp256k1-recover");
842
+
843
+ // ---------------------------------------------------------------------------
844
+ // On-disk discriminators + grammars — byte-identical to cli/core/revocation.js so a producer-minted
845
+ // revocation reads here verbatim.
846
+ // ---------------------------------------------------------------------------
847
+
848
+ const SIGNED_REVOCATION_KIND = "vh-key-revocation-signed";
849
+ const REVOCATION_KIND = "vh-key-revocation";
850
+
851
+ // The SCHEMA versions this build understands — byte-identical to cli/core/revocation.js's
852
+ // SUPPORTED_*_SCHEMA_VERSIONS. The producer REJECTS (IGNORES) a revocation whose container OR embedded
853
+ // payload carries an unsupported schemaVersion, so the verifier must too (parity).
854
+ const REVOCATION_SCHEMA_VERSION = 1;
855
+ const SUPPORTED_REVOCATION_SCHEMA_VERSIONS = Object.freeze([1]);
856
+ const SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS = Object.freeze([1]);
857
+
858
+ // The CLOSED reason set (the producer's REVOCATION_REASON_SET, sorted). An out-of-set reason marks the
859
+ // embedded revocation structurally malformed — the entry is IGNORED (never silently honored).
860
+ const REVOCATION_REASON_SET = Object.freeze(["compromised", "retired", "rotated", "superseded"]);
861
+
862
+ // The CLOSED field set of an UNSIGNED revocation payload — byte-identical to cli/core/revocation.js's
863
+ // REVOCATION_FIELDS. The producer HARD-rejects any extraneous/unknown key (validateRevocation), IGNORING
864
+ // the revocation; the verifier must enforce the SAME closed set so a smuggled extra field can never make
865
+ // the two stacks disagree (a self-signed-but-non-canonical revocation the producer ignores must be ignored
866
+ // here too). `supersededBy` is OPTIONAL but a member of the set.
867
+ const REVOCATION_FIELDS = Object.freeze([
868
+ "kind",
869
+ "schemaVersion",
870
+ "note",
871
+ "vendorAddress",
872
+ "reason",
873
+ "revokedAt",
874
+ "supersededBy",
875
+ ]);
876
+
877
+ // The standing in-band trust NOTES — copied VERBATIM from cli/core/revocation.js so the verifier pins the
878
+ // EXACT same `note` text the producer requires. The producer's validateRevocation requires the embedded
879
+ // payload's `note` to equal REVOCATION_TRUST_NOTE, and validateSignedAttestation requires the container's
880
+ // `note` to equal SIGNED_REVOCATION_TRUST_NOTE; a revocation with a wrong/absent note is IGNORED by the
881
+ // producer, so the verifier must ignore it too (parity). These strings are LOAD-BEARING for parity, not
882
+ // for security (the signature binds the bytes regardless) — they must never drift from the producer's.
883
+ const REVOCATION_TRUST_NOTE =
884
+ "This is a verifyhash producer KEY REVOCATION: the holder of `vendorAddress`'s key SIGNED it, declaring " +
885
+ "that address REVOKED as of `revokedAt` for `reason` (optionally superseded by `supersededBy`). verify " +
886
+ "RE-DERIVES the signer from these exact bytes and REQUIRES it to equal `vendorAddress` — a key revokes " +
887
+ "ITSELF; a third party cannot revoke a key it does not control. It proves the KEY-HOLDER's SIGNED CLAIM " +
888
+ 'ONLY: `revokedAt` is the holder\'s self-asserted instant, NOT a trusted TIMESTAMP (it rides the human-' +
889
+ "owned timestamp trust-root, STRATEGY.md P-3), and this is NOT a legal opinion.";
890
+
891
+ const SIGNED_REVOCATION_TRUST_NOTE =
892
+ "This is a SIGNED verifyhash key-revocation container: it WRAPS (never edits) the EXACT canonical " +
893
+ "revocation bytes in `attestation` and attaches a detached EIP-191 signature. verifyRevocation " +
894
+ "RE-DERIVES the signer from those bytes and pins it to the embedded `vendorAddress` — it never trusts " +
895
+ "the file's own claims. Every caveat of the embedded revocation applies. " +
896
+ REVOCATION_TRUST_NOTE;
897
+
898
+ // A claimed 0x-address INSIDE the payload: 0x + 40 LOWERCASE hex (byte-determinism — mixed-case rejected).
899
+ const PAYLOAD_ADDRESS_RE = /^0x[0-9a-f]{40}$/;
900
+ // A recovered/expected address (the verifier lowercases everything it compares).
901
+ const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
902
+ // A 65-byte (r||s||v) signature as 0x-hex — LOWERCASE-only, byte-for-byte the producer's EIP191_SIG_RE
903
+ // (cli/core/attestation.js). The producer REJECTS mixed/upper-case hex for byte-determinism and IGNORES a
904
+ // revocation carrying it; accepting mixed case here would let a third party re-encode a holder's genuine
905
+ // revocation into one the producer drops but the verifier honors — a parity split with NO key required. So
906
+ // the verifier pins the SAME lowercase grammar.
907
+ const SIGNATURE_RE = /^0x[0-9a-f]{130}$/;
908
+ // A strict, CANONICAL ISO-8601 UTC instant — the SAME grammar revokedAt / asOf are pinned to.
909
+ const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
910
+
911
+ // The recovered-signer sentinel the producer core returns for an unrecoverable signature.
912
+ const UNRECOVERABLE = "(unrecoverable)";
913
+
914
+ // A dedicated error type for the HARD input errors of THIS helper (a malformed asOf, a non-JSON/wrong-type
915
+ // revocations input, an unreadable path). An individual BOGUS revocation is NEVER thrown — it is collected as
916
+ // an ignored warning so one bad entry can never abort the evaluation of the good ones.
917
+ class RevocationReadError extends Error {
918
+ constructor(message) {
919
+ super(message);
920
+ this.name = "RevocationReadError";
921
+ }
922
+ }
923
+
924
+ function isPlainObject(v) {
925
+ return v != null && typeof v === "object" && !Array.isArray(v);
926
+ }
927
+
928
+ // ---------------------------------------------------------------------------
929
+ // Canonical-instant parsing (asOf + revokedAt share this).
930
+ // ---------------------------------------------------------------------------
931
+
932
+ function parseCanonicalInstant(value, label) {
933
+ if (typeof value !== "string" || !ISO_INSTANT_RE.test(value)) {
934
+ throw new RevocationReadError(
935
+ `${label} must be an ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"), got: ${String(value)}`
936
+ );
937
+ }
938
+ const ms = Date.parse(value);
939
+ if (Number.isNaN(ms) || new Date(ms).toISOString() !== value) {
940
+ throw new RevocationReadError(
941
+ `${label} must be a canonical ISO-8601 UTC instant (no rolled-over/impossible fields), got: ${String(value)}`
942
+ );
943
+ }
944
+ return ms;
945
+ }
946
+
947
+ /**
948
+ * Resolve the effective `--as-of` instant. PURE. When the recipient supplied one, validate + use it; when
949
+ * they did not, default to the recipient's CURRENT decision time (`nowISO`, injectable for tests). A
950
+ * malformed explicit `--as-of` is a HARD RevocationReadError (never silently coerced to now). Mirrors
951
+ * cli/core/trust-asof.js resolveAsOf.
952
+ * @param {string|undefined|null} asOf
953
+ * @param {string} nowISO the recipient's current instant (ISO-8601 UTC)
954
+ * @returns {{ asOf: string, defaulted: boolean }}
955
+ */
956
+ function resolveAsOf(asOf, nowISO) {
957
+ if (asOf !== undefined && asOf !== null && asOf !== "") {
958
+ parseCanonicalInstant(asOf, "--as-of"); // validate shape; throws on malformed
959
+ return { asOf, defaulted: false };
960
+ }
961
+ if (typeof nowISO !== "string") {
962
+ throw new RevocationReadError("resolveAsOf requires a nowISO instant when --as-of is not given");
963
+ }
964
+ parseCanonicalInstant(nowISO, "nowISO"); // the injected/default now must itself be canonical
965
+ return { asOf: nowISO, defaulted: true };
966
+ }
967
+
968
+ /**
969
+ * Serialize a validated UNSIGNED revocation payload to its CANONICAL, byte-deterministic bytes — a FIXED key
970
+ * order, NO insignificant whitespace, a single trailing newline. This is a LINE-FOR-LINE port of
971
+ * cli/core/revocation.js serializeRevocation: the FIXED field order (kind, schemaVersion, note,
972
+ * vendorAddress, reason, revokedAt) with `supersededBy` appended LAST and ONLY when present. It lets the
973
+ * verifier perform the producer's canonical-bytes BINDING check (`attestation === serializeRevocation(...)`)
974
+ * with NO ethers — the producer's whole point that two logically-identical revocations serialize identically.
975
+ * @param {object} payload an already-structurally-validated revocation payload
976
+ * @returns {string} the canonical serialization (newline-terminated)
977
+ */
978
+ function serializeRevocation(payload) {
979
+ const canonical = {
980
+ kind: payload.kind,
981
+ schemaVersion: payload.schemaVersion,
982
+ note: payload.note,
983
+ vendorAddress: payload.vendorAddress,
984
+ reason: payload.reason,
985
+ revokedAt: payload.revokedAt,
986
+ };
987
+ if (Object.prototype.hasOwnProperty.call(payload, "supersededBy") && payload.supersededBy !== undefined) {
988
+ canonical.supersededBy = payload.supersededBy;
989
+ }
990
+ return JSON.stringify(canonical) + "\n";
991
+ }
992
+
993
+ // ---------------------------------------------------------------------------
994
+ // Structural validation of a parsed SIGNED revocation container + its embedded payload. A structurally
995
+ // invalid container is REJECTED by THROWING (the caller catches + IGNORES it with a warning) — never
996
+ // half-accepted.
997
+ //
998
+ // PARITY-CRITICAL: this MIRRORS the producer's cli/core/attestation.js validateSignedAttestation +
999
+ // cli/core/revocation.js validateRevocation so the two stacks IGNORE the EXACT same malformed-but-self-signed
1000
+ // revocations. The producer's verdict-gating structural checks the verifier MUST replicate (or the offline
1001
+ // path reaches REVOKED where the producer reaches OK on identical inputs) are:
1002
+ // - the CONTAINER carries the right kind, a SUPPORTED schemaVersion, and the standing SIGNED note;
1003
+ // - the signature block has a known scheme, a 65-byte LOWERCASE-hex signature, and a lowercase signer;
1004
+ // - the EMBEDDED payload re-validates as a sound UNSIGNED revocation: a CLOSED field set (no extra/unknown
1005
+ // key), the right kind, a SUPPORTED schemaVersion, the standing UNSIGNED note, a lowercase vendorAddress,
1006
+ // a closed-set reason, a canonical revokedAt, an optional lowercase supersededBy; AND
1007
+ // - the WRAP-DON'T-EDIT binding: the embedded `attestation` STRING is byte-for-byte the canonical
1008
+ // re-serialization of the embedded payload (so a non-canonical / reordered / whitespace variant is
1009
+ // IGNORED, exactly as the producer ignores it).
1010
+ // The signature recovery (verifyRevocation) is what makes a revocation SAFE against forgers; these structural
1011
+ // checks are what make the verifier's VERDICT EQUAL the producer's on every malformed input it sees.
1012
+ // ---------------------------------------------------------------------------
1013
+
1014
+ function validateSignedRevocation(obj) {
1015
+ if (!isPlainObject(obj)) {
1016
+ throw new RevocationReadError("revocation container must be a JSON object");
1017
+ }
1018
+ if (obj.kind !== SIGNED_REVOCATION_KIND) {
1019
+ throw new RevocationReadError(
1020
+ `not a signed key-revocation (kind ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(SIGNED_REVOCATION_KIND)})`
1021
+ );
1022
+ }
1023
+ // The CONTAINER schemaVersion must be supported (the producer's validateSignedAttestation rejects an
1024
+ // unsupported one before any recovery — so the verifier must too).
1025
+ if (!SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
1026
+ throw new RevocationReadError(
1027
+ `unsupported signed revocation schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
1028
+ `(this build understands ${JSON.stringify(SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS)})`
1029
+ );
1030
+ }
1031
+ // The CONTAINER note must be the standing SIGNED note (the producer pins it; a drifted note is IGNORED).
1032
+ if (obj.note !== SIGNED_REVOCATION_TRUST_NOTE) {
1033
+ throw new RevocationReadError("signed revocation `note` must be the standing SIGNED_REVOCATION_TRUST_NOTE");
1034
+ }
1035
+ if (typeof obj.attestation !== "string") {
1036
+ throw new RevocationReadError("signed revocation must embed the canonical UNSIGNED bytes as a string `attestation`");
1037
+ }
1038
+ const sig = obj.signature;
1039
+ if (!isPlainObject(sig)) {
1040
+ throw new RevocationReadError("signed revocation is missing a { scheme, signer, signature } signature block");
1041
+ }
1042
+ if (sig.scheme !== "eip191-personal-sign") {
1043
+ throw new RevocationReadError(
1044
+ `unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
1045
+ );
1046
+ }
1047
+ // LOWERCASE-only signer + signature, byte-for-byte the producer's ADDRESS_RE / EIP191_SIG_RE — a
1048
+ // mixed/upper-case re-encoding is IGNORED by the producer, so it must be ignored here too.
1049
+ if (typeof sig.signer !== "string" || !PAYLOAD_ADDRESS_RE.test(sig.signer)) {
1050
+ throw new RevocationReadError(
1051
+ "signed revocation signer must be a 0x-prefixed 20-byte LOWERCASE-hex address (mixed/upper case rejected for byte-determinism)"
1052
+ );
1053
+ }
1054
+ if (typeof sig.signature !== "string" || !SIGNATURE_RE.test(sig.signature)) {
1055
+ throw new RevocationReadError(
1056
+ "signed revocation signature must be a 65-byte (r||s||v) 0x-prefixed LOWERCASE-hex string (mixed/upper case rejected for byte-determinism)"
1057
+ );
1058
+ }
1059
+
1060
+ // Parse + strictly validate the embedded revocation payload (the producer's validateRevocation, mirrored).
1061
+ let rev;
1062
+ try {
1063
+ rev = JSON.parse(obj.attestation);
1064
+ } catch (e) {
1065
+ throw new RevocationReadError(`embedded revocation is not valid JSON: ${e.message}`);
1066
+ }
1067
+ if (!isPlainObject(rev)) {
1068
+ throw new RevocationReadError("embedded revocation payload must be a JSON object");
1069
+ }
1070
+ // CLOSED FIELD SET: an unknown/extraneous key is a HARD reject (the producer IGNORES such a revocation).
1071
+ for (const key of Object.keys(rev)) {
1072
+ if (!REVOCATION_FIELDS.includes(key)) {
1073
+ throw new RevocationReadError(
1074
+ `revocation has an unknown field: ${JSON.stringify(key)} (the closed field set is ${JSON.stringify(REVOCATION_FIELDS)})`
1075
+ );
1076
+ }
1077
+ }
1078
+ if (rev.kind !== REVOCATION_KIND) {
1079
+ throw new RevocationReadError(
1080
+ `embedded payload is not a key revocation (kind ${JSON.stringify(rev.kind)}; expected ${JSON.stringify(REVOCATION_KIND)})`
1081
+ );
1082
+ }
1083
+ if (!SUPPORTED_REVOCATION_SCHEMA_VERSIONS.includes(rev.schemaVersion)) {
1084
+ throw new RevocationReadError(
1085
+ `unsupported revocation schemaVersion: ${JSON.stringify(rev.schemaVersion)} ` +
1086
+ `(this build understands ${JSON.stringify(SUPPORTED_REVOCATION_SCHEMA_VERSIONS)})`
1087
+ );
1088
+ }
1089
+ if (rev.note !== REVOCATION_TRUST_NOTE) {
1090
+ throw new RevocationReadError("revocation `note` must be the standing REVOCATION_TRUST_NOTE (caveat must not drift)");
1091
+ }
1092
+ if (typeof rev.vendorAddress !== "string" || !PAYLOAD_ADDRESS_RE.test(rev.vendorAddress)) {
1093
+ throw new RevocationReadError(
1094
+ `revocation vendorAddress must be a 0x-prefixed 20-byte LOWERCASE-hex address, got: ${String(rev.vendorAddress)}`
1095
+ );
1096
+ }
1097
+ if (typeof rev.reason !== "string" || !REVOCATION_REASON_SET.includes(rev.reason)) {
1098
+ throw new RevocationReadError(
1099
+ `revocation reason must be one of ${JSON.stringify(REVOCATION_REASON_SET)}, got: ${JSON.stringify(rev.reason)}`
1100
+ );
1101
+ }
1102
+ parseCanonicalInstant(rev.revokedAt, "revocation revokedAt"); // throws on a non-canonical instant
1103
+ if (
1104
+ Object.prototype.hasOwnProperty.call(rev, "supersededBy") &&
1105
+ rev.supersededBy !== undefined &&
1106
+ (typeof rev.supersededBy !== "string" || !PAYLOAD_ADDRESS_RE.test(rev.supersededBy))
1107
+ ) {
1108
+ throw new RevocationReadError(
1109
+ `revocation supersededBy, when present, must be a 0x-prefixed 20-byte LOWERCASE-hex address, got: ${String(rev.supersededBy)}`
1110
+ );
1111
+ }
1112
+ // WRAP-DON'T-EDIT BINDING (the producer's `obj.attestation !== cfg.serializeUnsigned(embedded)` gate). The
1113
+ // embedded STRING must be byte-for-byte the canonical re-serialization of the embedded payload — so a
1114
+ // reordered-keys / extra-whitespace / otherwise non-canonical (but genuinely self-signed) variant is
1115
+ // IGNORED here exactly as the producer ignores it. THIS is the check that closes the headline parity gap.
1116
+ if (obj.attestation !== serializeRevocation(rev)) {
1117
+ throw new RevocationReadError(
1118
+ "embedded revocation is not in canonical form (the signed-over bytes must be byte-for-byte the canonical serialization)"
1119
+ );
1120
+ }
1121
+ return { container: obj, revocation: rev };
1122
+ }
1123
+
1124
+ /**
1125
+ * Verify (purely, OFFLINE) a parsed SIGNED revocation container — the STACK-FREE mirror of the producer's
1126
+ * verifyRevocation. It recovers the signer from the embedded canonical bytes + signature and:
1127
+ * (1) confirms it equals the container's CLAIMED `signer` (signatureMatchesSigner — ALWAYS run);
1128
+ * (2) confirms it equals the revocation's OWN embedded `vendorAddress` (vendorAddressMatchesSigner — the
1129
+ * load-bearing SELF-CONTROL check: a key revokes ITSELF).
1130
+ * The verdict is ACCEPTED only when BOTH pass; a forged/tampered/third-party revocation is a clean REJECTED.
1131
+ * A structurally invalid container THROWS (RevocationReadError) before any recovery, so an ordinary REJECTED
1132
+ * verdict only ever describes a STRUCTURALLY SOUND revocation whose signature simply doesn't back its claims.
1133
+ *
1134
+ * @param {object} container a parsed signed-revocation container object
1135
+ * @returns {{ accepted, recoveredSigner, claimedSigner, vendorAddress, reason, revokedAt, supersededBy, failedChecks }}
1136
+ */
1137
+ function verifyRevocation(container) {
1138
+ const { revocation } = validateSignedRevocation(container);
1139
+ const claimedSigner = container.signature.signer.toLowerCase();
1140
+ const vendorAddress = revocation.vendorAddress;
1141
+
1142
+ // Recover the signer from the EXACT embedded bytes. A tampered/corrupt signature can be UNRECOVERABLE (no
1143
+ // valid curve point) — that throws; we map it to the "(unrecoverable)" sentinel, never a crash, mirroring
1144
+ // the producer core's catch.
1145
+ let recoveredSigner;
1146
+ try {
1147
+ recoveredSigner = recoverPersonalSignAddress(container.attestation, container.signature.signature);
1148
+ } catch (_) {
1149
+ recoveredSigner = UNRECOVERABLE;
1150
+ }
1151
+
1152
+ const signatureMatchesSigner = recoveredSigner === claimedSigner;
1153
+ const vendorAddressMatchesSigner = recoveredSigner === vendorAddress;
1154
+
1155
+ const failedChecks = [];
1156
+ if (!signatureMatchesSigner) failedChecks.push("signatureMatchesSigner");
1157
+ if (!vendorAddressMatchesSigner) failedChecks.push("vendorAddressMatchesSigner");
1158
+
1159
+ return {
1160
+ accepted: failedChecks.length === 0,
1161
+ recoveredSigner,
1162
+ claimedSigner,
1163
+ vendorAddress,
1164
+ reason: revocation.reason,
1165
+ revokedAt: revocation.revokedAt,
1166
+ supersededBy: Object.prototype.hasOwnProperty.call(revocation, "supersededBy")
1167
+ ? revocation.supersededBy
1168
+ : null,
1169
+ failedChecks,
1170
+ };
1171
+ }
1172
+
1173
+ // ---------------------------------------------------------------------------
1174
+ // Normalize the `revocations` input into a flat array of entries to evaluate. PURE. Accepts an ARRAY of
1175
+ // already-parsed containers (or JSON strings), a single container object, or a JSON STRING of either (a
1176
+ // bundle file is a JSON ARRAY of containers, or a single container object). A per-entry parse failure becomes
1177
+ // a `_parseError` marker (IGNORED with a warning); a WHOLE-input parse failure HARD-errors. Mirrors
1178
+ // cli/core/trust-asof.js normalizeRevocationsInput.
1179
+ // ---------------------------------------------------------------------------
1180
+
1181
+ function normalizeRevocationsInput(revocations) {
1182
+ if (typeof revocations === "string") {
1183
+ let parsed;
1184
+ try {
1185
+ parsed = JSON.parse(revocations);
1186
+ } catch (e) {
1187
+ throw new RevocationReadError(`revocations input is not valid JSON: ${e.message}`);
1188
+ }
1189
+ return normalizeRevocationsInput(parsed);
1190
+ }
1191
+ if (Array.isArray(revocations)) {
1192
+ return revocations.map((el) => {
1193
+ if (typeof el === "string") {
1194
+ try {
1195
+ return JSON.parse(el);
1196
+ } catch (e) {
1197
+ return { _parseError: `entry is not valid JSON: ${e.message}`, _raw: el };
1198
+ }
1199
+ }
1200
+ return el;
1201
+ });
1202
+ }
1203
+ if (isPlainObject(revocations)) {
1204
+ return [revocations];
1205
+ }
1206
+ throw new RevocationReadError(
1207
+ "revocations input must be a signed-revocation container, an array of them, or JSON text of either"
1208
+ );
1209
+ }
1210
+
1211
+ // Classify ONE already-parsed revocation entry against the subject + as-of pivot. PURE. Mirrors
1212
+ // cli/core/trust-asof.js classifyRevocation exactly (the same `applies`/`later`/`irrelevant`/`ignored`
1213
+ // outcomes + the inclusive `revokedAt <= asOf` boundary).
1214
+ function classifyRevocation(entry, subject, asOfMs) {
1215
+ if (entry && entry._parseError) {
1216
+ return { kind: "ignored", warning: `ignored an unparseable revocation entry (${entry._parseError})` };
1217
+ }
1218
+ let v;
1219
+ try {
1220
+ v = verifyRevocation(entry);
1221
+ } catch (e) {
1222
+ return { kind: "ignored", warning: `ignored a malformed/foreign revocation (${e.message})` };
1223
+ }
1224
+ if (!v.accepted) {
1225
+ return {
1226
+ kind: "ignored",
1227
+ warning:
1228
+ `ignored a revocation that does not verify (failed: ${v.failedChecks.join(", ")}; ` +
1229
+ `vendorAddress ${v.vendorAddress}) — a forged/tampered/third-party revocation never downgrades trust`,
1230
+ };
1231
+ }
1232
+ if (v.vendorAddress !== subject) {
1233
+ return { kind: "irrelevant", vendorAddress: v.vendorAddress };
1234
+ }
1235
+ const revokedAtMs = Date.parse(v.revokedAt);
1236
+ const detail = {
1237
+ vendorAddress: v.vendorAddress,
1238
+ reason: v.reason,
1239
+ revokedAt: v.revokedAt,
1240
+ supersededBy: v.supersededBy,
1241
+ };
1242
+ // Inclusive on the revoked side: a revocation effective EXACTLY at the as-of instant counts as revoked.
1243
+ if (revokedAtMs <= asOfMs) {
1244
+ return { kind: "applies", ...detail };
1245
+ }
1246
+ return { kind: "later", ...detail };
1247
+ }
1248
+
1249
+ /**
1250
+ * THE RECIPIENT-SIDE TRUST-DECISION-AS-OF. PURE / OFFLINE / KEY-FREE / I/O-FREE / CLOCK-FREE. Identical
1251
+ * semantics to cli/core/trust-asof.js evaluateTrustAsOf (so verify-vh's downgrade matches the producer's
1252
+ * byte-for-byte). Returns a stable decision block.
1253
+ * @param {object} params { subject, asOf, revocations }
1254
+ * @returns {{ status, revoked, subject, asOf, governing, laterRevoked, counts, ignored }}
1255
+ */
1256
+ function evaluateTrustAsOf(params) {
1257
+ if (!isPlainObject(params)) {
1258
+ throw new RevocationReadError("evaluateTrustAsOf requires { subject, asOf, revocations }");
1259
+ }
1260
+ const { subject, asOf, revocations } = params;
1261
+ if (typeof subject !== "string" || subject.length === 0) {
1262
+ throw new RevocationReadError("evaluateTrustAsOf requires a string `subject` (the artifact's recovered signer)");
1263
+ }
1264
+ const asOfMs = parseCanonicalInstant(asOf, "--as-of");
1265
+ const entries = normalizeRevocationsInput(revocations);
1266
+
1267
+ // A non-address subject (the "(unrecoverable)" sentinel) cannot be matched by any revocation — still
1268
+ // evaluate every entry (so forged ones are reported as ignored), but no SOUND revocation can apply.
1269
+ const subjectIsAddress = ADDRESS_RE.test(subject);
1270
+
1271
+ const applicable = [];
1272
+ const later = [];
1273
+ let irrelevant = 0;
1274
+ const ignored = [];
1275
+
1276
+ for (const entry of entries) {
1277
+ const c = classifyRevocation(entry, subject, asOfMs);
1278
+ if (c.kind === "ignored") ignored.push(c.warning);
1279
+ else if (c.kind === "irrelevant") irrelevant += 1;
1280
+ else if (c.kind === "later") later.push(c);
1281
+ else if (c.kind === "applies") applicable.push(c);
1282
+ }
1283
+
1284
+ // The GOVERNING revocation is the EARLIEST applicable one (smallest revokedAt), tie-broken deterministically
1285
+ // on vendorAddress then reason — the instant from which the key was no longer trustworthy.
1286
+ const sortByEffective = (a, b) =>
1287
+ Date.parse(a.revokedAt) - Date.parse(b.revokedAt) ||
1288
+ (a.vendorAddress < b.vendorAddress ? -1 : a.vendorAddress > b.vendorAddress ? 1 : 0) ||
1289
+ (a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
1290
+
1291
+ const govern = (arr) => {
1292
+ if (arr.length === 0) return null;
1293
+ const [g] = arr.slice().sort(sortByEffective);
1294
+ return { vendorAddress: g.vendorAddress, reason: g.reason, revokedAt: g.revokedAt, supersededBy: g.supersededBy };
1295
+ };
1296
+
1297
+ const governing = govern(applicable);
1298
+ const laterRevoked = governing ? null : govern(later);
1299
+
1300
+ let status;
1301
+ if (governing) status = "REVOKED";
1302
+ else if (!subjectIsAddress) status = "UNEVALUABLE";
1303
+ else status = "OK";
1304
+
1305
+ return {
1306
+ status,
1307
+ revoked: status === "REVOKED",
1308
+ subject,
1309
+ asOf,
1310
+ governing,
1311
+ laterRevoked,
1312
+ counts: {
1313
+ total: entries.length,
1314
+ applicable: applicable.length,
1315
+ later: later.length,
1316
+ irrelevant,
1317
+ ignored: ignored.length,
1318
+ },
1319
+ ignored,
1320
+ };
1321
+ }
1322
+
1323
+ /**
1324
+ * Fold a TRUST-DECISION-AS-OF onto an existing verify-vh result, OFFLINE. PURE. Mirrors cli/core/trust-asof.js
1325
+ * applyToVerifyResult: it NEVER upgrades a verdict — an already-REJECTED artifact stays rejected; the
1326
+ * trust-as-of only ever ADDS a REVOKED downgrade on top of an otherwise-ACCEPTED artifact. Returns a NEW
1327
+ * result object (the original is not mutated): the original fields PLUS `trustAsOf`, with accepted/verdict/
1328
+ * reason updated when REVOKED.
1329
+ *
1330
+ * The `subject` is the artifact's RECOVERED signer. When the signature did not even recover (the
1331
+ * "(unrecoverable)" sentinel / a null), no revocation can bind — the decision is UNEVALUABLE and never
1332
+ * changes the (already-rejected) verdict.
1333
+ *
1334
+ * @param {object} params { result, revocations, asOf }
1335
+ * @returns {object} a new result with `trustAsOf` attached
1336
+ */
1337
+ function applyToVerifyResult(params) {
1338
+ if (!isPlainObject(params) || !isPlainObject(params.result)) {
1339
+ throw new RevocationReadError("applyToVerifyResult requires { result, revocations, asOf }");
1340
+ }
1341
+ const { result, revocations, asOf } = params;
1342
+ // The subject is the recovered signer. verify-vh leaves recoveredSigner null for an UNSIGNED artifact and
1343
+ // sets the "(unrecoverable)" sentinel for a broken signature — both are non-addresses, so neither binds.
1344
+ const subject =
1345
+ typeof result.recoveredSigner === "string" && result.recoveredSigner.length > 0
1346
+ ? result.recoveredSigner
1347
+ : UNRECOVERABLE;
1348
+
1349
+ const decision = evaluateTrustAsOf({ subject, asOf, revocations });
1350
+ const out = { ...result, trustAsOf: decision };
1351
+
1352
+ if (decision.revoked) {
1353
+ // The ONLY downgrading path: an otherwise-ACCEPTED artifact whose signer was revoked-before-as-of becomes
1354
+ // REVOKED (exit 3). We flip accepted=false + set a distinct REVOKED verdict + a named reason so the
1355
+ // existing `accepted ? 0 : 3` exit mapping yields exit 3, byte-for-byte with the producer.
1356
+ out.accepted = false;
1357
+ out.verdict = "REVOKED";
1358
+ out.reason = "key_revoked_as_of";
1359
+ }
1360
+ return out;
1361
+ }
1362
+
1363
+ /**
1364
+ * Render the human-readable TRUST-DECISION-AS-OF lines verify-vh appends to its report. PURE. Returns an
1365
+ * array of lines. Mirrors cli/core/trust-asof.js renderTrustAsOf's content so the two stacks read the same.
1366
+ * @param {object} decision the object evaluateTrustAsOf returns
1367
+ * @param {{ defaulted?: boolean, indent?: string }} [ctx]
1368
+ * @returns {string[]} lines
1369
+ */
1370
+ function renderTrustAsOf(decision, ctx = {}) {
1371
+ const I = ctx.indent || "";
1372
+ const L = [];
1373
+ const asOfNote = ctx.defaulted ? " (defaulted to now; pass --as-of <ISO> to pin the decision instant)" : "";
1374
+ L.push(`${I}revocation check (as of ${decision.asOf})${asOfNote}:`);
1375
+ if (decision.status === "REVOKED") {
1376
+ const g = decision.governing;
1377
+ L.push(
1378
+ `${I} [REVOKED] the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
1379
+ `(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
1380
+ `the as-of instant. This artifact is NOT trustworthy as of ${decision.asOf}.`
1381
+ );
1382
+ } else if (decision.status === "UNEVALUABLE") {
1383
+ L.push(`${I} [skip] the signature did not recover to a key — no subject to evaluate revocations against.`);
1384
+ } else {
1385
+ L.push(`${I} [OK] no applicable revocation: the signing key was not revoked as of ${decision.asOf}.`);
1386
+ if (decision.laterRevoked) {
1387
+ const lr = decision.laterRevoked;
1388
+ L.push(
1389
+ `${I} [note] this key (${lr.vendorAddress}) IS revoked as of ${lr.revokedAt} ` +
1390
+ `(reason: ${lr.reason})${lr.supersededBy ? `, superseded by ${lr.supersededBy}` : ""} — AFTER your ` +
1391
+ `as-of instant, so it does NOT downgrade THIS decision (informational).`
1392
+ );
1393
+ }
1394
+ }
1395
+ for (const w of decision.ignored) {
1396
+ L.push(`${I} [warning] ${w}`);
1397
+ }
1398
+ return L;
1399
+ }
1400
+
1401
+ module.exports = {
1402
+ RevocationReadError,
1403
+ SIGNED_REVOCATION_KIND,
1404
+ REVOCATION_KIND,
1405
+ REVOCATION_SCHEMA_VERSION,
1406
+ SUPPORTED_REVOCATION_SCHEMA_VERSIONS,
1407
+ SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS,
1408
+ REVOCATION_REASON_SET,
1409
+ REVOCATION_FIELDS,
1410
+ REVOCATION_TRUST_NOTE,
1411
+ SIGNED_REVOCATION_TRUST_NOTE,
1412
+ ISO_INSTANT_RE,
1413
+ UNRECOVERABLE,
1414
+ isPlainObject,
1415
+ parseCanonicalInstant,
1416
+ resolveAsOf,
1417
+ serializeRevocation,
1418
+ validateSignedRevocation,
1419
+ verifyRevocation,
1420
+ normalizeRevocationsInput,
1421
+ classifyRevocation,
1422
+ evaluateTrustAsOf,
1423
+ applyToVerifyResult,
1424
+ renderTrustAsOf,
1425
+ };
1426
+
1427
+ };
1428
+
1429
+ // ===== module: revocation (from verifier/lib/revocation.js) =====
1430
+ __modules["revocation"] = function (module, exports, __require) {
1431
+ "use strict";
1432
+
1433
+ // verifier/lib/revocation.js — the STACK-FREE recipient-side KEY-REVOCATION reader + as-of decision for the
1434
+ // INDEPENDENT verifier (EPIC-51 / T-51.4).
1435
+ //
1436
+ // WHY THIS EXISTS
1437
+ // The producer stack already lets a recipient downgrade an otherwise-ACCEPTED signed artifact to REVOKED
1438
+ // when the signing key was revoked-before-the-as-of-instant (`vh ... verify-signed --revocations <f>
1439
+ // --as-of <T>`, cli/core/trust-asof.js + cli/core/revocation.js). The OFFLINE, no-producer-stack verifier
1440
+ // (`verify-vh`) did NOT — so a counterparty who only holds the single-file verifier reached a DIFFERENT
1441
+ // verdict than the producer on the SAME inputs (a clean ACCEPTED where the producer returned REVOKED).
1442
+ // This module closes that gap WITHOUT pulling in ethers/hardhat or back-edging into cli/: it RE-IMPLEMENTS
1443
+ // the revocation soundness check (EIP-191 signer recovery + the load-bearing SELF-CONTROL invariant) and
1444
+ // the as-of trust decision using ONLY the verifier's own pure-JS crypto (./secp256k1-recover, ./keccak).
1445
+ //
1446
+ // MODULE LAYOUT (T-66.1 split — surface + behavior UNCHANGED)
1447
+ // Every PURE piece of this module — the discriminators/grammars/trust notes, the structural validation, the
1448
+ // signer recovery, the as-of classification/decision, the verify-result fold, the renderer — lives in
1449
+ // ./revocation-core.js (which requires NO fs/path/os, so the verifier's IN-MEMORY bytes path can reach it
1450
+ // without ANY impure builtin on its require graph). This file re-exports ALL of it VERBATIM (the very same
1451
+ // function objects) and adds the two — and only two — fs-backed conveniences: `readRevocationsFromPath`
1452
+ // (the --revocations <file-or-dir> reader) and `loadAndApply` (the one-call CLI integration). Every
1453
+ // existing caller keeps its exact import surface; on identical inputs every function is byte-identical.
1454
+ //
1455
+ // THE LOAD-BEARING SAFETY INVARIANT — A REVOCATION CAN ONLY EVER REMOVE TRUST, NEVER ADD IT.
1456
+ // Every revocation statement is verified the SAME way the producer core does: it must (1) recover to its
1457
+ // own claimed `signer` AND (2) recover to its own embedded `vendorAddress` (a key revokes ITSELF). A
1458
+ // revocation that fails EITHER check — forged, tampered, third-party, structurally malformed, or simply
1459
+ // not parseable — is IGNORED with a WARNING and can NEVER downgrade the verdict. So a planted "revocation"
1460
+ // for a victim's key cannot grief a recipient into rejecting a perfectly good artifact.
1461
+ //
1462
+ // SUBJECT-SCOPING — A REVOCATION ONLY BITES THE KEY IT NAMES.
1463
+ // The `subject` is the artifact's RECOVERED signer (the address verify-vh derived from the bytes). A
1464
+ // revocation only affects the verdict when its `vendorAddress` EQUALS that subject; a revocation for some
1465
+ // OTHER key is `irrelevant`, never a downgrade.
1466
+ //
1467
+ // PURE + I/O-FREE on the decision; the FILE/DIR READ is the only I/O (readRevocationsFromPath), kept here so
1468
+ // verify-vh stays a thin wiring layer. No network, no key, no clock (the `asOf` instant is caller-supplied).
1469
+ //
1470
+ // PARITY WITH THE PRODUCER STACK
1471
+ // The decision semantics (applies / later / irrelevant / ignored; the inclusive `revokedAt <= asOf`
1472
+ // boundary; the EARLIEST-applicable governing record; the later-revoked informational note) mirror
1473
+ // cli/core/trust-asof.js, and the STRUCTURAL validation (validateSignedRevocation in ./revocation-core.js)
1474
+ // mirrors the producer's cli/core/attestation.js validateSignedAttestation + cli/core/revocation.js
1475
+ // validateRevocation gate-for-gate: the closed embedded field set, the supported schemaVersion (container
1476
+ // AND payload), the standing trust NOTES, the lowercase-only signature/address grammar, and the
1477
+ // WRAP-DON'T-EDIT canonical re-serialization binding. A revocation the producer IGNORES (a non-canonical /
1478
+ // extra-field / wrong-note / unsupported-schemaVersion / mixed-case-hex but genuinely self-signed one) is
1479
+ // therefore IGNORED here too — so on identical inputs verify-vh's verdict + exit code match
1480
+ // `vh ... verify-signed --revocations` byte-for-byte, for the SOUND inputs AND the malformed ones.
1481
+ // test/verifier.revocation.test.js pins that parity against the REAL producer core, including the
1482
+ // malformed-but-self-signed (NEGATIVE-parity) classes.
1483
+
1484
+ const fs = require("fs");
1485
+ const path = require("path");
1486
+
1487
+ const core = __require("revocation-core");
1488
+
1489
+ const { RevocationReadError, isPlainObject, resolveAsOf, normalizeRevocationsInput, applyToVerifyResult } = core;
1490
+
1491
+ // ---------------------------------------------------------------------------
1492
+ // FILE-OR-DIR reader. The ONLY I/O in this module. A counterparty may hand the verifier a SINGLE revocation
1493
+ // file (one container, or a JSON array of them) OR a DIRECTORY of revocation files — a vendor commonly
1494
+ // publishes one file per revoked key. Reading a directory aggregates every entry into ONE array of parsed
1495
+ // containers, so the as-of decision sees them all under one --revocations flag.
1496
+ //
1497
+ // DIRECTORY MODE: read every *.json / *.vhrevocation.json file in the dir (NON-recursive — a flat folder of
1498
+ // revocations), parse each, and flatten (a file that is itself a JSON array contributes all its entries). A
1499
+ // file that is not valid JSON, or whose JSON is not an object/array, becomes a `_parseError` marker so it is
1500
+ // IGNORED with a warning downstream — a single junk file in the folder never aborts the decision.
1501
+ //
1502
+ // FILE MODE: read the one file's text and hand it to normalizeRevocationsInput (so a single file may be one
1503
+ // container OR a JSON array). A non-JSON single file HARD-errors (the recipient pointed --revocations at
1504
+ // bytes that aren't a revocations input at all) — same contract as the producer's single-file read.
1505
+ // ---------------------------------------------------------------------------
1506
+
1507
+ // File extensions a directory scan treats as candidate revocation files.
1508
+ const REVOCATION_FILE_RE = /\.(json|vhrevocation\.json)$/i;
1509
+
1510
+ /**
1511
+ * Read the --revocations <file-or-dir> path into a normalized array of parsed entries (each a container
1512
+ * object or a `_parseError` marker). The caller passes the already-resolved array straight to
1513
+ * evaluateTrustAsOf/applyToVerifyResult.
1514
+ *
1515
+ * @param {string} p the --revocations path (a file or a directory)
1516
+ * @param {{ readFile?: Function, statSync?: Function, readdirSync?: Function }} [io] injectable fs for tests
1517
+ * @returns {Array<object>} a flat array of parsed entries (containers or `_parseError` markers)
1518
+ * @throws {RevocationReadError} on an unreadable path or a non-JSON SINGLE file
1519
+ */
1520
+ function readRevocationsFromPath(p, io = {}) {
1521
+ const readFile = io.readFile || ((f) => fs.readFileSync(f, "utf8"));
1522
+ const statSync = io.statSync || ((f) => fs.statSync(f));
1523
+ const readdirSync = io.readdirSync || ((d) => fs.readdirSync(d));
1524
+
1525
+ let st;
1526
+ try {
1527
+ st = statSync(p);
1528
+ } catch (e) {
1529
+ throw new RevocationReadError(`cannot read --revocations ${p}: ${e.message}`);
1530
+ }
1531
+
1532
+ if (st.isDirectory()) {
1533
+ let names;
1534
+ try {
1535
+ names = readdirSync(p);
1536
+ } catch (e) {
1537
+ throw new RevocationReadError(`cannot read --revocations directory ${p}: ${e.message}`);
1538
+ }
1539
+ // Deterministic order (sorted) so the governing tie-break + ignored-warning order are stable.
1540
+ const files = names
1541
+ .filter((n) => REVOCATION_FILE_RE.test(n))
1542
+ .sort()
1543
+ .map((n) => path.join(p, n));
1544
+ const entries = [];
1545
+ for (const f of files) {
1546
+ let text;
1547
+ try {
1548
+ text = readFile(f);
1549
+ } catch (e) {
1550
+ entries.push({ _parseError: `cannot read ${path.basename(f)}: ${e.message}`, _raw: f });
1551
+ continue;
1552
+ }
1553
+ let parsed;
1554
+ try {
1555
+ parsed = JSON.parse(text);
1556
+ } catch (e) {
1557
+ entries.push({ _parseError: `${path.basename(f)} is not valid JSON: ${e.message}`, _raw: f });
1558
+ continue;
1559
+ }
1560
+ // A file may itself be a single container OR a JSON array of them — flatten either into the pool.
1561
+ if (Array.isArray(parsed)) {
1562
+ for (const el of parsed) entries.push(el);
1563
+ } else if (isPlainObject(parsed)) {
1564
+ entries.push(parsed);
1565
+ } else {
1566
+ entries.push({ _parseError: `${path.basename(f)} is not a revocation object/array`, _raw: f });
1567
+ }
1568
+ }
1569
+ return entries;
1570
+ }
1571
+
1572
+ // Single file: read its text and normalize (a non-JSON single file HARD-errors via normalizeRevocationsInput).
1573
+ let text;
1574
+ try {
1575
+ text = readFile(p);
1576
+ } catch (e) {
1577
+ throw new RevocationReadError(`cannot read --revocations ${p}: ${e.message}`);
1578
+ }
1579
+ return normalizeRevocationsInput(text);
1580
+ }
1581
+
1582
+ /**
1583
+ * The ONE shared integration verify-vh calls: read the --revocations file-or-dir, resolve the --as-of
1584
+ * (defaulting to nowISO), evaluate the decision, and fold it onto the verify result. Runs ONLY when
1585
+ * `revocationsPath` is truthy; with no path it returns the result UNCHANGED + a null decision (the
1586
+ * regression-safety contract — with no --revocations the verifier is byte-identical to today).
1587
+ *
1588
+ * @param {object} params { result, revocationsPath, asOf, nowISO, io? }
1589
+ * @returns {{ result, decision, defaulted }}
1590
+ */
1591
+ function loadAndApply(params) {
1592
+ if (!isPlainObject(params) || !isPlainObject(params.result)) {
1593
+ throw new RevocationReadError("loadAndApply requires { result, revocationsPath, asOf, nowISO }");
1594
+ }
1595
+ const { result, revocationsPath, asOf, nowISO, io } = params;
1596
+ if (!revocationsPath) {
1597
+ return { result, decision: null, defaulted: false };
1598
+ }
1599
+ const { asOf: effectiveAsOf, defaulted } = resolveAsOf(asOf, nowISO);
1600
+ const entries = readRevocationsFromPath(revocationsPath, io || {});
1601
+ const out = applyToVerifyResult({ result, revocations: entries, asOf: effectiveAsOf });
1602
+ return { result: out, decision: out.trustAsOf, defaulted };
1603
+ }
1604
+
1605
+ module.exports = {
1606
+ RevocationReadError: core.RevocationReadError,
1607
+ SIGNED_REVOCATION_KIND: core.SIGNED_REVOCATION_KIND,
1608
+ REVOCATION_KIND: core.REVOCATION_KIND,
1609
+ REVOCATION_SCHEMA_VERSION: core.REVOCATION_SCHEMA_VERSION,
1610
+ SUPPORTED_REVOCATION_SCHEMA_VERSIONS: core.SUPPORTED_REVOCATION_SCHEMA_VERSIONS,
1611
+ SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS: core.SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS,
1612
+ REVOCATION_REASON_SET: core.REVOCATION_REASON_SET,
1613
+ REVOCATION_FIELDS: core.REVOCATION_FIELDS,
1614
+ REVOCATION_TRUST_NOTE: core.REVOCATION_TRUST_NOTE,
1615
+ SIGNED_REVOCATION_TRUST_NOTE: core.SIGNED_REVOCATION_TRUST_NOTE,
1616
+ ISO_INSTANT_RE: core.ISO_INSTANT_RE,
1617
+ UNRECOVERABLE: core.UNRECOVERABLE,
1618
+ parseCanonicalInstant: core.parseCanonicalInstant,
1619
+ resolveAsOf: core.resolveAsOf,
1620
+ serializeRevocation: core.serializeRevocation,
1621
+ validateSignedRevocation: core.validateSignedRevocation,
1622
+ verifyRevocation: core.verifyRevocation,
1623
+ normalizeRevocationsInput: core.normalizeRevocationsInput,
1624
+ classifyRevocation: core.classifyRevocation,
1625
+ evaluateTrustAsOf: core.evaluateTrustAsOf,
1626
+ applyToVerifyResult: core.applyToVerifyResult,
1627
+ renderTrustAsOf: core.renderTrustAsOf,
1628
+ readRevocationsFromPath,
1629
+ loadAndApply,
1630
+ };
1631
+
1632
+ };
1633
+
1634
+ // ===== module: verify-vh (from verifier/verify-vh.js) =====
1635
+ __modules["verify-vh"] = function (module, exports, __require) {
1636
+ "use strict";
1637
+
1638
+ // verifier/verify-vh.js — the STANDALONE, read-only, OFFLINE verifier (T-31.2).
1639
+ //
1640
+ // WHY THIS EXISTS
1641
+ // The whole verifyhash family sells one promise: "you do NOT have to trust the producer — verify it
1642
+ // OFFLINE, independently." `verify-vh` is the artifact that makes that promise real for the party who
1643
+ // matters most for a sale: the COUNTERPARTY (an auditor, opposing counsel, a buyer's security team, a
1644
+ // design partner). They drop one `*.vhevidence.json` / `*.vhseal` / dataset attestation / proof bundle
1645
+ // in front of this command and get a deterministic verdict — WITHOUT installing the producer's heavy
1646
+ // ethers/hardhat stack. This tree depends on ONLY `js-sha3` (+ a tiny vendored secp256k1 routine), so a
1647
+ // third party can `npm install` it alone and audit it in an afternoon.
1648
+ //
1649
+ // WHAT IT DOES
1650
+ // * AUTO-DETECTS the artifact `kind` (evidence seal, reconciliation/trust seal, dataset attestation,
1651
+ // proof bundle — bare or signed).
1652
+ // * RE-DERIVES the keccak Merkle root from the bytes REFERENCED by the artifact (resolving sibling
1653
+ // files relative to the artifact's own directory, with a `--dir <d>` override), NEVER trusting the
1654
+ // artifact's own stored hashes.
1655
+ // * RECOVERS the signer of a signed artifact via the independent EIP-191 secp256k1 recovery (T-31.1),
1656
+ // PINS it to a caller-supplied `--vendor <0xaddr>` (or REPORTS the recovered signer when no pin is
1657
+ // given).
1658
+ // * Prints a deterministic verdict: OK / which file CHANGED / MISSING / UNEXPECTED / `bad_signature`
1659
+ // / `wrong_issuer`.
1660
+ //
1661
+ // POSTURE — READ-ONLY. It holds NO key, opens nothing for write, and NEVER writes the cwd (or anywhere).
1662
+ // It reads ONLY the artifact and the sibling files it references. Same exit-code contract as
1663
+ // `vh verify-seal` / `vh evidence verify`: 0 ok / 3 rejected / 2 usage / 1 IO.
1664
+ //
1665
+ // FILE-SOURCE SEAM (T-66.1). The verify cores are written against ONE tiny abstraction — a `readEntry`
1666
+ // function `(relPath) -> { status: "ok", bytes } | { status: "missing" } | { status: "escaped" }` — so
1667
+ // the SAME engine verifies from the DISK (the CLI path below, byte-identical to before) or from an
1668
+ // IN-MEMORY `{ relPath: Uint8Array }` map (`verifyArtifactFromBytes`, the seam a browser page / vm
1669
+ // sandbox drives with ZERO fs/os/path/process on its code path). The whole pure engine sits between the
1670
+ // BEGIN/END markers below; test/verifier.browser-core.test.js proves (statically AND dynamically) that
1671
+ // no impure builtin use is reachable from the bytes entry, and that disk/bytes verdicts are DEEP-EQUAL.
1672
+
1673
+ const fs = require("fs");
1674
+ const os = require("os");
1675
+ const path = require("path");
1676
+
1677
+ const merkle = __require("merkle");
1678
+ const canonical = __require("canonical");
1679
+ const { recoverPersonalSignAddress } = __require("secp256k1-recover");
1680
+ const revocation = __require("revocation");
1681
+
1682
+ // ============================ BEGIN VERIFY-VH PURE ENGINE (T-66.1) ============================
1683
+ // EVERYTHING between this marker and the matching END marker is the PURE verify engine: it performs NO
1684
+ // I/O of its own and never touches fs / os / path / process / child_process — every byte it verifies
1685
+ // arrives through the injected `readEntry` seam (or as an argument). Its only outside references are the
1686
+ // four module bindings above, all of which resolve to PURE modules for the functions used here:
1687
+ // `merkle`, `canonical`, `recoverPersonalSignAddress`, and the PURE decision half of `revocation`
1688
+ // (./lib/revocation-core.js re-exports — never the fs-backed readRevocationsFromPath/loadAndApply).
1689
+ // test/verifier.browser-core.test.js enforces all of this mechanically; the markers also make the block
1690
+ // mechanically extractable (vm / browser bundling, EPIC-66).
1691
+
1692
+ // CI-gateable exit contract, mirroring the producer family (vh verify-seal / vh evidence verify):
1693
+ // 0 ok / 3 rejected / 2 usage / 1 IO. Stable; a future CI/indexer keys on these.
1694
+ const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, REJECTED: 3 });
1695
+
1696
+ // A usage error the CLI maps to exit 2 (vs an IO error -> 1, vs a clean REJECTED verdict -> 3).
1697
+ class UsageError extends Error {}
1698
+ class IOError extends Error {}
1699
+
1700
+ // The on-disk `kind` discriminators of every artifact family this verifier understands. Bare and signed
1701
+ // variants are listed so auto-detect routes correctly. Disjoint, versioned strings — a foreign/random
1702
+ // JSON file falls through to a clear "unrecognized artifact" usage error rather than a misread.
1703
+ const KINDS = Object.freeze({
1704
+ EVIDENCE_SEAL: "vh.evidence-seal",
1705
+ EVIDENCE_SEAL_SIGNED: "vh.evidence-seal-signed",
1706
+ TRUST_SEAL: "trustledger.reconcile-seal",
1707
+ TRUST_SEAL_SIGNED: "trustledger.reconcile-seal-signed",
1708
+ DATASET_ATTESTATION: "verifyhash.dataset-attestation",
1709
+ DATASET_ATTESTATION_SIGNED: "verifyhash.dataset-attestation-signed",
1710
+ DATASET_ATTESTATION_TIMESTAMPED: "verifyhash.dataset-attestation-timestamped",
1711
+ PROOF: "verifyhash.merkle-proof",
1712
+ AGENT_PACKET: "vh.agent-session-packet",
1713
+ });
1714
+
1715
+ const TRUST_NOTE =
1716
+ "verify-vh is an INDEPENDENT, read-only, OFFLINE verifier. It RE-DERIVES the keccak root from the " +
1717
+ "bytes you hold and recovers the signer with no producer stack. It proves TAMPER-EVIDENCE + WHO " +
1718
+ "vouched — NOT a trusted timestamp and NOT a legal opinion.";
1719
+
1720
+ // ---------------------------------------------------------------------------
1721
+ // Address normalization + recovery helpers. The verifier compares addresses as LOWERCASE 0x-hex (the
1722
+ // canonical byte-deterministic form the producer records); a caller may paste an EIP-55-checksummed
1723
+ // --vendor and we lowercase it (a checksum mismatch is not our concern — we compare 20 raw bytes).
1724
+ // ---------------------------------------------------------------------------
1725
+
1726
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
1727
+
1728
+ function normalizeAddress(addr, label) {
1729
+ if (typeof addr !== "string" || !ADDRESS_RE.test(addr)) {
1730
+ throw new UsageError(`${label} must be a 0x-prefixed 20-byte hex address, got: ${String(addr)}`);
1731
+ }
1732
+ return addr.toLowerCase();
1733
+ }
1734
+
1735
+ // Recover the EIP-191 signer over the embedded canonical bytes. A tampered/corrupt signature can be
1736
+ // UNRECOVERABLE (no valid curve point) — that throws, which the caller turns into a `bad_signature`
1737
+ // REJECTED verdict, never a crash. Returns lowercase 0x-hex, or null if recovery failed.
1738
+ function tryRecover(message, signature) {
1739
+ try {
1740
+ return recoverPersonalSignAddress(message, signature);
1741
+ } catch (_) {
1742
+ return null;
1743
+ }
1744
+ }
1745
+
1746
+ // ---------------------------------------------------------------------------
1747
+ // Signed-container decoding. A signed artifact carries the embedded UNSIGNED payload as the EXACT
1748
+ // canonical bytes (a STRING) in `attestation`, plus a { scheme, signer, signature } block. The signed
1749
+ // MESSAGE is that embedded string verbatim, so signer recovery runs over `container.attestation`.
1750
+ // ---------------------------------------------------------------------------
1751
+
1752
+ function decodeSigned(container) {
1753
+ const sig = container && container.signature;
1754
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
1755
+ throw new IOError("signed artifact is missing a { scheme, signer, signature } signature block");
1756
+ }
1757
+ if (sig.scheme !== "eip191-personal-sign") {
1758
+ throw new IOError(
1759
+ `unsupported signature scheme: ${JSON.stringify(sig.scheme)} ` +
1760
+ "(this verifier understands eip191-personal-sign)"
1761
+ );
1762
+ }
1763
+ if (typeof container.attestation !== "string") {
1764
+ throw new IOError("signed artifact must embed the canonical UNSIGNED bytes as a string `attestation`");
1765
+ }
1766
+ if (typeof sig.signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(sig.signature)) {
1767
+ throw new IOError("signed artifact signature must be a 65-byte (r||s||v) 0x-hex string");
1768
+ }
1769
+ if (typeof sig.signer !== "string" || !ADDRESS_RE.test(sig.signer)) {
1770
+ throw new IOError("signed artifact signer must be a 0x-prefixed 20-byte hex address");
1771
+ }
1772
+ let embedded;
1773
+ try {
1774
+ embedded = JSON.parse(container.attestation);
1775
+ } catch (e) {
1776
+ throw new IOError(`embedded attestation is not valid JSON: ${e.message}`);
1777
+ }
1778
+ return { embedded, message: container.attestation, claimedSigner: sig.signer.toLowerCase(), signature: sig.signature };
1779
+ }
1780
+
1781
+ // ---------------------------------------------------------------------------
1782
+ // Per-file re-derivation, shared by every seal kind AND by both file sources. Given the sealed
1783
+ // { relPath, contentHash } entries and a `readEntry` source, fetch each referenced file's bytes through
1784
+ // the source, recompute its contentHash, and localize the outcome to MATCH / CHANGED / MISSING /
1785
+ // ESCAPED; a file present under a sealed relPath that is NOT in the seal cannot occur here (we only read
1786
+ // sealed relPaths) — UNEXPECTED is reported only for seals where the producer enumerates a directory
1787
+ // (evidence seal verify re-walks the dir). For artifact verification we follow the producer's read
1788
+ // model: read exactly the relPaths the artifact names from the source.
1789
+ //
1790
+ // SECURITY — CONFINEMENT LIVES IN THE SOURCE. `relPath` values come straight from the attacker-controlled
1791
+ // artifact JSON (the threat model is attacker-controls-the-input, victim-runs-on-their-own-machine: a
1792
+ // malicious producer hands a counterparty a "verify me" artifact, hoping its relPaths probe the
1793
+ // counterparty's filesystem). Each source therefore CONFINES every read BEFORE touching its backing
1794
+ // store and answers `{ status: "escaped" }` for a hostile relPath (absolute, a `..` traversal component,
1795
+ // or — for the disk source — a resolved/realpath escape of baseDir). An escaped entry is recorded ONLY by
1796
+ // relPath (the attacker's string) — we NEVER hash it and NEVER emit an actualContentHash for it, so the
1797
+ // verdict can never become a content-confirmation / hash-disclosure oracle over a file outside the
1798
+ // source. A `path_escape` entry is a hard REJECTED verdict.
1799
+ // ---------------------------------------------------------------------------
1800
+
1801
+ function classifyFilesWith(sealedEntries, readEntry) {
1802
+ const changed = [];
1803
+ const missing = [];
1804
+ const matched = [];
1805
+ const escaped = []; // { relPath } only — NEVER a hash; a confinement reject, read nothing
1806
+ const flat = []; // { relPath, contentHash } actually-present, for the root re-derivation
1807
+
1808
+ for (const e of sealedEntries) {
1809
+ const relPath = e.relPath;
1810
+ const r = readEntry(relPath);
1811
+ if (r.status === "escaped") {
1812
+ escaped.push({ relPath: String(relPath) });
1813
+ continue;
1814
+ }
1815
+ if (r.status === "missing") {
1816
+ missing.push({ relPath });
1817
+ continue;
1818
+ }
1819
+ const actual = merkle.hashBytes(r.bytes);
1820
+ flat.push({ relPath, contentHash: actual });
1821
+ if (actual.toLowerCase() === String(e.contentHash).toLowerCase()) {
1822
+ matched.push({ relPath, contentHash: actual });
1823
+ } else {
1824
+ changed.push({ relPath, expectedContentHash: e.contentHash, actualContentHash: actual });
1825
+ }
1826
+ }
1827
+ return { matched, changed, missing, escaped, flat };
1828
+ }
1829
+
1830
+ // ---------------------------------------------------------------------------
1831
+ // Verify an EVIDENCE seal (bare or the embedded seal of a signed container). The seal lists `files`
1832
+ // [{ relPath, contentHash, leaf }] + `root`. We re-derive the root from the bytes the source holds and
1833
+ // localize any tamper. NO header (evidence seals bind only the file set). UNEXPECTED files (present
1834
+ // under a sealed-sibling tree but not named) are NOT scanned here — the artifact names exactly what it
1835
+ // commits to; the producer's `vh evidence verify` re-walks the dir, but the standalone verifier verifies
1836
+ // what the artifact REFERENCES (read-only, no directory walk). NOTE an "extra" file is still caught
1837
+ // structurally: the sealed root commits to the FULL file set, so a seal doctored to omit an entry can
1838
+ // never keep its root (root_mismatch), and a signed seal edited that way breaks its signature.
1839
+ // ---------------------------------------------------------------------------
1840
+
1841
+ function verifyEvidenceSealWith(seal, readEntry) {
1842
+ if (!Array.isArray(seal.files) || seal.files.length === 0) {
1843
+ throw new IOError("evidence seal `files` must be a non-empty array");
1844
+ }
1845
+ if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
1846
+ throw new IOError("evidence seal `root` must be a 0x-prefixed 32-byte hex string");
1847
+ }
1848
+ const { matched, changed, missing, escaped, flat } = classifyFilesWith(seal.files, readEntry);
1849
+
1850
+ // The AUTHORITATIVE root is re-derived from the bytes actually held — never the seal's stored root.
1851
+ // A partial/changed set yields a different root; rootMatches goes false.
1852
+ let recomputedRoot = null;
1853
+ if (flat.length > 0) {
1854
+ try {
1855
+ recomputedRoot = merkle.rootFromFlat(flat);
1856
+ } catch (_) {
1857
+ recomputedRoot = null;
1858
+ }
1859
+ }
1860
+ const rootMatches =
1861
+ missing.length === 0 &&
1862
+ changed.length === 0 &&
1863
+ escaped.length === 0 &&
1864
+ recomputedRoot != null &&
1865
+ recomputedRoot.toLowerCase() === seal.root.toLowerCase();
1866
+
1867
+ return {
1868
+ matched,
1869
+ changed,
1870
+ missing,
1871
+ escaped,
1872
+ unexpected: [],
1873
+ sealedRoot: seal.root,
1874
+ recomputedRoot,
1875
+ rootMatches,
1876
+ filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
1877
+ };
1878
+ }
1879
+
1880
+ // ---------------------------------------------------------------------------
1881
+ // Verify a TRUST (reconciliation) seal (bare or embedded). The seal lists `inputs` (role+relPath+
1882
+ // contentHash+leaf) and `outputs` (relPath+contentHash+leaf), plus a `verdict` + `root`. The root commits
1883
+ // to all inputs + outputs PLUS a synthetic verdict/role HEADER leaf. We re-derive the root from the held
1884
+ // bytes AND the header content recomputed from the seal's OWN verdict + input role bindings — so a
1885
+ // verdict/role edit (which lives in the seal, not a file) still changes the recomputed root. Inputs are
1886
+ // sealed by basename and resolve through the source (the portable handoff ships sources next to the seal).
1887
+ // ---------------------------------------------------------------------------
1888
+
1889
+ function verifyTrustSealWith(seal, readEntry) {
1890
+ if (!Array.isArray(seal.inputs) || seal.inputs.length === 0) {
1891
+ throw new IOError("trust seal `inputs` must be a non-empty array");
1892
+ }
1893
+ if (!Array.isArray(seal.outputs) || seal.outputs.length === 0) {
1894
+ throw new IOError("trust seal `outputs` must be a non-empty array");
1895
+ }
1896
+ if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
1897
+ throw new IOError("trust seal `root` must be a 0x-prefixed 32-byte hex string");
1898
+ }
1899
+ if (seal.verdict == null || typeof seal.verdict !== "object") {
1900
+ throw new IOError("trust seal is missing its `verdict` block");
1901
+ }
1902
+
1903
+ const sealedEntries = [
1904
+ ...seal.inputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: e.role })),
1905
+ ...seal.outputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: null })),
1906
+ ];
1907
+ const { matched, changed, missing, escaped, flat } = classifyFilesWith(sealedEntries, readEntry);
1908
+
1909
+ // Re-derive the root: the held file leaves PLUS the verdict/role HEADER leaf (content recomputed
1910
+ // from the seal's own verdict + input role bindings). The header is folded in as one more (relPath,
1911
+ // content) pair under the reserved header relPath — exactly the producer's binding.
1912
+ let recomputedRoot = null;
1913
+ // Only attempt the root re-derivation when no file is MISSING or ESCAPED (a partial set can never
1914
+ // re-derive the sealed root anyway, and the header binds the FULL committed structure).
1915
+ if (missing.length === 0 && escaped.length === 0 && flat.length === seal.inputs.length + seal.outputs.length) {
1916
+ try {
1917
+ const headerBytes = canonical.trustSealHeaderBytes(
1918
+ seal.verdict,
1919
+ seal.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
1920
+ );
1921
+ const committed = [
1922
+ ...flat,
1923
+ { relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
1924
+ ];
1925
+ recomputedRoot = merkle.rootFromFlat(committed);
1926
+ } catch (_) {
1927
+ recomputedRoot = null;
1928
+ }
1929
+ }
1930
+ const rootMatches =
1931
+ escaped.length === 0 &&
1932
+ recomputedRoot != null &&
1933
+ recomputedRoot.toLowerCase() === seal.root.toLowerCase();
1934
+
1935
+ return {
1936
+ matched,
1937
+ changed,
1938
+ missing,
1939
+ escaped,
1940
+ unexpected: [],
1941
+ sealedRoot: seal.root,
1942
+ recomputedRoot,
1943
+ rootMatches,
1944
+ filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
1945
+ };
1946
+ }
1947
+
1948
+ // ---------------------------------------------------------------------------
1949
+ // Verify a DATASET attestation (bare/signed/timestamped). A dataset attestation commits to the dataset
1950
+ // IDENTITY (root, fileCount, manifestDigest) — it does NOT carry the per-file list, so there are no
1951
+ // sibling bytes to re-derive a Merkle root from without the original manifest. The independent verifier
1952
+ // therefore confirms the embedded identity is well-formed + (for signed) recovers/pins the signer; the
1953
+ // `root` is the dataset's, carried as-is. (`vh dataset verify <dir> --manifest` is the path that
1954
+ // re-derives a root from a live tree; the attestation alone has no tree to re-walk.)
1955
+ // ---------------------------------------------------------------------------
1956
+
1957
+ function verifyDatasetAttestation(att) {
1958
+ for (const f of ["root", "manifestDigest"]) {
1959
+ if (typeof att[f] !== "string" || !merkle.HEX32_RE.test(att[f])) {
1960
+ throw new IOError(`dataset attestation ${f} must be a 0x-prefixed 32-byte hex string`);
1961
+ }
1962
+ }
1963
+ if (!Number.isInteger(att.fileCount) || att.fileCount < 1) {
1964
+ throw new IOError("dataset attestation fileCount must be a positive integer");
1965
+ }
1966
+ return {
1967
+ matched: [],
1968
+ changed: [],
1969
+ missing: [],
1970
+ escaped: [],
1971
+ unexpected: [],
1972
+ sealedRoot: att.root,
1973
+ recomputedRoot: null,
1974
+ rootMatches: null, // no sibling bytes to re-derive a root from (identity-only artifact)
1975
+ filesOk: true, // structural identity is sound; the binding is via the signature for signed variants
1976
+ identityOnly: true,
1977
+ };
1978
+ }
1979
+
1980
+ // ---------------------------------------------------------------------------
1981
+ // Verify a PROOF bundle. A proof artifact carries { root, leaf, contentHash, relPath, proof[] }. We
1982
+ // RE-DERIVE the leaf from relPath + contentHash, then fold leafHash(leaf) up through the proof siblings
1983
+ // with nodeHash and confirm it reproduces `root` — byte-identically to the on-chain verifyLeaf, but
1984
+ // fully OFFLINE. (The on-chain "is this root anchored" check is out of scope for the offline verifier.)
1985
+ // ---------------------------------------------------------------------------
1986
+
1987
+ function verifyProofBundle(art) {
1988
+ for (const f of ["root", "leaf", "contentHash"]) {
1989
+ if (typeof art[f] !== "string" || !merkle.HEX32_RE.test(art[f])) {
1990
+ throw new IOError(`proof artifact ${f} must be a 0x-prefixed 32-byte hex string`);
1991
+ }
1992
+ }
1993
+ if (typeof art.relPath !== "string" || art.relPath.length === 0) {
1994
+ throw new IOError("proof artifact relPath must be a non-empty string");
1995
+ }
1996
+ if (!Array.isArray(art.proof)) {
1997
+ throw new IOError("proof artifact `proof` must be an array of 0x 32-byte hex siblings");
1998
+ }
1999
+ const derivedLeaf = merkle.pathLeaf(art.relPath, art.contentHash);
2000
+ const leafMatches = derivedLeaf.toLowerCase() === art.leaf.toLowerCase();
2001
+ let computed = merkle.leafHash(art.leaf);
2002
+ for (const sib of art.proof) {
2003
+ computed = merkle.nodeHash(computed, sib);
2004
+ }
2005
+ const foldsToRoot = computed.toLowerCase() === art.root.toLowerCase();
2006
+ return {
2007
+ matched: leafMatches && foldsToRoot ? [{ relPath: art.relPath, contentHash: art.contentHash }] : [],
2008
+ changed:
2009
+ leafMatches && foldsToRoot ? [] : [{ relPath: art.relPath, expectedContentHash: art.root, actualContentHash: computed }],
2010
+ missing: [],
2011
+ escaped: [],
2012
+ unexpected: [],
2013
+ sealedRoot: art.root,
2014
+ recomputedRoot: computed,
2015
+ rootMatches: leafMatches && foldsToRoot,
2016
+ filesOk: leafMatches && foldsToRoot,
2017
+ proof: { derivedLeaf, leafMatches, foldsToRoot },
2018
+ };
2019
+ }
2020
+
2021
+ // ---------------------------------------------------------------------------
2022
+ // Verify an AGENT-SESSION packet (T-68.3 — the AgentTrace funnel leg, FREE surface only).
2023
+ //
2024
+ // A `*.vhagent.json` packet is SELF-CONTAINED: it carries its ordered event list (full and/or
2025
+ // REDACTED), a per-event leaf expectation list, and an RFC-6962-style ordered Merkle head
2026
+ // { size, root } — there are NO sibling files to read, so `readEntry` is never consulted. This block
2027
+ // RE-DERIVES every event leaf and the root from the events the packet holds, exactly as the producer's
2028
+ // `vh agent verify` does, but from an INDEPENDENT implementation surface: everything below is written
2029
+ // against the verifier's OWN dependency-free keccak (merkle.hashBytes) — it imports NOTHING from cli/.
2030
+ //
2031
+ // THE CONVENTION (must match cli/core/agent-session.js + cli/journal-log.js VERBATIM):
2032
+ // * payloadHash = keccak256(utf8(payload)) (the payload COMMITMENT)
2033
+ // * event leaf = keccak256(utf8(JSON.stringify([
2034
+ // LEAF_DOMAIN, seq, ts, actor, type, payloadHash, canonicalMetaJson|null ])))
2035
+ // — the payload participates ONLY via its commitment, so a FULL event and its REDACTED twin
2036
+ // (payload dropped, commitment carried, `redacted: true`) derive the IDENTICAL leaf: redaction
2037
+ // changes neither the leaves nor the root (it can WITHHOLD, never silently ALTER).
2038
+ // * the ordered tree (RFC 6962, position-bound, NO sorting — the OPPOSITE of the evidence tree):
2039
+ // leaf node = keccak256(0x00 || leaf) interior = keccak256(0x01 || left || right)
2040
+ // MTH(D[0:n]) = interior(MTH(D[0:k]), MTH(D[k:n])), k = largest power of two < n
2041
+ // empty log root = keccak256(utf8("vh.journal-log/v1:empty-root"))
2042
+ // * a SIGNED packet carries `headAttestation`: a detached EIP-191 personal-sign over the EXACT
2043
+ // canonical head-payload bytes (the embedded `attestation` string). The signature wraps the HEAD,
2044
+ // so ONE signature stays valid for every redacted copy of the same sealed session.
2045
+ //
2046
+ // VERDICTS: event-level tamper (a payload that no longer matches its carried commitment — including a
2047
+ // REDACTED event whose commitment was forged — or a leaf that no longer matches its expectation) is a
2048
+ // REJECT NAMING THE SEQ; a tampered head is `root_mismatch`; a forged signature is `bad_signature`; a
2049
+ // sound signature by the wrong signer under a --vendor pin is `wrong_issuer`; a --vendor pin on an
2050
+ // UNSIGNED packet is `unsigned_cannot_pin_vendor` (a stripped signature never passes a pinned verify).
2051
+ // The recompute is AUTHORITATIVE: the packet is an untrusted container and its stored hashes are only
2052
+ // EXPECTATIONS checked against.
2053
+ // ---------------------------------------------------------------------------
2054
+
2055
+ // The producer's in-band trust note, REQUIRED verbatim (the packetseal discipline: the caveat may not
2056
+ // drift; a packet whose note was edited is structurally invalid, exactly as `vh agent verify` treats it).
2057
+ const AGENT_TRUST_NOTE =
2058
+ "This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and " +
2059
+ "NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, " +
2060
+ "position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the " +
2061
+ "payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — " +
2062
+ "and the root from the events you hold, and a REJECT names the first offending event seq. " +
2063
+ "Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: " +
2064
+ "it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never " +
2065
+ 'verified against any clock); "sealed at time T" rides the human-owned signing/timestamp ' +
2066
+ "trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and " +
2067
+ "append-only, not that the log faithfully records what the agent actually did. The packet is an " +
2068
+ "UNTRUSTED transport container: verify never trusts the packet's own stored hashes.";
2069
+
2070
+ const AGENT_SIGNED_HEAD_TRUST_NOTE =
2071
+ "This is a SIGNED agent-session HEAD attestation: it WRAPS (never edits) the EXACT canonical head " +
2072
+ "bytes in `attestation` and attaches a detached EIP-191 signature. It asserts the holder of the " +
2073
+ "`signer` key vouched for THIS session head {size, root} at signing time. Because event leaves " +
2074
+ "are redaction-safe, the SAME signature stays valid for every redacted copy of the sealed session " +
2075
+ "(redaction changes neither leaves nor root). It does NOT prove a timestamp (no \"sealed since " +
2076
+ "T\" — still the human trust-root P-3) and is NOT a legal opinion. Every caveat of the packet " +
2077
+ "applies. " +
2078
+ AGENT_TRUST_NOTE;
2079
+
2080
+ const AGENT_HEAD_KIND = "vh.agent-head";
2081
+ const AGENT_SIGNED_HEAD_KIND = "vh.agent-head-signed";
2082
+ const AGENT_PACKET_SCHEMA_VERSIONS = Object.freeze([1]);
2083
+ const AGENT_EVENT_TYPES = Object.freeze(["prompt", "completion", "tool_call", "tool_result", "note"]);
2084
+ const AGENT_EVENT_FIELDS = Object.freeze([
2085
+ "seq",
2086
+ "ts",
2087
+ "actor",
2088
+ "type",
2089
+ "payload",
2090
+ "payloadHash",
2091
+ "redacted",
2092
+ "meta",
2093
+ ]);
2094
+ const AGENT_LEAF_DOMAIN = "vh.agent-session/v1:event-leaf";
2095
+ const AGENT_EMPTY_ROOT_DOMAIN = "vh.journal-log/v1:empty-root";
2096
+ const AGENT_META_MAX_DEPTH = 32;
2097
+ const AGENT_META_MAX_NODES = 100000;
2098
+
2099
+ // Canonical-case wire shapes (the producer emits lowercase-only hex; mixed case is a foreign artifact).
2100
+ const AGENT_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
2101
+ const AGENT_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
2102
+ const AGENT_SIG_LC_RE = /^0x[0-9a-f]{130}$/;
2103
+
2104
+ // STRICT UTF-8 encoder that MIRRORS the producer's ethers `toUtf8Bytes` byte-for-byte (verified over
2105
+ // the whole 0x0000..0xFFFF code-unit space + surrogate edge cases). ethers' default error mode THROWS
2106
+ // only on a lone HIGH surrogate (an unfinished pair, no code point) — so this returns null there — but
2107
+ // it ENCODES a lone LOW surrogate as its literal 3-byte sequence (U+DC00 -> ed b0 80), NOT an error;
2108
+ // so a lone low surrogate falls straight through to the c<0x10000 branch below (matching the producer,
2109
+ // whose commitment over such a payload is well-defined). Pure JS; no TextEncoder (which would silently
2110
+ // substitute U+FFFD and DIVERGE from the producer). null => the event's commitment is undefined here
2111
+ // exactly as it is for the producer, so both sides reject in lockstep (fail-closed, never a mismatch).
2112
+ function agentUtf8Bytes(str) {
2113
+ const out = [];
2114
+ for (let i = 0; i < str.length; i++) {
2115
+ let c = str.charCodeAt(i);
2116
+ if (c >= 0xd800 && c <= 0xdbff) {
2117
+ const lo = i + 1 < str.length ? str.charCodeAt(i + 1) : -1;
2118
+ if (lo < 0xdc00 || lo > 0xdfff) return null; // lone HIGH surrogate (ethers THROWS; no code point)
2119
+ c = (c - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
2120
+ i++;
2121
+ }
2122
+ // A lone LOW surrogate (0xdc00..0xdfff) is NOT special-cased: ethers encodes it as its 3-byte form
2123
+ // via the c<0x10000 branch, so we do too — deleting the old lone-low `return null` that FALSELY
2124
+ // rejected genuine packets carrying truncated-UTF-16 / arbitrary-tool-result bytes.
2125
+ if (c < 0x80) out.push(c);
2126
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
2127
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
2128
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
2129
+ }
2130
+ return new Uint8Array(out);
2131
+ }
2132
+
2133
+ // 0x-hex -> bytes, and a tiny concat — the only byte plumbing the ordered tree needs.
2134
+ function agentHexToBytes(hex) {
2135
+ const s = hex.slice(2);
2136
+ const out = new Uint8Array(s.length / 2);
2137
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
2138
+ return out;
2139
+ }
2140
+ function agentConcatBytes(list) {
2141
+ let total = 0;
2142
+ for (const b of list) total += b.length;
2143
+ const out = new Uint8Array(total);
2144
+ let off = 0;
2145
+ for (const b of list) {
2146
+ out.set(b, off);
2147
+ off += b.length;
2148
+ }
2149
+ return out;
2150
+ }
2151
+
2152
+ // RFC-6962 domain-separated hashing over the verifier's OWN keccak (merkle.hashBytes — the same
2153
+ // independent primitive every other artifact family here is re-derived with). Children fold in TREE
2154
+ // ORDER (never sorted): position IS meaning in an ordered session log.
2155
+ function agentLeafNodeHash(leafHex) {
2156
+ return merkle.hashBytes(agentConcatBytes([Uint8Array.of(0x00), agentHexToBytes(leafHex)]));
2157
+ }
2158
+ function agentInteriorHash(leftHex, rightHex) {
2159
+ return merkle.hashBytes(
2160
+ agentConcatBytes([Uint8Array.of(0x01), agentHexToBytes(leftHex), agentHexToBytes(rightHex)])
2161
+ );
2162
+ }
2163
+
2164
+ // MTH (RFC 6962 §2.1) over the ORDERED leaf values; the empty log has a domain-separated constant root.
2165
+ function agentTreeRoot(leaves) {
2166
+ if (leaves.length === 0) return merkle.hashBytes(agentUtf8Bytes(AGENT_EMPTY_ROOT_DOMAIN));
2167
+ function mth(lo, hi) {
2168
+ const n = hi - lo;
2169
+ if (n === 1) return agentLeafNodeHash(leaves[lo]);
2170
+ let k = 1;
2171
+ while (k * 2 < n) k *= 2;
2172
+ return agentInteriorHash(mth(lo, lo + k), mth(lo + k, hi));
2173
+ }
2174
+ return mth(0, leaves.length);
2175
+ }
2176
+
2177
+ // A "plain" JSON-shaped object (prototype Object.prototype or null) — the same strictness the producer
2178
+ // applies, so what is hashed is exactly what could be written to disk and read back.
2179
+ function agentIsPlainObject(v) {
2180
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
2181
+ const proto = Object.getPrototypeOf(v);
2182
+ return proto === Object.prototype || proto === null;
2183
+ }
2184
+
2185
+ // Canonical JSON for `meta`: keys SORTED, only JSON-representable values, depth capped, and a TOTAL
2186
+ // work budget so a shared-reference DAG can never hang the verifier. Returns the canonical text or
2187
+ // null (reject) — byte-identical to the producer's canonicalization for every accepted value.
2188
+ function agentCanonicalJson(value, depth, budget) {
2189
+ if (depth > AGENT_META_MAX_DEPTH) return null;
2190
+ if (++budget.n > AGENT_META_MAX_NODES) return null;
2191
+ if (value === null) return "null";
2192
+ const t = typeof value;
2193
+ if (t === "boolean") return value ? "true" : "false";
2194
+ if (t === "number") return Number.isFinite(value) ? JSON.stringify(value) : null;
2195
+ if (t === "string") return JSON.stringify(value);
2196
+ if (Array.isArray(value)) {
2197
+ const parts = [];
2198
+ for (const item of value) {
2199
+ const p = agentCanonicalJson(item, depth + 1, budget);
2200
+ if (p === null) return null;
2201
+ parts.push(p);
2202
+ }
2203
+ return "[" + parts.join(",") + "]";
2204
+ }
2205
+ if (agentIsPlainObject(value)) {
2206
+ const keys = Object.keys(value).sort();
2207
+ const parts = [];
2208
+ for (const k of keys) {
2209
+ const p = agentCanonicalJson(value[k], depth + 1, budget);
2210
+ if (p === null) return null;
2211
+ parts.push(JSON.stringify(k) + ":" + p);
2212
+ }
2213
+ return "{" + parts.join(",") + "}";
2214
+ }
2215
+ return null;
2216
+ }
2217
+
2218
+ // The payload COMMITMENT: keccak256 over the payload's UTF-8 bytes. null on a non-string or a string
2219
+ // with no UTF-8 encoding (a lone HIGH surrogate — where ethers throws) — TOTAL, mirrors the producer
2220
+ // exactly (a lone LOW surrogate IS encodable, so it commits rather than rejecting).
2221
+ function agentPayloadHash(payload) {
2222
+ if (typeof payload !== "string") return null;
2223
+ const bytes = agentUtf8Bytes(payload);
2224
+ return bytes === null ? null : merkle.hashBytes(bytes);
2225
+ }
2226
+
2227
+ // STRICT validation of one canonical event — an INDEPENDENT re-implementation of the producer's rules
2228
+ // (closed field set; exactly the FULL or REDACTED shape; a carried commitment on a full event must
2229
+ // equal the recomputed one). Never throws; every failure is a named { ok:false, reason, field? } (the
2230
+ // commitment-mismatch reject also carries carried/recomputed so the caller can localize the change).
2231
+ function agentValidateEvent(event) {
2232
+ try {
2233
+ if (!agentIsPlainObject(event)) return { ok: false, reason: "EVENT_NOT_OBJECT" };
2234
+ for (const k of Object.keys(event)) {
2235
+ if (!AGENT_EVENT_FIELDS.includes(k)) return { ok: false, reason: "EVENT_UNKNOWN_FIELD", field: k };
2236
+ }
2237
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
2238
+ return { ok: false, reason: "EVENT_BAD_SEQ", field: "seq" };
2239
+ }
2240
+ if (typeof event.ts !== "string") return { ok: false, reason: "EVENT_BAD_TS", field: "ts" };
2241
+ if (typeof event.actor !== "string" || event.actor.length === 0) {
2242
+ return { ok: false, reason: "EVENT_BAD_ACTOR", field: "actor" };
2243
+ }
2244
+ if (!AGENT_EVENT_TYPES.includes(event.type)) return { ok: false, reason: "EVENT_BAD_TYPE", field: "type" };
2245
+ const hasPayload = "payload" in event;
2246
+ const hasHash = "payloadHash" in event;
2247
+ if (hasPayload && typeof event.payload !== "string") {
2248
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
2249
+ }
2250
+ if (hasHash && !(typeof event.payloadHash === "string" && merkle.HEX32_RE.test(event.payloadHash))) {
2251
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
2252
+ }
2253
+ if ("redacted" in event && typeof event.redacted !== "boolean") {
2254
+ return { ok: false, reason: "EVENT_BAD_REDACTED_FLAG", field: "redacted" };
2255
+ }
2256
+ if (!hasPayload && !hasHash) return { ok: false, reason: "EVENT_MISSING_PAYLOAD", field: "payload" };
2257
+ if (event.redacted === true && hasPayload) {
2258
+ return { ok: false, reason: "EVENT_REDACTED_WITH_PAYLOAD", field: "redacted" };
2259
+ }
2260
+ if (event.redacted === true && !hasHash) {
2261
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
2262
+ }
2263
+ if (!hasPayload && event.redacted !== true) {
2264
+ return { ok: false, reason: "EVENT_UNFLAGGED_REDACTION", field: "redacted" };
2265
+ }
2266
+ let commitment;
2267
+ if (hasPayload) {
2268
+ commitment = agentPayloadHash(event.payload);
2269
+ if (commitment === null) return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
2270
+ if (hasHash && commitment !== event.payloadHash.toLowerCase()) {
2271
+ return {
2272
+ ok: false,
2273
+ reason: "EVENT_PAYLOAD_HASH_MISMATCH",
2274
+ field: "payloadHash",
2275
+ carried: event.payloadHash.toLowerCase(),
2276
+ recomputed: commitment,
2277
+ };
2278
+ }
2279
+ } else {
2280
+ commitment = event.payloadHash.toLowerCase();
2281
+ }
2282
+ let metaJson = null;
2283
+ if ("meta" in event) {
2284
+ metaJson = agentCanonicalJson(event.meta, 0, { n: 0 });
2285
+ if (metaJson === null) return { ok: false, reason: "EVENT_BAD_META", field: "meta" };
2286
+ }
2287
+ return { ok: true, redacted: !hasPayload, payloadHash: commitment, metaJson };
2288
+ } catch (_) {
2289
+ return { ok: false, reason: "HOSTILE_INPUT" };
2290
+ }
2291
+ }
2292
+
2293
+ // The redaction-safe LEAF VALUE of one validated event: the fixed-position JSON array preimage with
2294
+ // the payload represented ONLY by its commitment (so a full event and its redacted twin derive the
2295
+ // identical leaf). Returns null only for an encoding fault (kept total).
2296
+ function agentEventLeaf(event, validated) {
2297
+ const encoded = JSON.stringify([
2298
+ AGENT_LEAF_DOMAIN,
2299
+ event.seq,
2300
+ event.ts,
2301
+ event.actor,
2302
+ event.type,
2303
+ validated.payloadHash,
2304
+ validated.metaJson,
2305
+ ]);
2306
+ const bytes = agentUtf8Bytes(encoded);
2307
+ return bytes === null ? null : merkle.hashBytes(bytes);
2308
+ }
2309
+
2310
+ // The shared { size, root } head shape. Throws IOError (a malformed/foreign artifact, exit 1 — the same
2311
+ // class `vh agent verify` gives a structurally invalid packet).
2312
+ function validateAgentHeadShape(head, label) {
2313
+ if (head == null || typeof head !== "object" || Array.isArray(head)) {
2314
+ throw new IOError(`${label} \`head\` must be a { size, root } object`);
2315
+ }
2316
+ for (const k of Object.keys(head)) {
2317
+ if (k !== "size" && k !== "root") {
2318
+ throw new IOError(`${label} head has unknown field: ${JSON.stringify(k)}`);
2319
+ }
2320
+ }
2321
+ if (!Number.isSafeInteger(head.size) || head.size < 0) {
2322
+ throw new IOError(`${label} head.size must be a non-negative integer, got: ${String(head.size)}`);
2323
+ }
2324
+ if (typeof head.root !== "string" || !AGENT_HEX32_LC_RE.test(head.root)) {
2325
+ throw new IOError(
2326
+ `${label} head.root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(head.root)}`
2327
+ );
2328
+ }
2329
+ }
2330
+
2331
+ // STRICT structural validation of the OPTIONAL signed-head container: the exact canonical embedded
2332
+ // bytes, a known scheme, lowercase signer/signature, and an embedded head payload in canonical form.
2333
+ // Returns { embeddedHead } for the binding check. Throws IOError on any structural defect.
2334
+ function validateAgentSignedHead(container) {
2335
+ const label = "agent-session packet headAttestation";
2336
+ if (container == null || typeof container !== "object" || Array.isArray(container)) {
2337
+ throw new IOError(`${label} must be a JSON object`);
2338
+ }
2339
+ const KNOWN = ["kind", "schemaVersion", "note", "attestation", "signature"];
2340
+ for (const k of Object.keys(container)) {
2341
+ if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
2342
+ }
2343
+ if (container.kind !== AGENT_SIGNED_HEAD_KIND) {
2344
+ throw new IOError(
2345
+ `${label} kind must be ${JSON.stringify(AGENT_SIGNED_HEAD_KIND)}, got: ${JSON.stringify(container.kind)}`
2346
+ );
2347
+ }
2348
+ if (container.schemaVersion !== 1) {
2349
+ throw new IOError(`${label} has unsupported schemaVersion: ${JSON.stringify(container.schemaVersion)}`);
2350
+ }
2351
+ if (container.note !== AGENT_SIGNED_HEAD_TRUST_NOTE) {
2352
+ throw new IOError(`${label} note must be the standing signed-head trust note (caveat must not drift)`);
2353
+ }
2354
+ if (typeof container.attestation !== "string") {
2355
+ throw new IOError(`${label} must embed the canonical UNSIGNED head bytes as a string \`attestation\``);
2356
+ }
2357
+ let embedded;
2358
+ try {
2359
+ embedded = JSON.parse(container.attestation);
2360
+ } catch (e) {
2361
+ throw new IOError(`${label} embedded attestation is not valid JSON: ${e.message}`);
2362
+ }
2363
+ if (
2364
+ embedded == null ||
2365
+ typeof embedded !== "object" ||
2366
+ Array.isArray(embedded) ||
2367
+ embedded.kind !== AGENT_HEAD_KIND ||
2368
+ embedded.schemaVersion !== 1 ||
2369
+ embedded.note !== AGENT_TRUST_NOTE
2370
+ ) {
2371
+ throw new IOError(`${label} embedded payload is not a canonical ${JSON.stringify(AGENT_HEAD_KIND)} payload`);
2372
+ }
2373
+ validateAgentHeadShape(embedded.head, `${label} embedded payload`);
2374
+ // The embedded string must be the EXACT canonical serialization (the byte-unambiguous signed message);
2375
+ // an insignificant-whitespace/reordered variant is a foreign artifact.
2376
+ const canonicalText =
2377
+ JSON.stringify({
2378
+ kind: embedded.kind,
2379
+ schemaVersion: embedded.schemaVersion,
2380
+ note: embedded.note,
2381
+ head: { size: embedded.head.size, root: embedded.head.root },
2382
+ }) + "\n";
2383
+ if (container.attestation !== canonicalText) {
2384
+ throw new IOError(`${label} embedded attestation is not in canonical form (the signed-over bytes are ambiguous)`);
2385
+ }
2386
+ const sig = container.signature;
2387
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
2388
+ throw new IOError(`${label} signature must be a { scheme, signer, signature } object`);
2389
+ }
2390
+ if (sig.scheme !== "eip191-personal-sign") {
2391
+ throw new IOError(
2392
+ `${label} has unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
2393
+ );
2394
+ }
2395
+ if (typeof sig.signer !== "string" || !AGENT_ADDRESS_LC_RE.test(sig.signer)) {
2396
+ throw new IOError(`${label} signer must be a LOWERCASE 0x-prefixed 20-byte hex address`);
2397
+ }
2398
+ if (typeof sig.signature !== "string" || !AGENT_SIG_LC_RE.test(sig.signature)) {
2399
+ throw new IOError(`${label} signature must be a 65-byte (r||s||v) LOWERCASE 0x-hex string`);
2400
+ }
2401
+ return { embeddedHead: { size: embedded.head.size, root: embedded.head.root } };
2402
+ }
2403
+
2404
+ // STRICT structural validation of a parsed packet (SHAPE only — the per-event/leaf/root RECOMPUTE is
2405
+ // verifyAgentSeal's job, so event-level tamper stays a NAMED verdict naming the seq, never a throw).
2406
+ // Mirrors the producer's validatePacketShape defect-for-defect. Throws IOError.
2407
+ function validateAgentPacketStructure(obj) {
2408
+ const label = "agent-session packet";
2409
+ const KNOWN = ["kind", "schemaVersion", "note", "head", "counts", "events", "leaves", "headAttestation"];
2410
+ for (const k of Object.keys(obj)) {
2411
+ if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
2412
+ }
2413
+ if (!AGENT_PACKET_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
2414
+ throw new IOError(
2415
+ `unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
2416
+ `(this verifier understands ${JSON.stringify(AGENT_PACKET_SCHEMA_VERSIONS)})`
2417
+ );
2418
+ }
2419
+ if (obj.note !== AGENT_TRUST_NOTE) {
2420
+ throw new IOError(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
2421
+ }
2422
+ validateAgentHeadShape(obj.head, label);
2423
+ if (obj.counts == null || typeof obj.counts !== "object" || Array.isArray(obj.counts)) {
2424
+ throw new IOError(`${label} \`counts\` must be a { events, full, redacted } object`);
2425
+ }
2426
+ for (const k of Object.keys(obj.counts)) {
2427
+ if (!["events", "full", "redacted"].includes(k)) {
2428
+ throw new IOError(`${label} counts has unknown field: ${JSON.stringify(k)}`);
2429
+ }
2430
+ }
2431
+ for (const k of ["events", "full", "redacted"]) {
2432
+ if (!Number.isSafeInteger(obj.counts[k]) || obj.counts[k] < 0) {
2433
+ throw new IOError(`${label} counts.${k} must be a non-negative integer, got: ${String(obj.counts[k])}`);
2434
+ }
2435
+ }
2436
+ if (!Array.isArray(obj.events)) throw new IOError(`${label} \`events\` must be an array`);
2437
+ if (!Array.isArray(obj.leaves) || obj.leaves.length !== obj.events.length) {
2438
+ throw new IOError(`${label} \`leaves\` must be an array with EXACTLY one leaf expectation per event`);
2439
+ }
2440
+ obj.leaves.forEach((l, i) => {
2441
+ if (typeof l !== "string" || !AGENT_HEX32_LC_RE.test(l)) {
2442
+ throw new IOError(`${label} leaves[${i}] must be a LOWERCASE 0x-bytes32 hex string, got: ${String(l)}`);
2443
+ }
2444
+ });
2445
+ if (obj.head.size !== obj.events.length) {
2446
+ throw new IOError(
2447
+ `${label} head.size (${obj.head.size}) does not match the events length (${obj.events.length})`
2448
+ );
2449
+ }
2450
+ if (obj.counts.events !== obj.events.length || obj.counts.full + obj.counts.redacted !== obj.counts.events) {
2451
+ throw new IOError(
2452
+ `${label} \`counts\` is internally inconsistent (events must equal the events length; full + redacted must equal events)`
2453
+ );
2454
+ }
2455
+ let signedHead = null;
2456
+ if (obj.headAttestation !== undefined) signedHead = validateAgentSignedHead(obj.headAttestation);
2457
+ return { packet: obj, signedHead };
2458
+ }
2459
+
2460
+ // The AUTHORITATIVE per-event/leaf/root/counts RECOMPUTE over a shape-validated packet. Returns the
2461
+ // engine's standard fileResult shape (matched/changed/... + roots) PLUS an `agent` sub-verdict block
2462
+ // and a `reasonKind` in the verifier's reason vocabulary. Event faults are localized to the FIRST
2463
+ // offending seq, exactly as the producer's verify names it. Never throws.
2464
+ function verifyAgentSeal(packet) {
2465
+ const matched = [];
2466
+ const changed = [];
2467
+ const withheld = [];
2468
+ const agent = {
2469
+ head: { size: packet.head.size, root: packet.head.root },
2470
+ recomputedHead: null,
2471
+ counts: null,
2472
+ withheld: null,
2473
+ seq: null,
2474
+ reason: null,
2475
+ };
2476
+ const base = {
2477
+ matched,
2478
+ changed,
2479
+ missing: [],
2480
+ escaped: [],
2481
+ unexpected: [],
2482
+ sealedRoot: packet.head.root,
2483
+ recomputedRoot: null,
2484
+ rootMatches: null,
2485
+ filesOk: false,
2486
+ reasonKind: null,
2487
+ agent,
2488
+ };
2489
+ const events = packet.events;
2490
+ const leaves = [];
2491
+ for (let i = 0; i < events.length; i++) {
2492
+ const v = agentValidateEvent(events[i]);
2493
+ if (!v.ok) {
2494
+ agent.seq = i;
2495
+ agent.reason = v.reason;
2496
+ if (v.field !== undefined) agent.field = v.field;
2497
+ if (v.reason === "EVENT_PAYLOAD_HASH_MISMATCH") {
2498
+ // The payload no longer matches its carried commitment: a CONTENT change localized to its seq
2499
+ // (this is also how a REDACTED event's FORGED commitment surfaces once its leaf is checked).
2500
+ changed.push({ relPath: `events[${i}]`, expectedContentHash: v.carried, actualContentHash: v.recomputed });
2501
+ base.reasonKind = "CHANGED";
2502
+ } else {
2503
+ base.reasonKind = "event_invalid";
2504
+ }
2505
+ return base;
2506
+ }
2507
+ if (events[i].seq !== i) {
2508
+ agent.seq = i;
2509
+ agent.reason = "SESSION_SEQ_NOT_CONTIGUOUS";
2510
+ base.reasonKind = "event_invalid";
2511
+ return base;
2512
+ }
2513
+ const leaf = agentEventLeaf(events[i], v);
2514
+ if (leaf === null || leaf !== packet.leaves[i]) {
2515
+ // A bound-field edit (ts/actor/type/meta) or a forged redacted commitment: the re-derived leaf no
2516
+ // longer matches the packet's own expectation — named by seq, recompute authoritative.
2517
+ agent.seq = i;
2518
+ agent.reason = "EVENT_LEAF_MISMATCH";
2519
+ changed.push({ relPath: `events[${i}]`, expectedContentHash: packet.leaves[i], actualContentHash: leaf });
2520
+ base.reasonKind = "CHANGED";
2521
+ return base;
2522
+ }
2523
+ leaves.push(leaf);
2524
+ matched.push({ relPath: `events[${i}]`, contentHash: leaf });
2525
+ if (v.redacted) withheld.push(i);
2526
+ }
2527
+ const recomputedRoot = agentTreeRoot(leaves);
2528
+ base.recomputedRoot = recomputedRoot;
2529
+ agent.recomputedHead = { size: leaves.length, root: recomputedRoot };
2530
+ base.rootMatches = leaves.length === packet.head.size && recomputedRoot === packet.head.root;
2531
+ if (!base.rootMatches) {
2532
+ agent.reason = "HEAD_MISMATCH";
2533
+ base.reasonKind = "root_mismatch";
2534
+ return base;
2535
+ }
2536
+ const full = events.length - withheld.length;
2537
+ agent.counts = { events: events.length, full, redacted: withheld.length };
2538
+ agent.withheld = withheld;
2539
+ if (packet.counts.full !== full || packet.counts.redacted !== withheld.length) {
2540
+ agent.reason = "COUNTS_MISMATCH";
2541
+ base.reasonKind = "counts_mismatch";
2542
+ return base;
2543
+ }
2544
+ base.filesOk = true;
2545
+ return base;
2546
+ }
2547
+
2548
+ // The artifact-level orchestrator for KINDS.AGENT_PACKET — both entrypoints (disk + bytes) route here
2549
+ // through verifyParsedArtifact, so the two paths' verdicts are one code path (deep-equal by
2550
+ // construction). Precedence mirrors the producer's `vh agent verify`: event/leaf/head/counts faults
2551
+ // (naming the seq) dominate; then head binding, signature genuineness, and the vendor pin.
2552
+ function verifyAgentPacketArtifact({ artifact, obj, pinned }) {
2553
+ const { signedHead } = validateAgentPacketStructure(obj); // throws IOError on a malformed/foreign packet
2554
+ const fileResult = verifyAgentSeal(obj);
2555
+ const agent = fileResult.agent;
2556
+
2557
+ const signed = obj.headAttestation !== undefined;
2558
+ let recoveredSigner = null;
2559
+ let claimedSigner = null;
2560
+ let signatureOk = null;
2561
+ let signerMatchesVendor = null;
2562
+ let headBound = null;
2563
+ if (signed) {
2564
+ claimedSigner = obj.headAttestation.signature.signer; // lowercase, structurally enforced
2565
+ recoveredSigner = tryRecover(obj.headAttestation.attestation, obj.headAttestation.signature.signature);
2566
+ signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
2567
+ if (agent.recomputedHead != null) {
2568
+ // The signature must vouch for THIS session's RECOMPUTED head — a signature pasted from a
2569
+ // different session recovers fine but binds a different { size, root }.
2570
+ headBound =
2571
+ signedHead.embeddedHead.size === agent.recomputedHead.size &&
2572
+ signedHead.embeddedHead.root === agent.recomputedHead.root;
2573
+ }
2574
+ if (signatureOk && pinned != null) signerMatchesVendor = recoveredSigner === pinned;
2575
+ }
2576
+
2577
+ let accepted = true;
2578
+ let reason = "OK";
2579
+ if (!fileResult.filesOk) {
2580
+ accepted = false;
2581
+ reason = fileResult.reasonKind;
2582
+ } else if (signed && headBound === false) {
2583
+ accepted = false;
2584
+ reason = "head_not_bound";
2585
+ agent.reason = "HEAD_NOT_BOUND";
2586
+ } else if (signed && !signatureOk) {
2587
+ accepted = false;
2588
+ reason = "bad_signature";
2589
+ agent.reason = "SIGNATURE_FORGED";
2590
+ } else if (signed && pinned != null && signerMatchesVendor !== true) {
2591
+ accepted = false;
2592
+ reason = "wrong_issuer";
2593
+ agent.reason = "WRONG_VENDOR";
2594
+ } else if (!signed && pinned != null) {
2595
+ // Fail-closed pin: a stripped signature can never pass a pinned verify.
2596
+ accepted = false;
2597
+ reason = "unsigned_cannot_pin_vendor";
2598
+ agent.reason = "NOT_SIGNED";
2599
+ }
2600
+
2601
+ const result = {
2602
+ artifact,
2603
+ kind: KINDS.AGENT_PACKET,
2604
+ payloadKind: KINDS.AGENT_PACKET,
2605
+ signed,
2606
+ verdict: accepted ? "OK" : "REJECTED",
2607
+ reason,
2608
+ accepted,
2609
+ recoveredSigner,
2610
+ claimedSigner,
2611
+ pinnedVendor: pinned,
2612
+ signatureOk,
2613
+ signerMatchesVendor,
2614
+ sealedRoot: fileResult.sealedRoot,
2615
+ recomputedRoot: fileResult.recomputedRoot,
2616
+ rootMatches: fileResult.rootMatches,
2617
+ counts: {
2618
+ matched: fileResult.matched.length,
2619
+ changed: fileResult.changed.length,
2620
+ missing: 0,
2621
+ escaped: 0,
2622
+ unexpected: 0,
2623
+ },
2624
+ matched: fileResult.matched,
2625
+ changed: fileResult.changed,
2626
+ missing: [],
2627
+ escaped: [],
2628
+ unexpected: [],
2629
+ agent,
2630
+ note: TRUST_NOTE,
2631
+ };
2632
+ return { result, code: accepted ? EXIT.OK : EXIT.REJECTED };
2633
+ }
2634
+
2635
+ // ---------------------------------------------------------------------------
2636
+ // The core verify orchestration over an ALREADY-PARSED artifact object + an injected file source. This
2637
+ // is the ONE engine BOTH entrypoints drive — `verifyArtifact` (disk: the CLI contract, byte-identical to
2638
+ // before this seam existed) and `verifyArtifactFromBytes` (in-memory map). It auto-detects the artifact
2639
+ // kind, decodes a signed container (recovering + pinning the signer), re-derives the root from
2640
+ // referenced bytes, and assembles a deterministic verdict. PURE: every read goes through `readEntry`.
2641
+ // Returns { result, code } — code is the EXIT-contract integer.
2642
+ // ---------------------------------------------------------------------------
2643
+
2644
+ function verifyParsedArtifact({ artifact, obj, vendor, readEntry }) {
2645
+ const kind = obj.kind;
2646
+ const pinned = vendor != null ? normalizeAddress(vendor, "--vendor") : null;
2647
+
2648
+ // AGENT-SESSION packet (T-68.3): SELF-CONTAINED — no sibling bytes, its own leaf/root convention and
2649
+ // its own in-packet signed head. Routed to the dedicated orchestrator above (`readEntry` unused).
2650
+ if (kind === KINDS.AGENT_PACKET) {
2651
+ return verifyAgentPacketArtifact({ artifact, obj, pinned });
2652
+ }
2653
+
2654
+ // Detect signed vs bare and the underlying payload kind. A signed container wraps the embedded payload.
2655
+ let signed = false;
2656
+ let recoveredSigner = null;
2657
+ let claimedSigner = null;
2658
+ let signatureOk = null; // null = no signature on this artifact
2659
+ let payload = obj; // the (possibly embedded) thing whose root we re-derive
2660
+ let payloadKind = kind;
2661
+
2662
+ if (
2663
+ kind === KINDS.EVIDENCE_SEAL_SIGNED ||
2664
+ kind === KINDS.TRUST_SEAL_SIGNED ||
2665
+ kind === KINDS.DATASET_ATTESTATION_SIGNED ||
2666
+ kind === KINDS.DATASET_ATTESTATION_TIMESTAMPED
2667
+ ) {
2668
+ signed = true;
2669
+ const dec = decodeSigned(obj);
2670
+ payload = dec.embedded;
2671
+ payloadKind = dec.embedded.kind;
2672
+ claimedSigner = dec.claimedSigner;
2673
+ recoveredSigner = tryRecover(dec.message, dec.signature);
2674
+ // signatureOk: the signature recovers AND matches the CLAIMED signer recorded in the container.
2675
+ signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
2676
+ } else if (!Object.values(KINDS).includes(kind)) {
2677
+ throw new UsageError(
2678
+ `unrecognized artifact kind: ${JSON.stringify(kind)} ` +
2679
+ "(verify-vh understands evidence seals, reconciliation seals, dataset attestations, and proof bundles)"
2680
+ );
2681
+ }
2682
+
2683
+ // Re-derive the root from the referenced bytes per the (underlying) kind.
2684
+ let fileResult;
2685
+ if (payloadKind === KINDS.EVIDENCE_SEAL) {
2686
+ fileResult = verifyEvidenceSealWith(payload, readEntry);
2687
+ } else if (payloadKind === KINDS.TRUST_SEAL) {
2688
+ fileResult = verifyTrustSealWith(payload, readEntry);
2689
+ } else if (payloadKind === KINDS.DATASET_ATTESTATION) {
2690
+ fileResult = verifyDatasetAttestation(payload);
2691
+ } else if (payloadKind === KINDS.PROOF) {
2692
+ fileResult = verifyProofBundle(payload);
2693
+ } else {
2694
+ throw new UsageError(
2695
+ `unrecognized embedded artifact kind: ${JSON.stringify(payloadKind)}`
2696
+ );
2697
+ }
2698
+
2699
+ // --- Decide the verdict + the deterministic reason. ---
2700
+ // Precedence: a structural file tamper (CHANGED/MISSING/root mismatch) is a clean REJECTED. For a
2701
+ // SIGNED artifact, a broken signature is `bad_signature`; a recovered signer that does not equal the
2702
+ // pinned --vendor is `wrong_issuer`. Both are clean REJECTED verdicts (exit 3), never a crash.
2703
+ let reason = "OK";
2704
+ let accepted = true;
2705
+
2706
+ const escaped = fileResult.escaped || [];
2707
+ if (!fileResult.filesOk) {
2708
+ accepted = false;
2709
+ // path_escape DOMINATES: an artifact that tries to read outside its source is malicious by
2710
+ // construction (the threat model is a hostile producer probing the counterparty's filesystem), so it
2711
+ // is reported FIRST — never as a benign CHANGED/MISSING, and never with a leaked out-of-tree content
2712
+ // hash.
2713
+ if (escaped.length > 0) reason = "path_escape";
2714
+ else if (fileResult.changed.length > 0) reason = "CHANGED";
2715
+ else if (fileResult.missing.length > 0) reason = "MISSING";
2716
+ else if (fileResult.unexpected.length > 0) reason = "UNEXPECTED";
2717
+ else reason = "root_mismatch";
2718
+ }
2719
+
2720
+ // Signature checks (only for signed artifacts). A bad signature dominates the "issuer" check (you
2721
+ // cannot trust an issuer you cannot recover).
2722
+ let signerMatchesVendor = null;
2723
+ if (signed) {
2724
+ if (!signatureOk) {
2725
+ accepted = false;
2726
+ // bad_signature is the dominant reason ONLY if files were otherwise OK; if a file also changed we
2727
+ // still surface bad_signature because the signature is the trust root of a signed artifact.
2728
+ reason = "bad_signature";
2729
+ } else if (pinned != null) {
2730
+ signerMatchesVendor = recoveredSigner === pinned;
2731
+ if (!signerMatchesVendor) {
2732
+ accepted = false;
2733
+ // wrong_issuer only when the signature itself is sound but the signer is not the pinned vendor.
2734
+ if (fileResult.filesOk) reason = "wrong_issuer";
2735
+ else if (reason === "OK") reason = "wrong_issuer";
2736
+ }
2737
+ }
2738
+ } else if (pinned != null) {
2739
+ // A --vendor pin on an UNSIGNED artifact cannot be satisfied (there is no signer to recover); this is
2740
+ // a clean REJECTED wrong_issuer-style verdict so a CI gate expecting a signed-by-vendor artifact fails.
2741
+ accepted = false;
2742
+ reason = "unsigned_cannot_pin_vendor";
2743
+ }
2744
+
2745
+ const verdict = accepted ? "OK" : "REJECTED";
2746
+ const code = accepted ? EXIT.OK : EXIT.REJECTED;
2747
+
2748
+ const result = {
2749
+ artifact,
2750
+ kind,
2751
+ payloadKind,
2752
+ signed,
2753
+ verdict,
2754
+ reason,
2755
+ accepted,
2756
+ recoveredSigner,
2757
+ claimedSigner,
2758
+ pinnedVendor: pinned,
2759
+ signatureOk,
2760
+ signerMatchesVendor,
2761
+ sealedRoot: fileResult.sealedRoot,
2762
+ recomputedRoot: fileResult.recomputedRoot,
2763
+ rootMatches: fileResult.rootMatches,
2764
+ counts: {
2765
+ matched: fileResult.matched.length,
2766
+ changed: fileResult.changed.length,
2767
+ missing: fileResult.missing.length,
2768
+ escaped: escaped.length,
2769
+ unexpected: fileResult.unexpected.length,
2770
+ },
2771
+ matched: fileResult.matched,
2772
+ changed: fileResult.changed,
2773
+ missing: fileResult.missing,
2774
+ escaped,
2775
+ unexpected: fileResult.unexpected,
2776
+ note: TRUST_NOTE,
2777
+ };
2778
+ if (fileResult.identityOnly) result.identityOnly = true;
2779
+ if (fileResult.proof) result.proof = fileResult.proof;
2780
+
2781
+ return { result, code };
2782
+ }
2783
+
2784
+ // ---------------------------------------------------------------------------
2785
+ // The PURE revocation fold for the bytes path. Semantically identical to revocation.loadAndApply (the
2786
+ // disk integration) once the entries are in hand: resolve the as-of instant (defaulting to nowISO),
2787
+ // normalize the caller-supplied revocations input (a JSON string, a container object, or an array of
2788
+ // either), fold the decision onto the result, and recompute the exit code. Uses ONLY the pure decision
2789
+ // functions (./lib/revocation-core.js via the revocation re-exports) — never the fs-backed reader.
2790
+ // ---------------------------------------------------------------------------
2791
+
2792
+ function applyRevocationsDecision(result, revocationsInput, asOf, nowISO) {
2793
+ const resolved = revocation.resolveAsOf(asOf, nowISO);
2794
+ const entries = revocation.normalizeRevocationsInput(revocationsInput);
2795
+ const downgraded = revocation.applyToVerifyResult({ result, revocations: entries, asOf: resolved.asOf });
2796
+ downgraded.trustAsOfDefaulted = resolved.defaulted;
2797
+ return { result: downgraded, code: downgraded.accepted ? EXIT.OK : EXIT.REJECTED };
2798
+ }
2799
+
2800
+ // ---------------------------------------------------------------------------
2801
+ // THE IN-MEMORY FILE SOURCE + BYTES ENTRYPOINT (T-66.1).
2802
+ //
2803
+ // `verifyArtifactFromBytes({ artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName })`
2804
+ // drives the EXACT engine above over caller-supplied bytes:
2805
+ // * `artifactText` — the artifact JSON as a STRING (what a browser read out of a dropped file);
2806
+ // * `files` — a plain `{ relPath: Uint8Array|Buffer }` map of the packet's referenced bytes;
2807
+ // * `vendor` — optional 0x-address pin (same semantics as `--vendor`);
2808
+ // * `revocationsText` — optional revocations input (JSON text / container / array; same semantics as
2809
+ // the CONTENT of a `--revocations` file), with optional `asOf` (canonical ISO instant) + `nowISO`;
2810
+ // * `artifactName` — optional label used verbatim as `result.artifact` (defaults below).
2811
+ //
2812
+ // CONTRACT — NEVER THROWS. Hostile input (non-JSON artifact text, an oversized / absolute / `..` map
2813
+ // key, a non-bytes map value, a malformed vendor or asOf) is NAMED-rejected: the return value is
2814
+ // { ok, code, result, error }
2815
+ // where a computed verdict carries `result` (the SAME structured shape `verifyArtifact` returns — the
2816
+ // two are DEEP-EQUAL on identical inputs) + `error: null`, and an input problem carries `result: null` +
2817
+ // `error: { name: "UsageError"|"IOError", code, message }` with the exact defect named. The verdict
2818
+ // classes (missing / extra / content-mismatch / wrong-vendor / tampered-signature / path_escape /
2819
+ // revoked) derive from the MAP exactly as the disk path derives them from the directory.
2820
+ // ---------------------------------------------------------------------------
2821
+
2822
+ // The largest relPath key the in-memory map accepts. Sealed relPaths are short; a multi-kilobyte "key"
2823
+ // is hostile input (an attempted resource-exhaustion / log-flooding vector), rejected by NAME up front.
2824
+ const MAX_RELPATH_CHARS = 4096;
2825
+
2826
+ // PURE string-level confinement for an in-memory relPath — the map-source mirror of the disk source's
2827
+ // string checks (absolute anywhere, or any `..` traversal component, is hostile). Windows-style drive
2828
+ // and UNC prefixes are treated as absolute here too: an in-memory map NEVER has a legitimate absolute
2829
+ // key, whatever platform authored the artifact.
2830
+ function isTraversalOrAbsoluteRelPath(relPath) {
2831
+ if (typeof relPath !== "string" || relPath.length === 0) return true;
2832
+ if (relPath.charAt(0) === "/" || relPath.charAt(0) === "\\") return true;
2833
+ if (/^[A-Za-z]:[\\/]/.test(relPath)) return true;
2834
+ if (relPath.split(/[\\/]/).includes("..")) return true;
2835
+ return false;
2836
+ }
2837
+
2838
+ // Validate the caller's `{ relPath: bytes }` map SHAPE up front so a hostile map is NAMED-rejected
2839
+ // before any verification work (and before any key is dereferenced). Throws UsageError; the entrypoint
2840
+ // converts that into the structured `{ error }` return — never an uncaught throw.
2841
+ function validateFilesMap(files) {
2842
+ if (files == null || typeof files !== "object" || Array.isArray(files)) {
2843
+ throw new UsageError(
2844
+ "verifyArtifactFromBytes requires `files` as a plain { relPath: Uint8Array|Buffer } object map"
2845
+ );
2846
+ }
2847
+ for (const key of Object.keys(files)) {
2848
+ if (key.length === 0) {
2849
+ throw new UsageError("files map contains an empty relPath key");
2850
+ }
2851
+ if (key.length > MAX_RELPATH_CHARS) {
2852
+ throw new UsageError(
2853
+ `files map key exceeds ${MAX_RELPATH_CHARS} characters (oversized relPath, starts: ` +
2854
+ `${JSON.stringify(key.slice(0, 64))})`
2855
+ );
2856
+ }
2857
+ if (isTraversalOrAbsoluteRelPath(key)) {
2858
+ throw new UsageError(
2859
+ `files map key is not a confined relative path: ${JSON.stringify(key.slice(0, 256))}`
2860
+ );
2861
+ }
2862
+ const v = files[key];
2863
+ if (!(v instanceof Uint8Array)) {
2864
+ throw new UsageError(
2865
+ `files map value for ${JSON.stringify(key.slice(0, 256))} must be a Uint8Array/Buffer of the file's bytes`
2866
+ );
2867
+ }
2868
+ }
2869
+ }
2870
+
2871
+ // The in-memory `readEntry` source over an (already-validated) map: a hostile relPath from the ARTIFACT
2872
+ // is `escaped` (the same string-level rules as the disk source — so absolute/`..` seal entries produce
2873
+ // the identical path_escape verdict), an absent key is `missing`, and a present key answers its bytes.
2874
+ // Lookups use an own-property check so `__proto__`/`constructor` style keys can never smuggle
2875
+ // prototype-chain values in as file bytes.
2876
+ function makeMapReadEntry(files) {
2877
+ return function readEntry(relPath) {
2878
+ if (isTraversalOrAbsoluteRelPath(relPath)) return { status: "escaped" };
2879
+ if (!Object.prototype.hasOwnProperty.call(files, relPath)) return { status: "missing" };
2880
+ return { status: "ok", bytes: files[relPath] };
2881
+ };
2882
+ }
2883
+
2884
+ function verifyArtifactFromBytes(params) {
2885
+ try {
2886
+ if (params == null || typeof params !== "object" || Array.isArray(params)) {
2887
+ throw new UsageError(
2888
+ "verifyArtifactFromBytes requires a params object: " +
2889
+ "{ artifactText, files, vendor?, revocationsText?, asOf?, nowISO?, artifactName? }"
2890
+ );
2891
+ }
2892
+ const { artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName } = params;
2893
+ if (typeof artifactText !== "string") {
2894
+ throw new UsageError("verifyArtifactFromBytes requires `artifactText` (the artifact JSON as a string)");
2895
+ }
2896
+ validateFilesMap(files);
2897
+
2898
+ // Mirror the CLI's flag-shape gate (parseArgs): asOf only means something alongside revocations, and
2899
+ // must be a canonical ISO-8601 UTC instant — a malformed one is a NAMED usage rejection up front,
2900
+ // never a mid-verify throw.
2901
+ if (asOf !== undefined && asOf !== null && (revocationsText === undefined || revocationsText === null)) {
2902
+ throw new UsageError(
2903
+ "asOf requires revocationsText (it pins the instant the revocation decision is made AS OF)"
2904
+ );
2905
+ }
2906
+ if (asOf !== undefined && asOf !== null) {
2907
+ const ms = Date.parse(asOf);
2908
+ if (
2909
+ typeof asOf !== "string" ||
2910
+ !revocation.ISO_INSTANT_RE.test(asOf) ||
2911
+ Number.isNaN(ms) ||
2912
+ new Date(ms).toISOString() !== asOf
2913
+ ) {
2914
+ throw new UsageError(
2915
+ `invalid asOf: ${String(asOf)} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
2916
+ );
2917
+ }
2918
+ }
2919
+
2920
+ const label = artifactName != null ? String(artifactName) : "(in-memory artifact)";
2921
+ let obj;
2922
+ try {
2923
+ obj = JSON.parse(artifactText);
2924
+ } catch (e) {
2925
+ throw new IOError(`artifact ${label} is not valid JSON: ${e.message}`);
2926
+ }
2927
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
2928
+ throw new IOError(`artifact ${label} must be a JSON object`);
2929
+ }
2930
+
2931
+ const { result, code } = verifyParsedArtifact({
2932
+ artifact: label,
2933
+ obj,
2934
+ vendor,
2935
+ readEntry: makeMapReadEntry(files),
2936
+ });
2937
+
2938
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF, from caller-supplied revocations INPUT (never a
2939
+ // filesystem read). Same downgrade math as the disk path's revocation.loadAndApply, so the two
2940
+ // paths' results stay deep-equal on identical inputs.
2941
+ if (revocationsText !== undefined && revocationsText !== null) {
2942
+ let applied;
2943
+ try {
2944
+ applied = applyRevocationsDecision(result, revocationsText, asOf, nowISO || new Date().toISOString());
2945
+ } catch (e) {
2946
+ // A non-JSON / wrong-shape revocations input is the bytes-path analogue of an unreadable
2947
+ // --revocations file: a NAMED IO-class rejection, never a silently-skipped downgrade.
2948
+ throw new IOError(`cannot evaluate revocations: ${e.message}`);
2949
+ }
2950
+ return { ok: applied.result.accepted, code: applied.code, result: applied.result, error: null };
2951
+ }
2952
+
2953
+ return { ok: result.accepted, code, result, error: null };
2954
+ } catch (e) {
2955
+ const isUsage = e instanceof UsageError;
2956
+ const code = isUsage ? EXIT.USAGE : EXIT.IO;
2957
+ return {
2958
+ ok: false,
2959
+ code,
2960
+ result: null,
2961
+ error: {
2962
+ name: isUsage ? "UsageError" : "IOError",
2963
+ code,
2964
+ message: String(e && e.message ? e.message : e),
2965
+ },
2966
+ };
2967
+ }
2968
+ }
2969
+
2970
+ // ============================= END VERIFY-VH PURE ENGINE (T-66.1) =============================
2971
+
2972
+ // ---------------------------------------------------------------------------
2973
+ // Argument parsing.
2974
+ // SINGLE-ARTIFACT (the original, byte-for-byte unchanged contract):
2975
+ // verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--json]
2976
+ // BATCH/MANIFEST (T-33.1 — one invocation gates EVERY release artifact, one CI exit code):
2977
+ // verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--json]
2978
+ // verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--json]
2979
+ // Batch mode is a pure SUPERSET: it engages ONLY when more than one positional <artifact> is given OR
2980
+ // `--manifest <file>` is supplied. A lone positional with no --manifest takes the identical single path,
2981
+ // so existing callers/tests never shift. A top-level `--vendor`/`--dir` is a DEFAULT each entry inherits
2982
+ // unless the entry (a manifest line) overrides it with its own per-entry `--vendor`/`--dir`.
2983
+ // ---------------------------------------------------------------------------
2984
+
2985
+ function parseArgs(argv) {
2986
+ const opts = {
2987
+ artifact: undefined,
2988
+ vendor: undefined,
2989
+ dir: undefined,
2990
+ json: false,
2991
+ help: false,
2992
+ manifest: undefined,
2993
+ revocations: undefined,
2994
+ asOf: undefined,
2995
+ _pos: [],
2996
+ };
2997
+ for (let i = 0; i < argv.length; i++) {
2998
+ const a = argv[i];
2999
+ const need = (flag) => {
3000
+ const v = argv[++i];
3001
+ if (v === undefined) throw new UsageError(`${flag} requires a value`);
3002
+ return v;
3003
+ };
3004
+ switch (a) {
3005
+ case "--vendor":
3006
+ opts.vendor = need("--vendor");
3007
+ break;
3008
+ case "--dir":
3009
+ opts.dir = need("--dir");
3010
+ break;
3011
+ case "--manifest":
3012
+ opts.manifest = need("--manifest");
3013
+ break;
3014
+ case "--revocations":
3015
+ opts.revocations = need("--revocations");
3016
+ break;
3017
+ case "--as-of":
3018
+ opts.asOf = need("--as-of");
3019
+ break;
3020
+ case "--json":
3021
+ opts.json = true;
3022
+ break;
3023
+ case "-h":
3024
+ case "--help":
3025
+ case "help":
3026
+ opts.help = true;
3027
+ break;
3028
+ default:
3029
+ if (a && a.startsWith("--")) throw new UsageError(`unknown flag: ${a}`);
3030
+ opts._pos.push(a);
3031
+ }
3032
+ }
3033
+ // batch === any path that aggregates MULTIPLE per-artifact verdicts under ONE exit code:
3034
+ // either a --manifest file, or more than one repeated positional <artifact>.
3035
+ opts.batch = opts.manifest !== undefined || opts._pos.length > 1;
3036
+ if (opts.manifest !== undefined && opts._pos.length > 0) {
3037
+ throw new UsageError(
3038
+ `--manifest <file> lists the artifacts; do not also pass positional <artifact> args (got: ${opts._pos[0]})`
3039
+ );
3040
+ }
3041
+ // Validate the OPTIONAL recipient-side trust-decision flags (--revocations / --as-of, T-51.4) SHAPE up
3042
+ // front so a malformed --as-of (or --as-of without --revocations) is a usage error (2), never a runtime
3043
+ // throw mid-verify. Mirrors `vh evidence verify-signed`'s validateAsOfFlags so the two stacks reject the
3044
+ // same inputs the same way.
3045
+ if (opts.asOf !== undefined && !opts.revocations) {
3046
+ throw new UsageError(
3047
+ "--as-of requires --revocations (it pins the instant the revocation decision is made AS OF)"
3048
+ );
3049
+ }
3050
+ if (opts.asOf !== undefined) {
3051
+ const ms = Date.parse(opts.asOf);
3052
+ if (
3053
+ typeof opts.asOf !== "string" ||
3054
+ !revocation.ISO_INSTANT_RE.test(opts.asOf) ||
3055
+ Number.isNaN(ms) ||
3056
+ new Date(ms).toISOString() !== opts.asOf
3057
+ ) {
3058
+ throw new UsageError(
3059
+ `invalid --as-of: ${opts.asOf} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
3060
+ );
3061
+ }
3062
+ }
3063
+ // Preserve the SINGLE-artifact contract verbatim: exactly one positional and no --manifest.
3064
+ opts.artifact = opts._pos[0];
3065
+ return opts;
3066
+ }
3067
+
3068
+ // ---------------------------------------------------------------------------
3069
+ // Manifest parsing. A manifest is a newline list OR a JSON array of artifact entries; each entry names an
3070
+ // artifact path and may carry a per-entry `--vendor`/`--dir` that overrides the top-level defaults.
3071
+ //
3072
+ // NEWLINE form — one entry per line, shell-style tokens. Blank lines and `#` comments are skipped:
3073
+ // releases/a.vhevidence.json
3074
+ // releases/b.vhseal --vendor 0xabc... --dir ./out
3075
+ // JSON form — an array of strings and/or objects:
3076
+ // ["a.vhevidence.json", {"artifact":"b.vhseal","vendor":"0xabc...","dir":"./out"}]
3077
+ //
3078
+ // Paths in the manifest resolve relative to the MANIFEST FILE's own directory (a release ships its
3079
+ // manifest next to its artifacts), unless the path is given a per-entry `--dir` for its SIBLINGS — note
3080
+ // `dir` localizes where an artifact's SIBLING files are read, exactly as the single-artifact `--dir` does;
3081
+ // the artifact path itself resolves against the manifest dir. The manifest is parsed in-process; NO new
3082
+ // crypto and NO network — it is a list, nothing more.
3083
+ // ---------------------------------------------------------------------------
3084
+
3085
+ // Minimal whitespace tokenizer for a newline-form manifest line. No quoting support is needed (artifact
3086
+ // paths and 0x addresses contain no spaces); a token is any run of non-whitespace.
3087
+ function tokenizeManifestLine(line) {
3088
+ return line.split(/\s+/).filter((t) => t.length > 0);
3089
+ }
3090
+
3091
+ function parseManifestLine(line, lineNo) {
3092
+ const toks = tokenizeManifestLine(line);
3093
+ const entry = { artifact: undefined, vendor: undefined, dir: undefined };
3094
+ for (let i = 0; i < toks.length; i++) {
3095
+ const t = toks[i];
3096
+ const need = (flag) => {
3097
+ const v = toks[++i];
3098
+ if (v === undefined) throw new UsageError(`manifest line ${lineNo}: ${flag} requires a value`);
3099
+ return v;
3100
+ };
3101
+ if (t === "--vendor") entry.vendor = need("--vendor");
3102
+ else if (t === "--dir") entry.dir = need("--dir");
3103
+ else if (t.startsWith("--")) throw new UsageError(`manifest line ${lineNo}: unknown flag: ${t}`);
3104
+ else if (entry.artifact === undefined) entry.artifact = t;
3105
+ else throw new UsageError(`manifest line ${lineNo}: unexpected extra token: ${t}`);
3106
+ }
3107
+ if (entry.artifact === undefined) {
3108
+ throw new UsageError(`manifest line ${lineNo}: no artifact path`);
3109
+ }
3110
+ return entry;
3111
+ }
3112
+
3113
+ function parseManifest(text, manifestPath) {
3114
+ const trimmed = text.replace(/^/, "").trim();
3115
+ const entries = [];
3116
+ if (trimmed.startsWith("[")) {
3117
+ // JSON array form.
3118
+ let arr;
3119
+ try {
3120
+ arr = JSON.parse(trimmed);
3121
+ } catch (e) {
3122
+ throw new IOError(`manifest ${manifestPath} is not valid JSON: ${e.message}`);
3123
+ }
3124
+ if (!Array.isArray(arr)) throw new IOError(`manifest ${manifestPath} JSON must be an array of entries`);
3125
+ arr.forEach((raw, idx) => {
3126
+ if (typeof raw === "string") {
3127
+ entries.push({ artifact: raw, vendor: undefined, dir: undefined });
3128
+ } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
3129
+ if (typeof raw.artifact !== "string" || raw.artifact.length === 0) {
3130
+ throw new IOError(`manifest ${manifestPath} entry ${idx}: "artifact" must be a non-empty string`);
3131
+ }
3132
+ entries.push({
3133
+ artifact: raw.artifact,
3134
+ vendor: raw.vendor != null ? String(raw.vendor) : undefined,
3135
+ dir: raw.dir != null ? String(raw.dir) : undefined,
3136
+ });
3137
+ } else {
3138
+ throw new IOError(`manifest ${manifestPath} entry ${idx} must be a string or { artifact, vendor?, dir? }`);
3139
+ }
3140
+ });
3141
+ } else {
3142
+ // Newline form: one entry per non-blank, non-comment line.
3143
+ const lines = trimmed.split(/\r?\n/);
3144
+ for (let i = 0; i < lines.length; i++) {
3145
+ const line = lines[i];
3146
+ const bare = line.trim();
3147
+ if (bare.length === 0 || bare.startsWith("#")) continue;
3148
+ entries.push(parseManifestLine(line, i + 1));
3149
+ }
3150
+ }
3151
+ if (entries.length === 0) {
3152
+ throw new UsageError(`manifest ${manifestPath} lists no artifacts`);
3153
+ }
3154
+ return entries;
3155
+ }
3156
+
3157
+ // ---------------------------------------------------------------------------
3158
+ // THE DISK FILE SOURCE — the CLI's `readEntry` implementation, carrying the FULL path-confinement
3159
+ // discipline the disk path always had (byte-identical classification):
3160
+ // (1) string-level confinement, BEFORE any filesystem access: an ABSOLUTE relPath, or any relPath with
3161
+ // a `..` path COMPONENT, is REJECTED unread;
3162
+ // (2) resolved-path confinement: a resolved path that ESCAPES baseDir (string-wise, against the
3163
+ // realpath of baseDir) is REJECTED;
3164
+ // (3) post-open symlink confinement: after opening a present file we realpath it and re-assert
3165
+ // containment, defeating a sibling that is a SYMLINK pointing out of baseDir (fs.readFileSync
3166
+ // follows symlinks regardless of the string check) — the just-read bytes are DROPPED, never hashed.
3167
+ // ---------------------------------------------------------------------------
3168
+
3169
+ // True when a resolved absolute path escapes the (already realpath'd) base directory. A path equal to the
3170
+ // base or under it does not escape; anything that path.relative()'s to "" / ".." / an absolute drive is out.
3171
+ function escapesBase(baseReal, abs) {
3172
+ const rel = path.relative(baseReal, abs);
3173
+ return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
3174
+ }
3175
+
3176
+ function makeDiskReadEntry(baseDir) {
3177
+ // Anchor confinement on the REALPATH of baseDir so a symlinked baseDir itself (e.g. /tmp -> /private/tmp
3178
+ // on macOS) does not spuriously trip the containment check on otherwise-legitimate siblings.
3179
+ let baseReal;
3180
+ try {
3181
+ baseReal = fs.realpathSync(baseDir);
3182
+ } catch (_) {
3183
+ baseReal = path.resolve(baseDir);
3184
+ }
3185
+
3186
+ return function readEntry(relPath) {
3187
+ // (1) String-level confinement, BEFORE any filesystem access.
3188
+ if (
3189
+ typeof relPath !== "string" ||
3190
+ relPath.length === 0 ||
3191
+ path.isAbsolute(relPath) ||
3192
+ relPath.split(/[\\/]/).includes("..")
3193
+ ) {
3194
+ return { status: "escaped" };
3195
+ }
3196
+
3197
+ // (2) Resolved-path confinement: the resolved absolute path must stay under baseReal.
3198
+ const abs = path.resolve(baseDir, relPath);
3199
+ if (escapesBase(baseReal, abs)) {
3200
+ return { status: "escaped" };
3201
+ }
3202
+
3203
+ let bytes;
3204
+ try {
3205
+ bytes = fs.readFileSync(abs);
3206
+ } catch (_) {
3207
+ return { status: "missing" };
3208
+ }
3209
+
3210
+ // (3) Post-open symlink confinement.
3211
+ let real;
3212
+ try {
3213
+ real = fs.realpathSync(abs);
3214
+ } catch (_) {
3215
+ real = abs;
3216
+ }
3217
+ if (escapesBase(baseReal, real)) {
3218
+ return { status: "escaped" };
3219
+ }
3220
+
3221
+ return { status: "ok", bytes };
3222
+ };
3223
+ }
3224
+
3225
+ // The original disk-shaped helpers, kept with their exact signatures + behavior (thin wrappers over the
3226
+ // engine with a disk source). `relResolver` was always accepted-and-unused on classifyFiles; retained so
3227
+ // the signature does not shift.
3228
+ function classifyFiles(sealedEntries, baseDir, relResolver) { // eslint-disable-line no-unused-vars
3229
+ return classifyFilesWith(sealedEntries, makeDiskReadEntry(baseDir));
3230
+ }
3231
+
3232
+ function verifyEvidenceSeal(seal, baseDir) {
3233
+ return verifyEvidenceSealWith(seal, makeDiskReadEntry(baseDir));
3234
+ }
3235
+
3236
+ function verifyTrustSeal(seal, baseDir) {
3237
+ return verifyTrustSealWith(seal, makeDiskReadEntry(baseDir));
3238
+ }
3239
+
3240
+ // ---------------------------------------------------------------------------
3241
+ // The DISK verify entrypoint — the original CLI contract, byte-identical: reads + JSON-parses the
3242
+ // artifact, then drives the SAME pure engine with the disk file source. Returns { result, code }.
3243
+ // ---------------------------------------------------------------------------
3244
+
3245
+ function verifyArtifact(opts) {
3246
+ if (!opts.artifact) throw new UsageError("verify-vh requires an <artifact>");
3247
+
3248
+ const artifactPath = path.resolve(opts.artifact);
3249
+ let text;
3250
+ try {
3251
+ text = fs.readFileSync(artifactPath, "utf8");
3252
+ } catch (e) {
3253
+ throw new IOError(`cannot read artifact ${opts.artifact}: ${e.message}`);
3254
+ }
3255
+ let obj;
3256
+ try {
3257
+ obj = JSON.parse(text);
3258
+ } catch (e) {
3259
+ throw new IOError(`artifact ${opts.artifact} is not valid JSON: ${e.message}`);
3260
+ }
3261
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
3262
+ throw new IOError(`artifact ${opts.artifact} must be a JSON object`);
3263
+ }
3264
+
3265
+ // The base directory siblings resolve against: --dir override else the artifact's own directory.
3266
+ const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(artifactPath);
3267
+
3268
+ const { result, code } = verifyParsedArtifact({
3269
+ artifact: opts.artifact,
3270
+ obj,
3271
+ vendor: opts.vendor,
3272
+ readEntry: makeDiskReadEntry(baseDir),
3273
+ });
3274
+
3275
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF (EPIC-51 / T-51.4). Runs ONLY under --revocations — with no
3276
+ // flag the result + code are byte-identical to the pre-T-51.4 baseline (regression-pinned). A signer
3277
+ // revoked-before-as-of downgrades an otherwise-ACCEPTED artifact to REVOKED (exit 3); a later-dated
3278
+ // revocation is informational; a forged/tampered/third-party one is ignored with a warning. OFFLINE /
3279
+ // key-free on the read side; the revocations file/dir is the ONLY new I/O. This reaches the SAME downgrade
3280
+ // `vh ... verify-signed --revocations` does, byte-for-byte on identical inputs.
3281
+ if (opts.revocations) {
3282
+ let applied;
3283
+ try {
3284
+ applied = revocation.loadAndApply({
3285
+ result,
3286
+ revocationsPath: opts.revocations,
3287
+ asOf: opts.asOf,
3288
+ nowISO: opts.nowISO || new Date().toISOString(),
3289
+ });
3290
+ } catch (e) {
3291
+ // A malformed --as-of is caught at parse time; here the only failures are an unreadable path or a
3292
+ // non-JSON single revocations file — a genuine IO error (exit 1), surfaced (never a stack), never a
3293
+ // silently-skipped downgrade.
3294
+ throw new IOError(`cannot evaluate --revocations ${opts.revocations}: ${e.message}`);
3295
+ }
3296
+ // A REVOKED decision flips an otherwise-ACCEPTED verdict to REVOKED (exit 3); an already-REJECTED verdict
3297
+ // is left rejected (the trust-as-of never upgrades). The trustAsOf block + defaulted flag ride along for
3298
+ // the renderer.
3299
+ const downgraded = applied.result;
3300
+ downgraded.trustAsOfDefaulted = applied.defaulted;
3301
+ const newCode = downgraded.accepted ? EXIT.OK : EXIT.REJECTED;
3302
+ return { result: downgraded, code: newCode };
3303
+ }
3304
+
3305
+ return { result, code };
3306
+ }
3307
+
3308
+ // ---------------------------------------------------------------------------
3309
+ // BATCH / MANIFEST orchestration (T-33.1). One invocation gates EVERY artifact a release produces and
3310
+ // returns ONE CI exit code. Each entry is verified READ-ONLY through the SAME `verifyArtifact` core (NO
3311
+ // new crypto, NO new artifact kind, path-escape/no-network guarantees preserved per entry); the per-entry
3312
+ // `--json` body is the IDENTICAL single-artifact shape, so there is no divergence to drift.
3313
+ //
3314
+ // AGGREGATE EXIT CONTRACT:
3315
+ // * exit 0 (OK) — and only if — EVERY artifact verifies (each accepted).
3316
+ // * exit 3 (REJECTED) — if ANY artifact is rejected (CHANGED/MISSING/bad_signature/wrong_issuer/…);
3317
+ // the report names WHICH artifact failed and why.
3318
+ // * exit 2 (USAGE) — a malformed flag / per-entry --vendor (raised before any verify runs).
3319
+ // * exit 1 (IO) — an artifact (or the manifest itself) is unreadable / not the expected shape.
3320
+ // Usage/IO are evaluated PER ENTRY and SHORT-CIRCUIT the whole run with the matching code, exactly as the
3321
+ // single-artifact path does — a release gate must not "pass" while one of its artifacts could not even be
3322
+ // read or parsed. The IO/USAGE code wins over a REJECTED tally (you cannot certify a batch you could not
3323
+ // fully evaluate).
3324
+ // ---------------------------------------------------------------------------
3325
+
3326
+ function buildBatchEntries(opts) {
3327
+ // Returns [{ artifact, vendor, dir }] with top-level --vendor/--dir applied as DEFAULTS each entry may
3328
+ // override. Artifact paths from a manifest resolve against the manifest file's own directory.
3329
+ if (opts.manifest !== undefined) {
3330
+ const manifestPath = path.resolve(opts.manifest);
3331
+ let text;
3332
+ try {
3333
+ text = fs.readFileSync(manifestPath, "utf8");
3334
+ } catch (e) {
3335
+ throw new IOError(`cannot read manifest ${opts.manifest}: ${e.message}`);
3336
+ }
3337
+ const manifestDir = path.dirname(manifestPath);
3338
+ return parseManifest(text, opts.manifest).map((e) => ({
3339
+ // The artifact path resolves relative to the manifest's directory (a release ships them together).
3340
+ artifact: path.resolve(manifestDir, e.artifact),
3341
+ // Per-entry --vendor/--dir override the top-level defaults; a --dir resolves against the manifest dir.
3342
+ vendor: e.vendor != null ? e.vendor : opts.vendor,
3343
+ dir: e.dir != null ? path.resolve(manifestDir, e.dir) : opts.dir,
3344
+ }));
3345
+ }
3346
+ // Repeated positional <artifact> args: each inherits the (single) top-level --vendor/--dir.
3347
+ return opts._pos.map((a) => ({ artifact: a, vendor: opts.vendor, dir: opts.dir }));
3348
+ }
3349
+
3350
+ function verifyBatch(opts) {
3351
+ const entries = buildBatchEntries(opts);
3352
+ const results = [];
3353
+ for (const e of entries) {
3354
+ // Verify each entry through the SAME core. A USAGE/IO problem with any single entry short-circuits the
3355
+ // whole batch with that code (the gate cannot certify a release it could not fully evaluate). The
3356
+ // top-level --revocations/--as-of (T-51.4) apply to EVERY entry as a default, so one revocations
3357
+ // file/dir gates a whole release's signed artifacts under one as-of instant.
3358
+ const { result } = verifyArtifact({
3359
+ artifact: e.artifact,
3360
+ vendor: e.vendor,
3361
+ dir: e.dir,
3362
+ revocations: opts.revocations,
3363
+ asOf: opts.asOf,
3364
+ nowISO: opts.nowISO,
3365
+ });
3366
+ results.push(result);
3367
+ }
3368
+ const total = results.length;
3369
+ const passed = results.filter((r) => r.accepted).length;
3370
+ const failed = total - passed;
3371
+ const ok = failed === 0;
3372
+ const aggregate = { ok, total, passed, failed, results };
3373
+ return { aggregate, code: ok ? EXIT.OK : EXIT.REJECTED };
3374
+ }
3375
+
3376
+ // ---------------------------------------------------------------------------
3377
+ // Human + JSON rendering.
3378
+ // ---------------------------------------------------------------------------
3379
+
3380
+ function renderHuman(r) {
3381
+ const L = [];
3382
+ L.push(TRUST_NOTE);
3383
+ L.push("");
3384
+ L.push(`# verify-vh — ${r.artifact}`);
3385
+ L.push(`kind: ${r.kind}`);
3386
+ if (r.payloadKind !== r.kind) L.push(`embedded kind: ${r.payloadKind}`);
3387
+ L.push(`signed: ${r.signed ? "yes" : "no"}`);
3388
+ if (r.signed) {
3389
+ L.push(`recovered signer:${r.recoveredSigner ? " " + r.recoveredSigner : " (unrecoverable)"}`);
3390
+ L.push(`claimed signer: ${r.claimedSigner}`);
3391
+ if (r.pinnedVendor != null) {
3392
+ L.push(`pinned --vendor: ${r.pinnedVendor}`);
3393
+ L.push(`signer matches vendor: ${r.signerMatchesVendor ? "yes" : "NO"}`);
3394
+ } else {
3395
+ L.push("(no --vendor pin: the recovered signer above is reported, not pinned)");
3396
+ }
3397
+ } else if (r.recoveredSigner == null && r.pinnedVendor != null) {
3398
+ L.push("note: --vendor was supplied but this artifact is UNSIGNED (no signer to pin)");
3399
+ }
3400
+ if (r.sealedRoot != null) L.push(`sealed root: ${r.sealedRoot}`);
3401
+ if (r.recomputedRoot != null) L.push(`recomputed root: ${r.recomputedRoot}`);
3402
+ if (r.rootMatches != null) L.push(`root matches: ${r.rootMatches ? "yes" : "NO"}`);
3403
+ if (r.identityOnly) {
3404
+ L.push("(identity-only artifact: it commits to a dataset root/digest, not a re-walkable file set)");
3405
+ }
3406
+ L.push(
3407
+ `files: ${r.counts.matched} matched, ${r.counts.changed} changed, ` +
3408
+ `${r.counts.missing} missing, ${r.counts.escaped || 0} rejected, ${r.counts.unexpected} unexpected`
3409
+ );
3410
+ // AGENT-SESSION packet block (T-68.3) — present ONLY for r.agent results, so every other kind's
3411
+ // output stays byte-identical.
3412
+ if (r.agent) {
3413
+ L.push(`declared head: { size: ${r.agent.head.size}, root: ${r.agent.head.root} }`);
3414
+ if (r.agent.counts) {
3415
+ L.push(
3416
+ `events: ${r.agent.counts.events} (${r.agent.counts.full} full, ${r.agent.counts.redacted} redacted)`
3417
+ );
3418
+ L.push(
3419
+ `withheld seqs: ${r.agent.withheld.length === 0 ? "(none — every payload disclosed)" : r.agent.withheld.join(", ")}`
3420
+ );
3421
+ }
3422
+ }
3423
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF block (T-51.4) — printed ONLY when --revocations was
3424
+ // supplied (r.trustAsOf is attached then). With no flag this block is absent, so the output is byte-
3425
+ // identical to the pre-T-51.4 baseline. The block reads the SAME way the producer's verify-signed does.
3426
+ if (r.trustAsOf) {
3427
+ L.push("");
3428
+ for (const line of revocation.renderTrustAsOf(r.trustAsOf, { defaulted: r.trustAsOfDefaulted })) {
3429
+ L.push(line);
3430
+ }
3431
+ }
3432
+ L.push("");
3433
+ if (r.accepted) {
3434
+ L.push("OK — the artifact verifies.");
3435
+ } else if (r.reason === "key_revoked_as_of") {
3436
+ // The signature + bytes checked out, but the signing key was revoked AT OR BEFORE the as-of instant — a
3437
+ // distinct REVOKED verdict (exit 3), matching the producer's verify-signed downgrade.
3438
+ const g = r.trustAsOf && r.trustAsOf.governing;
3439
+ L.push("REVOKED (key_revoked_as_of):");
3440
+ if (g) {
3441
+ L.push(
3442
+ ` key_revoked_as_of: the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
3443
+ `(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
3444
+ `the as-of instant. The bytes + signature check out, but the key was no longer trustworthy then.`
3445
+ );
3446
+ }
3447
+ } else {
3448
+ L.push(`REJECTED (${r.reason}):`);
3449
+ for (const c of r.changed) {
3450
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
3451
+ }
3452
+ for (const m of r.missing) {
3453
+ L.push(` MISSING ${m.relPath}: referenced but not found on disk`);
3454
+ }
3455
+ for (const x of r.escaped || []) {
3456
+ // SECURITY: print the attacker's relPath string ONLY — never a content hash of the out-of-tree target.
3457
+ L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (refused to read; no hash computed)`);
3458
+ }
3459
+ for (const u of r.unexpected) {
3460
+ L.push(` UNEXPECTED ${u.relPath}: on disk but not referenced`);
3461
+ }
3462
+ if (r.reason === "bad_signature") {
3463
+ L.push(" bad_signature: the signature does not recover to the claimed signer (tampered or forged).");
3464
+ }
3465
+ if (r.reason === "wrong_issuer") {
3466
+ L.push(
3467
+ ` wrong_issuer: recovered ${r.recoveredSigner} but you pinned --vendor ${r.pinnedVendor}.`
3468
+ );
3469
+ }
3470
+ if (r.reason === "unsigned_cannot_pin_vendor") {
3471
+ L.push(" --vendor was pinned but the artifact carries no signature to recover a signer from.");
3472
+ }
3473
+ if (r.reason === "root_mismatch") {
3474
+ L.push(" root_mismatch: the recomputed root does not equal the sealed root.");
3475
+ }
3476
+ if (r.reason === "path_escape") {
3477
+ L.push(
3478
+ " path_escape: the artifact references a file OUTSIDE its own directory (absolute path, `..` " +
3479
+ "traversal, or an out-of-tree symlink). A genuine artifact never does this; refused to read it."
3480
+ );
3481
+ }
3482
+ // AGENT-SESSION packet reject details (T-68.3): name the first offending event seq + the named fault.
3483
+ if (r.agent) {
3484
+ if (r.agent.seq !== null && r.agent.seq !== undefined) {
3485
+ L.push(` first offending event seq: ${r.agent.seq}${r.agent.reason ? ` (${r.agent.reason})` : ""}`);
3486
+ }
3487
+ if (r.reason === "event_invalid") {
3488
+ L.push(
3489
+ ` event_invalid: an event failed strict canonical validation` +
3490
+ `${r.agent.field ? ` (field: ${r.agent.field})` : ""} — the packet cannot be trusted.`
3491
+ );
3492
+ }
3493
+ if (r.reason === "counts_mismatch") {
3494
+ L.push(" counts_mismatch: the packet's declared full/redacted counts do not match a recount.");
3495
+ }
3496
+ if (r.reason === "head_not_bound") {
3497
+ L.push(
3498
+ " head_not_bound: the headAttestation signs a DIFFERENT { size, root } than this packet's " +
3499
+ "events derive — the signature belongs to another session."
3500
+ );
3501
+ }
3502
+ }
3503
+ }
3504
+ L.push("");
3505
+ return L.join("\n");
3506
+ }
3507
+
3508
+ // Human rendering of a batch aggregate: a per-artifact PASS/FAIL line (FAIL names the reason), then the
3509
+ // one-line roll-up + the final verdict. The trust note is printed ONCE at the top.
3510
+ function renderBatchHuman(agg) {
3511
+ const L = [];
3512
+ L.push(TRUST_NOTE);
3513
+ L.push("");
3514
+ L.push(`# verify-vh — BATCH (${agg.total} artifact${agg.total === 1 ? "" : "s"})`);
3515
+ for (const r of agg.results) {
3516
+ if (r.accepted) {
3517
+ L.push(` PASS ${r.artifact}`);
3518
+ } else {
3519
+ L.push(` FAIL ${r.artifact} (${r.reason})`);
3520
+ // Localize the first failing detail so a CI log names exactly what moved, per artifact.
3521
+ for (const c of r.changed) {
3522
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
3523
+ }
3524
+ for (const m of r.missing) {
3525
+ L.push(` MISSING ${m.relPath}`);
3526
+ }
3527
+ for (const x of r.escaped || []) {
3528
+ L.push(` REJECTED ${x.relPath}: path escapes the artifact directory (no hash computed)`);
3529
+ }
3530
+ }
3531
+ }
3532
+ L.push("");
3533
+ L.push(`total: ${agg.total}, passed: ${agg.passed}, failed: ${agg.failed}`);
3534
+ L.push(agg.ok ? "OK — every artifact verifies." : `REJECTED — ${agg.failed} artifact(s) failed.`);
3535
+ L.push("");
3536
+ return L.join("\n");
3537
+ }
3538
+
3539
+ // ---------------------------------------------------------------------------
3540
+ // `demo` — the ZERO-CONFIG, zero-flag, zero-key-knowledge quickstart (T-55.2).
3541
+ //
3542
+ // WHY THIS EXISTS
3543
+ // A cold prospect should be able to go from NOTHING to a VERIFIED packet in one command — `verify-vh demo`
3544
+ // (or `npx … demo`) — with NO flags, NO `--vendor` to paste, and NO key knowledge. The whole sales promise
3545
+ // ("don't trust us — verify it yourself, offline") is unfalsifiable until they have RUN the tool once and
3546
+ // watched it ACCEPT a genuine packet, name the signer, then REJECT a one-byte-tampered copy. `demo` IS that
3547
+ // first run: it ships a tiny, self-contained, GENUINELY-SIGNED evidence packet baked into this file, plays
3548
+ // it through the EXACT same `verifyArtifact` core every real verify uses, and prints the honest verdict.
3549
+ //
3550
+ // HOW IT STAYS HONEST (no special-case verify path)
3551
+ // The fixture below is a REAL `vh.evidence-seal-signed` container: a keccak Merkle seal over two referenced
3552
+ // files, signed with a FIXED, well-known TEST-ONLY key (NEVER a real key, NEVER real funds — its address is
3553
+ // the standard hardhat account #1, published precisely so no one mistakes it for a production signer). The
3554
+ // signature was produced once with the family's real EIP-191 personal-sign path; the demo RECOVERS it with
3555
+ // the SAME vendored secp256k1 recovery a real verify uses, so the signer address printed is genuinely
3556
+ // recovered from the bytes — not echoed. `demo` materializes the packet + its two files into a throwaway
3557
+ // temp dir, runs the real `verifyArtifact` twice (genuine -> ACCEPT pinned to the recovered signer; a
3558
+ // one-byte-tampered copy -> REJECT/CHANGED), then deletes the temp dir. It writes NOTHING under cwd.
3559
+ // ---------------------------------------------------------------------------
3560
+
3561
+ // The fixed TEST-ONLY signer (hardhat account #1). Published so it can NEVER be confused with a real key.
3562
+ const DEMO_SIGNER = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8";
3563
+
3564
+ // The two referenced files the demo seal commits to, by relPath -> exact UTF-8 content.
3565
+ const DEMO_FILES = Object.freeze({
3566
+ "model-card.md": "# Demo model card\nThis file is sealed by the verify-vh demo.\n",
3567
+ "weights.txt": "0.10 0.20 0.30\n",
3568
+ });
3569
+
3570
+ // The GENUINELY-SIGNED evidence container. `attestation` is the EXACT bytes the signature is over (the same
3571
+ // plain serialization the producer's evidence path emits for the embedded seal); the signature is a real
3572
+ // 65-byte EIP-191 personal-sign over those bytes by DEMO_SIGNER. Re-derived from DEMO_FILES (a build-time
3573
+ // check would re-seal the same bytes), so the root binds the real file content above.
3574
+ const DEMO_CONTAINER = Object.freeze({
3575
+ kind: "vh.evidence-seal-signed",
3576
+ attestation:
3577
+ '{"kind":"vh.evidence-seal","files":[{"relPath":"model-card.md","contentHash":"0x1aeca0ad922f53e9c30186234c5d1a62ffda62a828988bdd266fa93240675db0","leaf":"0xbbb3052a7359188aed3f114e15b721cf5d707a8bdf09109d1d51ec5765b3c58c"},{"relPath":"weights.txt","contentHash":"0x7716d380e062d1daf7ca58897b55f6b58900ed4fd1eda79445956c5c3d336cdf","leaf":"0x34ce488c6fb49a32d356a2553196dc817a439c13a03ce9a2a2ff2710fcf9eea2"}],"root":"0x621a5eb924a9887f88d4b05ccdf19834cdae2f4ed2399921acc7b8a45d48da9b"}',
3578
+ signature: {
3579
+ scheme: "eip191-personal-sign",
3580
+ signer: DEMO_SIGNER,
3581
+ signature:
3582
+ "0x1aabba1530df192e87498bbf1a26f63a7e30d84d72c14bf5d08b2d872df9810b672efcf26f30ec6a38a00ffc158be53633daeff9e99f344b6c1a2e99522d61a01b",
3583
+ },
3584
+ });
3585
+
3586
+ // The packet filename the demo materializes (shared by the throwaway-temp round-trip and the `demo <dir>`
3587
+ // keepable scaffold) so the "NEXT" command the demo prints names the file it actually wrote.
3588
+ const DEMO_PACKET_NAME = "demo-packet.vhevidence.json";
3589
+
3590
+ // ---------------------------------------------------------------------------
3591
+ // The DEMO AGENT-SESSION packet (T-68.3): a small, GENUINE `vh.agent-session-packet` produced by the
3592
+ // REAL `vh agent seal` + `vh agent redact` path (never re-authored by hand) — a 4-event session
3593
+ // (prompt -> tool_call -> tool_result -> completion) whose tool_call payload (seq 1) is REDACTED
3594
+ // behind its hash commitment, so the fixture demonstrates the load-bearing property: a redacted
3595
+ // packet STILL VERIFIES (identical leaves + root). UNSIGNED — the whole agent verify surface is the
3596
+ // FREE funnel leg. The standalone HTML page inlines these constants verbatim (next to DEMO_FILES /
3597
+ // DEMO_CONTAINER above) for its built-in agent demo: click -> ACCEPT; tamper ONE byte of a payload in
3598
+ // the page -> REJECT naming event seq DEMO_AGENT_TAMPER_SEQ. The TAMPER_FROM/TO pair is a one-byte
3599
+ // substring edit that occurs EXACTLY once in the packet text (pinned by test/verifier.agent.test.js).
3600
+ // ---------------------------------------------------------------------------
3601
+ const DEMO_AGENT_PACKET_NAME = "demo-session.vhagent.json";
3602
+ const DEMO_AGENT_PACKET_TEXT = "{\"kind\":\"vh.agent-session-packet\",\"schemaVersion\":1,\"note\":\"This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — and the root from the events you hold, and a REJECT names the first offending event seq. Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never verified against any clock); \\\"sealed at time T\\\" rides the human-owned signing/timestamp trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and append-only, not that the log faithfully records what the agent actually did. The packet is an UNTRUSTED transport container: verify never trusts the packet's own stored hashes.\",\"head\":{\"size\":4,\"root\":\"0xd455ad3f8050f1d863d65003532055326629bf92574cf8919b022222abdf66d1\"},\"counts\":{\"events\":4,\"full\":3,\"redacted\":1},\"events\":[{\"seq\":0,\"ts\":\"2026-07-01T09:00:00.000Z\",\"actor\":\"user\",\"type\":\"prompt\",\"payload\":\"Summarize the vendor contract and flag any auto-renewal clause.\",\"payloadHash\":\"0x1e2d99e683d2623c77a82721f633f27206cd8051be8c848509f63bb570bd5be4\"},{\"seq\":1,\"ts\":\"2026-07-01T09:00:01.000Z\",\"actor\":\"agent:assistant\",\"type\":\"tool_call\",\"payloadHash\":\"0x32133a5998ab97eaef8850a7a47cec6e1056b964a050e6e5561f97ec22b24498\",\"redacted\":true,\"meta\":{\"tool\":\"contract_search\"}},{\"seq\":2,\"ts\":\"2026-07-01T09:00:02.000Z\",\"actor\":\"tool:contract_search\",\"type\":\"tool_result\",\"payload\":\"Section 12.3: renews automatically for successive 12-month terms unless cancelled 60 days prior.\",\"payloadHash\":\"0x57bed64393fb6ed461a5b00143cc239cf705e4a1ea5d0ee84a8f5f7ecc85bdc1\"},{\"seq\":3,\"ts\":\"2026-07-01T09:00:03.000Z\",\"actor\":\"agent:assistant\",\"type\":\"completion\",\"payload\":\"Flagged: Section 12.3 auto-renews for successive 12-month terms and requires 60 days cancellation notice.\",\"payloadHash\":\"0x43649f64cb62093be040484c6858b80f0973e6aa2bd9bc4df75c0c725dcd5bb4\"}],\"leaves\":[\"0x5a3354160c02d09a5b653227ebd35d8f0a1ade1284e402049b91c4f8acd873e3\",\"0x57ac83bf53104a1d952cf9d00e904f15e31d4cc17bc6ff0aedacd1b6ca40904a\",\"0xb3ee61a8dc496b92e05db48b990edee212bda46ca29e5480efb056a5c2cf817f\",\"0x1000b07e45f6151bcf49be6266358cec551a690654f22dc5dae279e7d6bfb7d1\"]}\n";
3603
+ const DEMO_AGENT_TAMPER_SEQ = 0;
3604
+ const DEMO_AGENT_TAMPER_FROM = "\"payload\":\"Summarize the vendor contract";
3605
+ const DEMO_AGENT_TAMPER_TO = "\"payload\":\"SUMMARIZE the vendor contract";
3606
+
3607
+ // Materialize the demo packet + its referenced files into `dir`. Returns the packet path.
3608
+ function writeDemoFixture(dir) {
3609
+ for (const [rel, content] of Object.entries(DEMO_FILES)) {
3610
+ fs.writeFileSync(path.join(dir, rel), content);
3611
+ }
3612
+ const packetPath = path.join(dir, DEMO_PACKET_NAME);
3613
+ fs.writeFileSync(packetPath, JSON.stringify(DEMO_CONTAINER, null, 2));
3614
+ return packetPath;
3615
+ }
3616
+
3617
+ // Run the zero-config demo: seal -> ACCEPT (pinned to the recovered signer) -> tamper -> REJECT. Uses the
3618
+ // REAL verifyArtifact core for BOTH runs (no bespoke verify path), so the verdicts are exactly what a real
3619
+ // counterparty would see. Returns the EXIT-contract code (0 only when the whole demo behaved as designed).
3620
+ function runDemo(write, writeErr) {
3621
+ // A throwaway temp dir so the demo needs no input and writes NOTHING under cwd. Cleaned in finally.
3622
+ let tmp;
3623
+ try {
3624
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "verify-vh-demo-"));
3625
+ } catch (e) {
3626
+ writeErr(`error: demo could not create a temp working dir: ${e.message}\n`);
3627
+ return EXIT.IO;
3628
+ }
3629
+ try {
3630
+ const packetPath = writeDemoFixture(tmp);
3631
+
3632
+ const L = [];
3633
+ L.push(TRUST_NOTE);
3634
+ L.push("");
3635
+ L.push("# verify-vh demo — a self-contained, GENUINELY-SIGNED packet, verified OFFLINE with zero config.");
3636
+ L.push("# (No flags, no key to paste: the demo ships a real signed seal + its files and checks them for you.)");
3637
+ L.push(`# Working dir (throwaway, deleted on exit): ${tmp}`);
3638
+ L.push("");
3639
+
3640
+ // (1) GENUINE packet: recover the signer first, then PIN it (so the demo proves both recovery AND the
3641
+ // vendor-pin path) — exactly what a real counterparty does once they learn the producer's address.
3642
+ const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
3643
+ if (recovered !== DEMO_SIGNER) {
3644
+ writeErr(
3645
+ `error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
3646
+ );
3647
+ return EXIT.IO;
3648
+ }
3649
+ L.push("STEP 1 — verify the genuine packet (signer recovered from the bytes, then pinned):");
3650
+ const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
3651
+ if (!good.result.accepted || good.code !== EXIT.OK) {
3652
+ // Should never happen for the shipped fixture; treat as an internal fault, not a silent pass.
3653
+ writeErr(`error: demo genuine packet did NOT verify (reason: ${good.result.reason})\n`);
3654
+ write(renderHuman(good.result));
3655
+ return EXIT.IO;
3656
+ }
3657
+ L.push(` ACCEPT — the artifact verifies. signer: ${good.result.recoveredSigner}`);
3658
+ L.push(` sealed root: ${good.result.sealedRoot}`);
3659
+ L.push(` recomputed root: ${good.result.recomputedRoot} (re-derived from the bytes on disk)`);
3660
+ L.push(` files: ${good.result.counts.matched} matched, 0 changed, 0 missing.`);
3661
+ L.push("");
3662
+
3663
+ // (2) TAMPER one byte of a referenced file, re-verify the SAME packet -> a clean REJECT naming the file.
3664
+ const victim = path.join(tmp, "model-card.md");
3665
+ fs.writeFileSync(victim, DEMO_FILES["model-card.md"] + "X"); // one extra byte
3666
+ L.push("STEP 2 — tamper ONE byte of a referenced file, then re-verify the SAME packet:");
3667
+ const bad = verifyArtifact({ artifact: packetPath, vendor: recovered, dir: tmp });
3668
+ if (bad.result.accepted || bad.code !== EXIT.REJECTED) {
3669
+ writeErr(`error: demo tampered packet was NOT rejected (reason: ${bad.result.reason})\n`);
3670
+ return EXIT.IO;
3671
+ }
3672
+ L.push(` REJECT (${bad.result.reason}) — the tampered copy is caught:`);
3673
+ for (const c of bad.result.changed) {
3674
+ L.push(` CHANGED ${c.relPath}: sealed ${c.expectedContentHash} != on-disk ${c.actualContentHash}`);
3675
+ }
3676
+ L.push("");
3677
+
3678
+ L.push("That is the whole promise: a genuine packet is ACCEPTED and its signer named, while a one-byte");
3679
+ L.push("change is REJECTED — re-derived from the bytes you hold, offline, with no producer stack.");
3680
+ L.push("");
3681
+ // The bare demo is a closed loop in a temp dir — gone the instant it exits. Hand the user the ONE command
3682
+ // that turns "I watched a demo" into "I have a real packet on disk I can poke at": `demo <dir>` writes the
3683
+ // same genuine packet somewhere they KEEP, with copy-paste verify/tamper/restore commands. That is the
3684
+ // working on-ramp from the canned proof to verifying their OWN bytes (where the paid `--sign` pull begins).
3685
+ // NOTE: we name the command literally (NOT process.argv[1]) so the bare-demo output is byte-identical
3686
+ // whether run in-process, as `node verify-vh.js`, or from the standalone bundle — the demo's own
3687
+ // determinism is a tested invariant (the standalone must byte-match the in-tree demo).
3688
+ L.push("TRY IT YOURSELF: keep a copy you can tamper with by hand —");
3689
+ L.push(" node verify-vh.js demo ./vh-demo # writes the same signed packet + files into ./vh-demo,");
3690
+ L.push(" # then prints the exact verify / tamper / restore commands.");
3691
+ L.push("");
3692
+ L.push("NEXT: run it on a REAL packet you were handed:");
3693
+ L.push(" node verify-vh.js <packet> --vendor 0xPRODUCER_ADDRESS (exit 0 = verifies; 3 = REJECTED)");
3694
+ L.push("");
3695
+ write(L.join("\n"));
3696
+ return EXIT.OK;
3697
+ } catch (e) {
3698
+ writeErr(`error: demo failed unexpectedly: ${e.message}\n`);
3699
+ return EXIT.IO;
3700
+ } finally {
3701
+ try {
3702
+ fs.rmSync(tmp, { recursive: true, force: true });
3703
+ } catch (_) {
3704
+ /* best-effort cleanup; the OS reaps temp dirs anyway */
3705
+ }
3706
+ }
3707
+ }
3708
+
3709
+ // ---------------------------------------------------------------------------
3710
+ // `demo <dir>` — the KEEPABLE scaffold (T-55.2 rework). The bare `demo` proves the round-trip in a throwaway
3711
+ // temp dir and is GONE the instant it exits — a closed loop the prospect can WATCH but cannot TOUCH. That is
3712
+ // the funnel dead-end the review panel flagged: the demo's own "NEXT: run it on a REAL packet" is unactionable
3713
+ // because a brand-new user HAS no packet yet. `demo <dir>` closes that gap: it MATERIALIZES the same genuine
3714
+ // signed packet + its two referenced files into a directory the user names and KEEPS, then prints the exact,
3715
+ // copy-pasteable REAL commands to (a) verify it with the real (non-canned) verify path, (b) tamper one byte
3716
+ // and watch the real REJECT, and (c) restore and re-ACCEPT. The prospect's FIRST hands-on artifact is now one
3717
+ // they hold on disk and can poke at with the production code path — the working on-ramp from "watched a demo"
3718
+ // to "verified my own bytes", which is where the free→paid pull (sign YOUR OWN files: `vh evidence seal
3719
+ // --sign` / the `evidence_unlimited` upgrade) actually begins.
3720
+ //
3721
+ // It is a PURE SUPERSET of the flagless quickstart: it engages ONLY when a single <dir> token follows `demo`
3722
+ // (`verify-vh demo` with no token stays the byte-identical throwaway round-trip above). It WRITES — by design,
3723
+ // into the dir the user explicitly named — so it is never reached by the bare flagless path the "writes
3724
+ // nothing under cwd" contract pins. The packet it writes is byte-identical to the round-trip's, signed by the
3725
+ // same fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).
3726
+ // ---------------------------------------------------------------------------
3727
+
3728
+ function runDemoEmit(targetDir, write, writeErr) {
3729
+ // Confirm the shipped fixture is internally sound BEFORE writing anything (recover the signer from the
3730
+ // embedded bytes, exactly as a real verify does) — a corrupt fixture is an internal fault, not a scaffold.
3731
+ const recovered = tryRecover(DEMO_CONTAINER.attestation, DEMO_CONTAINER.signature.signature);
3732
+ if (recovered !== DEMO_SIGNER) {
3733
+ writeErr(
3734
+ `error: demo fixture is corrupt — embedded signature recovered ${String(recovered)} != ${DEMO_SIGNER}\n`
3735
+ );
3736
+ return EXIT.IO;
3737
+ }
3738
+
3739
+ const dir = path.resolve(targetDir);
3740
+ // mkdir -p the target. We create the user-named dir if absent; an existing dir is fine (we only add files).
3741
+ try {
3742
+ fs.mkdirSync(dir, { recursive: true });
3743
+ } catch (e) {
3744
+ writeErr(`error: demo could not create ${targetDir}: ${e.message}\n`);
3745
+ return EXIT.IO;
3746
+ }
3747
+
3748
+ let packetPath;
3749
+ try {
3750
+ packetPath = writeDemoFixture(dir);
3751
+ } catch (e) {
3752
+ writeErr(`error: demo could not write the scaffold into ${targetDir}: ${e.message}\n`);
3753
+ return EXIT.IO;
3754
+ }
3755
+
3756
+ // Verify the just-written packet through the REAL core (no canned path), so the scaffold is proven good on
3757
+ // disk before we tell the user to trust it — and so the ACCEPT line the user will reproduce is the truth.
3758
+ const good = verifyArtifact({ artifact: packetPath, vendor: recovered, dir });
3759
+ if (!good.result.accepted || good.code !== EXIT.OK) {
3760
+ writeErr(`error: demo scaffold did NOT verify after writing (reason: ${good.result.reason})\n`);
3761
+ return EXIT.IO;
3762
+ }
3763
+
3764
+ // The command name as the user invoked us (verify-vh.js in-tree, verify-vh-standalone.js as the bundle), so
3765
+ // the copy-paste commands below name the EXACT file they ran — not a guessed path.
3766
+ // Name the command the user actually ran (verify-vh.js in-tree, verify-vh-standalone.js as the bundle) so the
3767
+ // copy-paste lines below name the EXACT file they invoked. If argv[1] is not one of our scripts (e.g. running
3768
+ // in-process under a test harness), fall back to the canonical name rather than printing the harness binary.
3769
+ const argv1 = path.basename(process.argv[1] || "");
3770
+ const self = /verify-vh/.test(argv1) ? argv1 : "verify-vh.js";
3771
+ // Print a path that is copy-pasteable from the user's CURRENT shell: the relative path when the target sits
3772
+ // at/under cwd (the common `demo ./vh-demo` case -> a tidy `vh-demo/...`), else the absolute path (a `../../`
3773
+ // chain to a far-off dir is unreadable and brittle — the absolute path always resolves).
3774
+ const rel = (p) => {
3775
+ const r = path.relative(process.cwd(), p);
3776
+ return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p;
3777
+ };
3778
+ const pkt = rel(packetPath);
3779
+ const card = rel(path.join(dir, "model-card.md"));
3780
+
3781
+ const L = [];
3782
+ L.push(TRUST_NOTE);
3783
+ L.push("");
3784
+ L.push(`# verify-vh demo — wrote a real, KEEPABLE signed packet you can verify yourself, hands-on.`);
3785
+ L.push(`# Signed by a fixed TEST-ONLY key (hardhat #1 — never a real key / real funds).`);
3786
+ L.push("");
3787
+ L.push(`Wrote into ${dir}:`);
3788
+ L.push(` ${DEMO_PACKET_NAME} (a genuinely-signed evidence packet)`);
3789
+ for (const r of Object.keys(DEMO_FILES)) L.push(` ${r}`);
3790
+ L.push(` signer (recovered from the bytes): ${recovered}`);
3791
+ L.push("");
3792
+ L.push("It already VERIFIES — run it yourself (the real verify path, no canned demo):");
3793
+ L.push(` node ${self} ${pkt} --vendor ${recovered}`);
3794
+ L.push(" # exit 0 = ACCEPT (root re-derived from YOUR bytes on disk; signer pinned).");
3795
+ L.push("");
3796
+ L.push("Now PROVE tamper-evidence with your own hands — change one byte, then re-verify:");
3797
+ L.push(` printf 'X' >> ${card}`);
3798
+ L.push(` node ${self} ${pkt} --vendor ${recovered} # exit 3 = REJECT (CHANGED ${path.basename(card)})`);
3799
+ L.push("");
3800
+ L.push("Restore it and watch it ACCEPT again (the change was the ONLY reason it rejected):");
3801
+ L.push(` node ${self} ${pkt} --vendor ${recovered} # after restoring the byte`);
3802
+ L.push("");
3803
+ L.push("NEXT — verify a packet someone handed YOU (same command, their address):");
3804
+ L.push(` node ${self} <their-packet> --vendor 0xTHEIR_ADDRESS`);
3805
+ L.push("");
3806
+ L.push("Want to SIGN your OWN files so a counterparty can pin YOU? That is the paid producer side:");
3807
+ L.push(" vh evidence seal <your-folder> --sign (an EIP-191 signer-pin; the `evidence_unlimited`");
3808
+ L.push(" upgrade lifts the free 25-file cap) — see verifier/README.md §0a.");
3809
+ L.push("");
3810
+ write(L.join("\n"));
3811
+ return EXIT.OK;
3812
+ }
3813
+
3814
+ function usage() {
3815
+ return [
3816
+ "verify-vh — standalone, read-only, OFFLINE verifier for verifyhash artifacts",
3817
+ "",
3818
+ "Usage:",
3819
+ " verify-vh demo (zero-config quickstart)",
3820
+ " verify-vh demo <dir> (write a keepable signed packet you can verify yourself)",
3821
+ " verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir> [--as-of <ISO>]] [--json]",
3822
+ " verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
3823
+ " verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
3824
+ "",
3825
+ "DEMO: `verify-vh demo` runs a self-contained, genuinely-signed packet through the real verify path —",
3826
+ "NO flags, NO key, NO install state: it ACCEPTs the packet (naming the signer), then REJECTs a one-byte-",
3827
+ "tampered copy. The single command that takes a brand-new user from nothing to a verified packet.",
3828
+ "`verify-vh demo <dir>` goes one step further: it WRITES that same genuine signed packet + its files into",
3829
+ "<dir> (which you keep) and prints copy-paste commands so you verify, tamper, and re-verify it by hand.",
3830
+ "",
3831
+ "Auto-detects the artifact kind (evidence seal, reconciliation seal, dataset attestation, proof",
3832
+ "bundle — bare or signed — or an agent-session packet *.vhagent.json), RE-DERIVES the keccak root",
3833
+ "from the referenced bytes (siblings resolve next to the artifact, or under --dir <d>), recovers",
3834
+ "the signer of a signed artifact, and PINS it to --vendor <0xaddr> (or reports the recovered signer",
3835
+ "when no pin is given). An agent-session packet is SELF-CONTAINED: every event leaf + the ordered",
3836
+ "RFC-6962-style head are re-derived from the events in the packet (REDACTED payloads are checked by",
3837
+ "their hash commitments), and a REJECT names the first offending event seq.",
3838
+ "",
3839
+ "REVOCATIONS: --revocations <file-or-dir> [--as-of <ISO>] downgrades an otherwise-ACCEPTED signed",
3840
+ "artifact to REVOKED (exit 3) when its signing key was REVOKED at or before --as-of (default now). The",
3841
+ "file may be one signed revocation or a JSON array; a directory is read as a flat pool of revocation",
3842
+ "files. A revocation dated AFTER --as-of stays ACCEPTED with a later-revoked note; a forged/tampered/",
3843
+ "third-party revocation is IGNORED with a warning. This reaches the SAME downgrade the producer's",
3844
+ "`vh ... verify-signed --revocations` does, OFFLINE — no producer stack, no network, no key.",
3845
+ "",
3846
+ "BATCH/MANIFEST: pass several <artifact> args, or --manifest <file> (a newline list or JSON array of",
3847
+ "artifact paths, each line/object may carry its own --vendor/--dir). ALL must pass for exit 0; if ANY",
3848
+ "is rejected, exit is 3 and the report names which artifact failed and why. --json emits a stable",
3849
+ "aggregate { ok, total, passed, failed, results:[...] } whose entries are the single-artifact shape.",
3850
+ "Top-level --vendor/--dir are inherited as defaults a manifest entry may override; --revocations/--as-of",
3851
+ "apply to every entry.",
3852
+ "",
3853
+ "READ-ONLY: holds no key, writes nothing. Exit: 0 ok / 3 rejected|revoked / 2 usage / 1 IO.",
3854
+ "",
3855
+ ].join("\n");
3856
+ }
3857
+
3858
+ // ---------------------------------------------------------------------------
3859
+ // run(argv, io) — the testable entrypoint. Returns the EXIT-contract integer. Injectable stdout/stderr.
3860
+ // ---------------------------------------------------------------------------
3861
+
3862
+ function run(argv, io = {}) {
3863
+ const write = io.write || ((s) => process.stdout.write(s));
3864
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
3865
+
3866
+ let opts;
3867
+ try {
3868
+ opts = parseArgs(argv);
3869
+ } catch (e) {
3870
+ writeErr(`error: ${e.message}\n`);
3871
+ return EXIT.USAGE;
3872
+ }
3873
+ if (opts.help) {
3874
+ write(usage());
3875
+ return EXIT.OK;
3876
+ }
3877
+ // DEMO: the zero-config quickstart (T-55.2). `verify-vh demo` — a SINGLE bare positional `demo`, with NO
3878
+ // other args at all (no flags, no second positional, no manifest) — runs the self-contained signed packet
3879
+ // through the real verify path. We require the LONE argument to be exactly `demo` so the quickstart contract
3880
+ // is unambiguous: `demo` with any extra token falls through to the normal path (where it is a clean error),
3881
+ // never a silently-flag-ignoring run. It is a pure SUPERSET of the existing contract: `demo` was never a
3882
+ // valid artifact path before (there is no file named `demo`, so a lone `demo` was a clean IO error), so
3883
+ // intercepting it here shifts no existing caller.
3884
+ if (argv.length === 1 && opts.artifact === "demo") {
3885
+ return runDemo(write, writeErr);
3886
+ }
3887
+ // DEMO SCAFFOLD: `verify-vh demo <dir>` — a pure SUPERSET (T-55.2 rework). When `demo` is followed by exactly
3888
+ // ONE more bare token (a target directory) and NO flags, write the same genuine signed packet + its files
3889
+ // into that dir the user KEEPS, and print copy-paste verify/tamper/restore commands. This is the actionable
3890
+ // on-ramp the bare demo (a throwaway temp dir, gone on exit) cannot give. We require EXACTLY two bare
3891
+ // positionals and no flags so the contract stays unambiguous; `demo <dir> --anything` falls through to the
3892
+ // normal path (where a file literally named `demo` is a clean IO error, byte-identically to before).
3893
+ if (
3894
+ argv.length === 2 &&
3895
+ argv[0] === "demo" &&
3896
+ opts._pos.length === 2 &&
3897
+ opts._pos[0] === "demo" &&
3898
+ !opts.json &&
3899
+ opts.manifest === undefined &&
3900
+ opts.vendor === undefined &&
3901
+ opts.dir === undefined
3902
+ ) {
3903
+ return runDemoEmit(opts._pos[1], write, writeErr);
3904
+ }
3905
+ // No artifact AND no manifest → the same usage error as before (the batch additions are a pure superset).
3906
+ if (opts.artifact === undefined && opts.manifest === undefined) {
3907
+ writeErr("error: verify-vh requires an <artifact>\n\n");
3908
+ writeErr(usage());
3909
+ return EXIT.USAGE;
3910
+ }
3911
+
3912
+ // The recipient's current decision instant (the default --as-of). Injectable via io.nowISO so a test can
3913
+ // pin the clock; otherwise the wall clock. Threaded onto opts for the (optional) revocation evaluation.
3914
+ opts.nowISO = io.nowISO || new Date().toISOString();
3915
+
3916
+ // BATCH path: a --manifest file or more than one positional <artifact>. Aggregates per-artifact verdicts
3917
+ // under one CI exit code. The single-artifact path below is byte-for-byte the original behavior.
3918
+ if (opts.batch) {
3919
+ let out;
3920
+ try {
3921
+ out = verifyBatch(opts);
3922
+ } catch (e) {
3923
+ if (e instanceof UsageError) {
3924
+ writeErr(`error: ${e.message}\n`);
3925
+ return EXIT.USAGE;
3926
+ }
3927
+ if (e instanceof IOError) {
3928
+ writeErr(`error: ${e.message}\n`);
3929
+ return EXIT.IO;
3930
+ }
3931
+ writeErr(`error: ${e.message}\n`);
3932
+ return EXIT.IO;
3933
+ }
3934
+ if (opts.json) {
3935
+ write(JSON.stringify(out.aggregate, null, 2) + "\n");
3936
+ } else {
3937
+ write(renderBatchHuman(out.aggregate));
3938
+ }
3939
+ return out.code;
3940
+ }
3941
+
3942
+ let out;
3943
+ try {
3944
+ out = verifyArtifact(opts);
3945
+ } catch (e) {
3946
+ if (e instanceof UsageError) {
3947
+ writeErr(`error: ${e.message}\n`);
3948
+ return EXIT.USAGE;
3949
+ }
3950
+ if (e instanceof IOError) {
3951
+ writeErr(`error: ${e.message}\n`);
3952
+ return EXIT.IO;
3953
+ }
3954
+ // Any other error is an unexpected internal fault — surface it as an IO error (never a stack to a
3955
+ // counterparty), exit 1.
3956
+ writeErr(`error: ${e.message}\n`);
3957
+ return EXIT.IO;
3958
+ }
3959
+
3960
+ if (opts.json) {
3961
+ write(JSON.stringify(out.result, null, 2) + "\n");
3962
+ } else {
3963
+ write(renderHuman(out.result));
3964
+ }
3965
+ return out.code;
3966
+ }
3967
+
3968
+ // CLI shim: only run when invoked directly (so the module is importable in tests without side effects).
3969
+ if (require.main === module) {
3970
+ process.exit(run(process.argv.slice(2)));
3971
+ }
3972
+
3973
+ module.exports = {
3974
+ EXIT,
3975
+ KINDS,
3976
+ TRUST_NOTE,
3977
+ UsageError,
3978
+ IOError,
3979
+ parseArgs,
3980
+ parseManifest,
3981
+ verifyArtifact,
3982
+ verifyArtifactFromBytes,
3983
+ verifyBatch,
3984
+ buildBatchEntries,
3985
+ renderBatchHuman,
3986
+ verifyEvidenceSeal,
3987
+ verifyTrustSeal,
3988
+ verifyDatasetAttestation,
3989
+ verifyProofBundle,
3990
+ verifyAgentSeal,
3991
+ AGENT_TRUST_NOTE,
3992
+ renderHuman,
3993
+ revocation,
3994
+ usage,
3995
+ run,
3996
+ runDemo,
3997
+ runDemoEmit,
3998
+ DEMO_SIGNER,
3999
+ DEMO_FILES,
4000
+ DEMO_CONTAINER,
4001
+ DEMO_PACKET_NAME,
4002
+ DEMO_AGENT_PACKET_NAME,
4003
+ DEMO_AGENT_PACKET_TEXT,
4004
+ DEMO_AGENT_TAMPER_SEQ,
4005
+ DEMO_AGENT_TAMPER_FROM,
4006
+ DEMO_AGENT_TAMPER_TO,
4007
+ MAX_RELPATH_CHARS,
4008
+ };
4009
+
4010
+ };
4011
+
4012
+ // ---- embedded build-provenance (this file's own): see `--provenance` / `--self-attest` below. ----
4013
+ var __SELF_SHA256_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
4014
+ var __PROVENANCE = {
4015
+ "schema": "verifyhash/build-provenance@1",
4016
+ "target": "verify",
4017
+ "note": "This bundle's OWN provenance, embedded so the single file is self-describing. Run `node verify-vh-standalone.js --self-attest` to recompute selfSha256 from these very bytes, or `--provenance` to print the ordered source modules + hashes it was built from. Cross-check against verifier/dist/BUILD-PROVENANCE.json (the same data) with: node verifier/build-standalone.js --check",
4018
+ "selfSha256": "97d8a906c8f9d584ff2c5ff88691e4bad334180f8894afa1d3c7a6b4aff2d8e8",
4019
+ "modules": [
4020
+ {
4021
+ "id": "keccak256-vendored",
4022
+ "synthetic": false,
4023
+ "sourceFile": "verifier/lib/keccak256-vendored.js",
4024
+ "sourceSha256": "4f5f2dda618a5889ab5b3f8498dc64ddeacdd22b57349d97824f60960ee334a1",
4025
+ "inlinedSha256": "4f5f2dda618a5889ab5b3f8498dc64ddeacdd22b57349d97824f60960ee334a1",
4026
+ "entry": false
4027
+ },
4028
+ {
4029
+ "id": "keccak",
4030
+ "synthetic": true,
4031
+ "sourceFile": null,
4032
+ "sourceSha256": null,
4033
+ "inlinedSha256": "7ead489c805c4e62e8338dbcfde77a85ca52076e2a3639e2ba57ce3b2415828e",
4034
+ "note": "swapped body (keccak provider shim) — defined in build-standalone.js, not a source file"
4035
+ },
4036
+ {
4037
+ "id": "merkle",
4038
+ "synthetic": false,
4039
+ "sourceFile": "verifier/lib/merkle.js",
4040
+ "sourceSha256": "1bea7bab4b479962225279f4590c5524fad5295c7c70cf01d4978dbf9f37f34b",
4041
+ "inlinedSha256": "e65be5614998f9a00ca9b58a6c79b05abe7f70ed2e0dad61ece07a41ee5da876",
4042
+ "entry": false
4043
+ },
4044
+ {
4045
+ "id": "canonical",
4046
+ "synthetic": false,
4047
+ "sourceFile": "verifier/lib/canonical.js",
4048
+ "sourceSha256": "edc190dcabd2b34b33c54240fc951141de13d2c7d2759184cacb160dfdf3e764",
4049
+ "inlinedSha256": "edc190dcabd2b34b33c54240fc951141de13d2c7d2759184cacb160dfdf3e764",
4050
+ "entry": false
4051
+ },
4052
+ {
4053
+ "id": "secp256k1-recover",
4054
+ "synthetic": false,
4055
+ "sourceFile": "verifier/lib/secp256k1-recover.js",
4056
+ "sourceSha256": "34bb39eed0afc5f55241419ba827404bf767fa5dde1363dba5ad9c419cf91dab",
4057
+ "inlinedSha256": "c8bab43d54b01dd7e55aeae6bc6d27986b868cbc7ebc1b1279dc70f032dbc122",
4058
+ "entry": false
4059
+ },
4060
+ {
4061
+ "id": "revocation-core",
4062
+ "synthetic": false,
4063
+ "sourceFile": "verifier/lib/revocation-core.js",
4064
+ "sourceSha256": "99377291d5455b7a01a3eca313cd1289f6df8ce76a37bcb0aacc68ebe70601b1",
4065
+ "inlinedSha256": "6a426132b56560771fc187caaeee308cd4aaaf87b211aff87dff04cbc622bd2e",
4066
+ "entry": false
4067
+ },
4068
+ {
4069
+ "id": "revocation",
4070
+ "synthetic": false,
4071
+ "sourceFile": "verifier/lib/revocation.js",
4072
+ "sourceSha256": "538b09ca210e47b51319b89fc681229ee16873425f2635855dc0d6f0dc57374f",
4073
+ "inlinedSha256": "ba7ae1d7600289d7584e998f02fffb82eaa03dfb55e2e2373bda374144ce9d44",
4074
+ "entry": false
4075
+ },
4076
+ {
4077
+ "id": "verify-vh",
4078
+ "synthetic": false,
4079
+ "sourceFile": "verifier/verify-vh.js",
4080
+ "sourceSha256": "0e948e5973cd0faf3acda4fb14640feed13fde6190e345b2aea083addeb4b662",
4081
+ "inlinedSha256": "ad89a1d71240c725255633f27f7f3298c8c9718b33eea7c640cae8d62eea8054",
4082
+ "entry": true
4083
+ }
4084
+ ]
4085
+ };
4086
+ function __maybeProvenance(argv) {
4087
+ var wantProv = argv.indexOf('--provenance') !== -1;
4088
+ var wantAttest = argv.indexOf('--self-attest') !== -1;
4089
+ if (!wantProv && !wantAttest) return null;
4090
+ if (wantProv) { process.stdout.write(JSON.stringify(__PROVENANCE, null, 2) + '\n'); }
4091
+ if (wantAttest) {
4092
+ var fs = require('fs');
4093
+ var crypto = require('crypto');
4094
+ var selfText;
4095
+ try { selfText = fs.readFileSync(__filename, 'utf8'); }
4096
+ catch (e) { process.stderr.write('self-attest: cannot read this file: ' + e.message + '\n'); return 1; }
4097
+ // Re-blank our own selfSha256 line back to the sentinel, then hash — reproducing the build's pass-1 hash.
4098
+ var blanked = selfText.replace(
4099
+ '"selfSha256": "' + __PROVENANCE.selfSha256 + '"',
4100
+ '"selfSha256": "' + __SELF_SHA256_SENTINEL + '"'
4101
+ );
4102
+ var got = crypto.createHash('sha256').update(Buffer.from(blanked, 'utf8')).digest('hex');
4103
+ if (got === __PROVENANCE.selfSha256) {
4104
+ process.stdout.write('[MATCH] self-attest: this file is intact (selfSha256 ' + got + ').\n');
4105
+ return 0;
4106
+ }
4107
+ process.stderr.write('[MISMATCH] self-attest: this file has been MODIFIED ' +
4108
+ '(embedded selfSha256 ' + __PROVENANCE.selfSha256 + ' != recomputed ' + got + ').\n');
4109
+ return 1;
4110
+ }
4111
+ return 0;
4112
+ }
4113
+
4114
+ // ---- boot: run the inlined verifier CLI with this process's argv. ----
4115
+ var __entry = __require("verify-vh");
4116
+ if (require.main === module) {
4117
+ var __code = __maybeProvenance(process.argv.slice(2));
4118
+ if (__code !== null) process.exit(__code);
4119
+ process.exit(__entry.run(process.argv.slice(2)));
4120
+ }
4121
+ module.exports = __entry;