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,3373 @@
1
+ <!doctype html>
2
+ <!--
3
+ verify-vh-standalone.html — the SINGLE-FILE, FULLY OFFLINE verifyhash verify page.
4
+
5
+ SPDX-License-Identifier: Apache-2.0
6
+ Copyright 2026 verifyhash.com - https://verifyhash.com
7
+
8
+ GENERATED by verifier/build-standalone-html.js from the in-tree verifier - DO NOT EDIT BY HAND.
9
+ Re-generate with: node verifier/build-standalone-html.js (deterministic; `--check` attests the
10
+ committed file reproduces byte-for-byte from source, see verifier/dist/BUILD-PROVENANCE.json).
11
+
12
+ HOW TO USE IT (no Node, no install, no account, no server): save this ONE file, open it in a
13
+ browser, click the built-in sample packet (ACCEPT), change one byte of a sample file in the page
14
+ (REJECT, naming the file) - then drag a real sealed packet + its files in. Everything runs inside
15
+ the page: the file contains NO network API, so the packet bytes never leave this machine (verify
16
+ in your browser devtools Network tab).
17
+
18
+ HONEST BOUNDARY: ACCEPT is tamper-evidence that these exact bytes match the seal - and, for a
19
+ signed seal, WHO vouched (signer recovery + optional vendor pin). It is NOT a trusted timestamp
20
+ and NOT proof of WHEN. For CI/production gating use the node standalone (verify-vh-standalone.js).
21
+ -->
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="utf-8">
25
+ <meta name="viewport" content="width=device-width, initial-scale=1">
26
+ <title>verify-vh — offline verifier (verifyhash)</title>
27
+ <style>
28
+ :root { color-scheme: light; }
29
+ * { box-sizing: border-box; }
30
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
31
+ margin: 0; background: #f6f7f9; color: #1a1f24; line-height: 1.45; }
32
+ main { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
33
+ h1 { font-size: 1.5rem; margin: 0.2em 0; }
34
+ h2 { font-size: 1.15rem; margin: 1.6em 0 0.4em; }
35
+ p { margin: 0.5em 0; }
36
+ .note { color: #444d56; font-size: 0.92rem; }
37
+ .boundary { background: #fff8e6; border: 1px solid #e0c869; border-radius: 6px; padding: 10px 12px; font-size: 0.92rem; }
38
+ section { background: #ffffff; border: 1px solid #d9dee3; border-radius: 8px; padding: 16px; margin-top: 16px; }
39
+ button { font: inherit; padding: 7px 14px; border-radius: 6px; border: 1px solid #9aa4ad; background: #eef1f4; cursor: pointer; }
40
+ button.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
41
+ textarea, input[type=text] { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
42
+ font-size: 0.85rem; width: 100%; border: 1px solid #c4ccd4; border-radius: 6px; padding: 8px; }
43
+ .drop { border: 2px dashed #9aa4ad; border-radius: 8px; padding: 18px; text-align: center; color: #444d56; background: #fafbfc; }
44
+ .drop.armed { border-color: #1f6feb; background: #eef4ff; }
45
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.82rem; overflow-wrap: anywhere; }
46
+ .verdict { border-radius: 8px; padding: 12px 14px; margin-top: 12px; border: 1px solid; }
47
+ .verdict.accept { background: #e9f7ec; border-color: #34a853; }
48
+ .verdict.reject { background: #fdecec; border-color: #d93025; }
49
+ .verdict-title { font-weight: 700; margin-bottom: 6px; }
50
+ .kv { margin: 2px 0; }
51
+ .kv b { display: inline-block; min-width: 12em; font-weight: 600; }
52
+ ul.files { margin: 6px 0; padding-left: 1.2em; }
53
+ .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 8px 0; }
54
+ footer { margin-top: 28px; color: #444d56; font-size: 0.85rem; }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <main>
59
+ <header>
60
+ <h1>verify-vh — offline verifier (in your browser)</h1>
61
+ <p class="note">An INDEPENDENT, read-only, fully OFFLINE verifier for verifyhash evidence packets.
62
+ It RE-DERIVES the keccak Merkle root from the bytes YOU drop in and recovers the signer with a
63
+ pure-JS secp256k1 routine — it never trusts the artifact's own stored hashes. This one file contains
64
+ NO network API at all: your packet bytes never leave this machine (check the devtools Network tab).</p>
65
+ <p class="boundary"><strong>Honest boundary:</strong> ACCEPT is tamper-evidence that these exact bytes
66
+ match the seal — and, for a signed seal, WHO vouched (signer recovery + optional vendor pin). It is NOT
67
+ a trusted timestamp and NOT proof of WHEN without the P-3 trust-root. For CI/production gating use the
68
+ node standalone (<code>verify-vh-standalone.js</code>).</p>
69
+ </header>
70
+
71
+ <section id="challenge-section">
72
+ <h2>1 — The 60-second challenge (built in)</h2>
73
+ <p class="note">A real, genuinely-signed sample packet is embedded in this page (signed by a fixed,
74
+ published TEST-ONLY key — never a real key). Load it, watch it ACCEPT, then change ONE byte of a
75
+ sample file below and watch the verifier REJECT it and name the file you changed.</p>
76
+ <div class="row">
77
+ <button id="load-sample" class="primary" type="button">Load the sample packet &amp; verify</button>
78
+ </div>
79
+ <div id="sample-area" style="display:none">
80
+ <p class="kv"><b>packet</b><span id="sample-name" class="mono"></span></p>
81
+ <p class="kv"><b>pinned signer</b><span id="sample-signer" class="mono"></span></p>
82
+ <p class="note">The editable bytes of <code>model-card.md</code> (one sealed file) — change ANY one
83
+ character, then re-verify:</p>
84
+ <textarea id="sample-editor" rows="4" spellcheck="false"></textarea>
85
+ <div class="row">
86
+ <button id="sample-verify" class="primary" type="button">Re-verify the sample packet</button>
87
+ <button id="sample-tamper" type="button">Tamper one byte for me</button>
88
+ <button id="sample-restore" type="button">Restore the original bytes</button>
89
+ </div>
90
+ <div id="sample-verdict"></div>
91
+ </div>
92
+ </section>
93
+
94
+ <section id="agent-section">
95
+ <h2>1b — The agent-session demo (AgentTrace, built in)</h2>
96
+ <p class="note">A sample <code>*.vhagent.json</code> AGENT-SESSION packet is embedded in this page:
97
+ an ordered prompt/tool_call/tool_result/completion log under one RFC-6962-style Merkle head, with
98
+ the tool_call payload REDACTED behind its hash commitment — and it STILL verifies (redaction can
99
+ withhold, never silently alter). Load it, watch it ACCEPT, then change ONE byte of a payload below
100
+ and watch the verifier REJECT it and name the offending event seq.</p>
101
+ <div class="row">
102
+ <button id="load-agent-sample" class="primary" type="button">Load the sample agent packet &amp; verify</button>
103
+ </div>
104
+ <div id="agent-sample-area" style="display:none">
105
+ <p class="kv"><b>packet</b><span id="agent-sample-name" class="mono"></span></p>
106
+ <p class="note">The editable packet bytes (self-contained — no sibling files) — change ANY one
107
+ payload character, then re-verify:</p>
108
+ <textarea id="agent-editor" rows="7" spellcheck="false"></textarea>
109
+ <div class="row">
110
+ <button id="agent-verify" class="primary" type="button">Re-verify the agent packet</button>
111
+ <button id="agent-tamper" type="button">Tamper one byte for me</button>
112
+ <button id="agent-restore" type="button">Restore the original bytes</button>
113
+ </div>
114
+ <div id="agent-verdict"></div>
115
+ </div>
116
+ </section>
117
+
118
+ <section id="verify-section">
119
+ <h2>2 — Verify a packet YOU were handed</h2>
120
+ <p class="note">Drop the sealed artifact (<code>*.vhevidence.json</code> / <code>*.vhseal</code> /
121
+ attestation / proof bundle / <code>*.vhagent.json</code> agent-session packet) together with the
122
+ files it references (an agent packet is self-contained) — or pick them below (the folder
123
+ picker keeps sub-directory paths). Nothing is uploaded; the page reads the bytes locally.</p>
124
+ <div id="drop-zone" class="drop">Drag the packet + its files (or a whole folder) here</div>
125
+ <div class="row">
126
+ <label>Files: <input id="file-input" type="file" multiple></label>
127
+ <label>Folder: <input id="dir-input" type="file" webkitdirectory multiple></label>
128
+ <button id="clear-files" type="button">Clear</button>
129
+ </div>
130
+ <div id="held-files" class="mono"></div>
131
+ <div class="row">
132
+ <label style="flex:1">Artifact: <select id="artifact-select" style="max-width:100%"></select></label>
133
+ </div>
134
+ <div class="row">
135
+ <label style="flex:1">Vendor pin (optional 0x address — REJECT wrong_issuer if the signer differs):
136
+ <input id="vendor-input" type="text" placeholder="0x…" spellcheck="false"></label>
137
+ </div>
138
+ <div class="row">
139
+ <label>Revocations file (optional): <input id="revocations-input" type="file"></label>
140
+ <span id="revocations-name" class="mono"></span>
141
+ </div>
142
+ <div class="row"><button id="run-verify" class="primary" type="button">Verify offline, in this page</button></div>
143
+ <div id="verify-verdict"></div>
144
+ </section>
145
+
146
+ <footer>
147
+ <p id="trust-note" class="note"></p>
148
+ <p>Who verifies the verifier? This file is reproducible from readable source: rebuild it with
149
+ <code>node verifier/build-standalone-html.js --check</code> (offline, Node-core-only) and compare the
150
+ published <code>verify-vh-standalone.html.sha256</code>. Per-source pins live in
151
+ <code>verifier/dist/BUILD-PROVENANCE.json</code>.</p>
152
+ </footer>
153
+ </main>
154
+ <script>
155
+ // __VERIFY_VH_ENGINE_BEGIN__
156
+ "use strict";
157
+ var VerifyVhStandalone = (function () {
158
+ // ---- minimal CommonJS module shim (so the inlined modules keep their require() structure) --------
159
+ var __modules = Object.create(null);
160
+ var __cache = Object.create(null);
161
+ function __require(id) {
162
+ if (id in __cache) return __cache[id].exports;
163
+ var factory = __modules[id];
164
+ if (!factory) throw new Error('standalone bundle: unknown module: ' + id);
165
+ var module = { exports: {} };
166
+ __cache[id] = module;
167
+ factory(module, module.exports, __require);
168
+ return module.exports;
169
+ }
170
+
171
+ // ===== module: vh-buffer (build-generated) =====
172
+ __modules["vh-buffer"] = function (module, exports, __require) {
173
+ "use strict";
174
+ // Minimal PURE-JS Buffer subset for the browser/vm (see verifier/build-standalone-html.js). NOT a
175
+ // general Node Buffer — exactly the calls the inlined verifier libs make, nothing more.
176
+ var HEX_CHARS = "0123456789abcdef";
177
+ function hexNibble(ch) {
178
+ var c = ch.charCodeAt(0);
179
+ if (c >= 48 && c <= 57) return c - 48; // 0-9
180
+ if (c >= 97 && c <= 102) return c - 87; // a-f
181
+ if (c >= 65 && c <= 70) return c - 55; // A-F
182
+ return -1;
183
+ }
184
+ function utf8Encode(str) {
185
+ var out = [];
186
+ for (var i = 0; i < str.length; i++) {
187
+ var c = str.codePointAt(i);
188
+ if (c > 0xffff) i++; // consumed a full surrogate pair
189
+ if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd; // lone surrogate -> replacement char (Node semantics)
190
+ if (c < 0x80) out.push(c);
191
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
192
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
193
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
194
+ }
195
+ return out;
196
+ }
197
+ function hexDecode(str) {
198
+ var out = [];
199
+ for (var i = 0; i + 1 < str.length; i += 2) {
200
+ var hi = hexNibble(str[i]);
201
+ var lo = hexNibble(str[i + 1]);
202
+ if (hi < 0 || lo < 0) break; // Node stops at the first invalid pair
203
+ out.push((hi << 4) | lo);
204
+ }
205
+ return out;
206
+ }
207
+ class VhBuffer extends Uint8Array {
208
+ toString(enc) {
209
+ if (enc !== "hex") {
210
+ throw new Error("vh-buffer supports only .toString('hex'), got: " + String(enc));
211
+ }
212
+ var s = "";
213
+ for (var i = 0; i < this.length; i++) {
214
+ s += HEX_CHARS[this[i] >> 4] + HEX_CHARS[this[i] & 15];
215
+ }
216
+ return s;
217
+ }
218
+ static from(input, enc) {
219
+ if (typeof input === "string") {
220
+ if (enc === "hex") return new VhBuffer(hexDecode(input));
221
+ if (enc === undefined || enc === "utf8" || enc === "utf-8") return new VhBuffer(utf8Encode(input));
222
+ throw new Error("vh-buffer supports only utf8/hex string encodings, got: " + String(enc));
223
+ }
224
+ if (input instanceof Uint8Array || Array.isArray(input)) return new VhBuffer(input);
225
+ throw new TypeError("vh-buffer: Buffer.from requires a string, array, or Uint8Array");
226
+ }
227
+ static alloc(n) {
228
+ return new VhBuffer(n); // zero-filled, like Node's Buffer.alloc
229
+ }
230
+ static concat(list) {
231
+ var total = 0;
232
+ for (var i = 0; i < list.length; i++) total += list[i].length;
233
+ var out = new VhBuffer(total);
234
+ var off = 0;
235
+ for (var j = 0; j < list.length; j++) {
236
+ out.set(list[j], off);
237
+ off += list[j].length;
238
+ }
239
+ return out;
240
+ }
241
+ static compare(a, b) {
242
+ var n = Math.min(a.length, b.length);
243
+ for (var i = 0; i < n; i++) {
244
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
245
+ }
246
+ return a.length === b.length ? 0 : a.length < b.length ? -1 : 1;
247
+ }
248
+ static isBuffer(x) {
249
+ return x instanceof VhBuffer;
250
+ }
251
+ }
252
+ module.exports = { Buffer: VhBuffer };
253
+ };
254
+
255
+ // The `Buffer` global the inlined verifier libs reference, satisfied by the pure-JS shim above.
256
+ var Buffer = __require("vh-buffer").Buffer;
257
+
258
+ // ===== module: keccak256-vendored (from verifier/lib/keccak256-vendored.js) =====
259
+ __modules["keccak256-vendored"] = function (module, exports, __require) {
260
+ "use strict";
261
+
262
+ // verifier/lib/keccak256-vendored.js — a PURE-JS, ZERO-DEPENDENCY keccak256 (T-35.1).
263
+ //
264
+ // WHY THIS FILE EXISTS
265
+ // The whole point of the free verifier is "save ONE file, run it with `node`, no `npm install`, audit it
266
+ // in one sitting." The in-tree verifier core takes keccak256 from `js-sha3` (verifier/lib/keccak.js) — an
267
+ // audited, dependency-free package that is already a project dependency — which is correct for the
268
+ // IN-TREE path. But `js-sha3` is still a RUNTIME dependency: a third party handed a single sealed packet
269
+ // would have to `npm install` it. This module is the LAST piece needed to inline the verifier into one
270
+ // self-contained file: a from-scratch keccak256 that `require`s NOTHING (no `js-sha3`, no Node core, no
271
+ // relative module). It is ADDITIVE — keccak.js and verifier/package.json's `dependencies: ["js-sha3"]`
272
+ // are deliberately left UNCHANGED so the existing tree + isolation test stay green.
273
+ //
274
+ // CORRECTNESS, NOT NOVELTY
275
+ // keccak256 is the FIXED, standardized Keccak[c=512] sponge over the Keccak-f[1600] permutation with the
276
+ // ORIGINAL Keccak padding (a single 0x01 domain byte, NOT SHA3's 0x06) and a 256-bit squeeze — exactly
277
+ // what Ethereum/ethers and `js-sha3.keccak256` compute. This is a textbook implementation of FIPS-202's
278
+ // Keccak-f (theta, rho, pi, chi, iota) done with 32-bit lane halves (lo/hi) so it runs on plain JS numbers
279
+ // with no BigInt and no 64-bit-int dependency. test/verifier.keccak-vendored.test.js proves byte-identical
280
+ // output vs BOTH `js-sha3` AND the production `ethers` keccak path across the empty input, the known
281
+ // vectors, and ≥500 random buffers — a single mismatch FAILS. So this is independent CODE but never an
282
+ // independent ALGORITHM: it cannot silently diverge from the standard.
283
+ //
284
+ // REQUIRES NOTHING: a grep of this source finds no CommonJS require call and no bare-name import.
285
+ // (Intentional — this property is asserted by test/verifier.keccak-vendored.test.js.)
286
+
287
+ // ---- Keccak-f[1600] round constants, split into 32-bit (hi, lo) halves --------------------------------
288
+ // The 24 RC[i] are the canonical Keccak iota constants; here each 64-bit constant is pre-split so we never
289
+ // need a 64-bit integer type. RC_HI[i] is bits 63..32, RC_LO[i] is bits 31..0.
290
+ const RC_HI = [
291
+ 0x00000000, 0x00000000, 0x80000000, 0x80000000, 0x00000000, 0x00000000, 0x80000000, 0x80000000,
292
+ 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x80000000, 0x80000000, 0x80000000,
293
+ 0x80000000, 0x80000000, 0x00000000, 0x80000000, 0x80000000, 0x80000000, 0x00000000, 0x80000000,
294
+ ];
295
+ const RC_LO = [
296
+ 0x00000001, 0x00008082, 0x0000808a, 0x80008000, 0x0000808b, 0x80000001, 0x80008081, 0x00008009,
297
+ 0x0000008a, 0x00000088, 0x80008009, 0x8000000a, 0x8000808b, 0x0000008b, 0x00008089, 0x00008003,
298
+ 0x00008002, 0x00000080, 0x0000800a, 0x8000000a, 0x80008081, 0x00008080, 0x80000001, 0x80008008,
299
+ ];
300
+
301
+ // Rotation offsets r[x,y] for the rho step, indexed by lane number (x + 5*y). Lane 0 is never rotated.
302
+ const RHO = [
303
+ 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,
304
+ ];
305
+ // pi permutation: destination lane for each source lane. pi maps (x,y) -> (y, 2x+3y), so source lane
306
+ // (x + 5y) is written to lane (y + 5*((2x+3y) mod 5)); PI[src] = dst.
307
+ const PI = [
308
+ 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,
309
+ ];
310
+
311
+ // The state is 25 lanes; we hold each lane as two 32-bit halves in parallel arrays sLo/sHi (index = lane).
312
+
313
+ // Keccak-f[1600] permutation, in place, on (sLo, sHi). 24 rounds of theta, rho+pi, chi, iota.
314
+ function keccakF(sLo, sHi) {
315
+ const bcLo = new Array(5);
316
+ const bcHi = new Array(5);
317
+ const tLo = new Array(25);
318
+ const tHi = new Array(25);
319
+
320
+ for (let round = 0; round < 24; round++) {
321
+ // --- theta ---
322
+ for (let x = 0; x < 5; x++) {
323
+ bcLo[x] = sLo[x] ^ sLo[x + 5] ^ sLo[x + 10] ^ sLo[x + 15] ^ sLo[x + 20];
324
+ bcHi[x] = sHi[x] ^ sHi[x + 5] ^ sHi[x + 10] ^ sHi[x + 15] ^ sHi[x + 20];
325
+ }
326
+ for (let x = 0; x < 5; x++) {
327
+ // d = bc[x-1] XOR rotl1(bc[x+1])
328
+ const x1 = (x + 1) % 5;
329
+ const x4 = (x + 4) % 5;
330
+ const rotLo = ((bcLo[x1] << 1) | (bcHi[x1] >>> 31)) >>> 0;
331
+ const rotHi = ((bcHi[x1] << 1) | (bcLo[x1] >>> 31)) >>> 0;
332
+ const dLo = (bcLo[x4] ^ rotLo) >>> 0;
333
+ const dHi = (bcHi[x4] ^ rotHi) >>> 0;
334
+ for (let y = 0; y < 25; y += 5) {
335
+ sLo[x + y] = (sLo[x + y] ^ dLo) >>> 0;
336
+ sHi[x + y] = (sHi[x + y] ^ dHi) >>> 0;
337
+ }
338
+ }
339
+
340
+ // --- rho + pi --- (write permuted, rotated lanes into t)
341
+ for (let i = 0; i < 25; i++) {
342
+ const r = RHO[i];
343
+ const dest = PI[i];
344
+ let outLo, outHi;
345
+ if (r === 0) {
346
+ outLo = sLo[i];
347
+ outHi = sHi[i];
348
+ } else if (r < 32) {
349
+ outLo = ((sLo[i] << r) | (sHi[i] >>> (32 - r))) >>> 0;
350
+ outHi = ((sHi[i] << r) | (sLo[i] >>> (32 - r))) >>> 0;
351
+ } else if (r === 32) {
352
+ outLo = sHi[i];
353
+ outHi = sLo[i];
354
+ } else {
355
+ const rr = r - 32;
356
+ outLo = ((sHi[i] << rr) | (sLo[i] >>> (32 - rr))) >>> 0;
357
+ outHi = ((sLo[i] << rr) | (sHi[i] >>> (32 - rr))) >>> 0;
358
+ }
359
+ tLo[dest] = outLo;
360
+ tHi[dest] = outHi;
361
+ }
362
+
363
+ // --- chi --- a[x] = t[x] XOR ((NOT t[x+1]) AND t[x+2]), per row
364
+ for (let y = 0; y < 25; y += 5) {
365
+ for (let x = 0; x < 5; x++) {
366
+ const x1 = y + ((x + 1) % 5);
367
+ const x2 = y + ((x + 2) % 5);
368
+ sLo[y + x] = (tLo[y + x] ^ (~tLo[x1] & tLo[x2])) >>> 0;
369
+ sHi[y + x] = (tHi[y + x] ^ (~tHi[x1] & tHi[x2])) >>> 0;
370
+ }
371
+ }
372
+
373
+ // --- iota ---
374
+ sLo[0] = (sLo[0] ^ RC_LO[round]) >>> 0;
375
+ sHi[0] = (sHi[0] ^ RC_HI[round]) >>> 0;
376
+ }
377
+ }
378
+
379
+ // keccak256 over `bytes` (a Uint8Array/Buffer or array of byte values), returning a 32-byte Uint8Array.
380
+ // Rate r = 1088 bits = 136 bytes (c = 512), original Keccak padding (0x01 .. 0x80), 256-bit output.
381
+ function keccak256Bytes(bytes) {
382
+ const RATE = 136; // bytes absorbed per permutation
383
+ const sLo = new Array(25).fill(0);
384
+ const sHi = new Array(25).fill(0);
385
+
386
+ // Build the padded message: append a single 0x01 domain/pad start byte, zero-fill, set the high bit
387
+ // (0x80) of the final rate block. (If the 0x01 lands on the last byte of a block, it merges to 0x81.)
388
+ const inLen = bytes.length;
389
+ const padLen = RATE - (inLen % RATE); // 1..RATE, guarantees room for the 0x01 and 0x80 markers
390
+ const total = inLen + padLen;
391
+ const msg = new Uint8Array(total);
392
+ for (let i = 0; i < inLen; i++) msg[i] = bytes[i] & 0xff;
393
+ msg[inLen] = 0x01; // start of the original-Keccak pad (NOT SHA3's 0x06)
394
+ msg[total - 1] = (msg[total - 1] | 0x80) & 0xff; // final-block high bit
395
+
396
+ // Absorb: XOR each RATE-byte block into the state (little-endian lanes) and permute.
397
+ for (let off = 0; off < total; off += RATE) {
398
+ for (let i = 0; i < RATE; i += 8) {
399
+ const lane = i >> 3; // lane index within the rate region (0..16), block-relative
400
+ const b = off + i;
401
+ const lo =
402
+ ((msg[b] | (msg[b + 1] << 8) | (msg[b + 2] << 16) | (msg[b + 3] << 24)) >>> 0);
403
+ const hi =
404
+ ((msg[b + 4] | (msg[b + 5] << 8) | (msg[b + 6] << 16) | (msg[b + 7] << 24)) >>> 0);
405
+ sLo[lane] = (sLo[lane] ^ lo) >>> 0;
406
+ sHi[lane] = (sHi[lane] ^ hi) >>> 0;
407
+ }
408
+ keccakF(sLo, sHi);
409
+ }
410
+
411
+ // Squeeze 256 bits = 32 bytes = the first 4 lanes (little-endian), no further permutation needed.
412
+ const out = new Uint8Array(32);
413
+ for (let lane = 0; lane < 4; lane++) {
414
+ const lo = sLo[lane];
415
+ const hi = sHi[lane];
416
+ const base = lane * 8;
417
+ out[base] = lo & 0xff;
418
+ out[base + 1] = (lo >>> 8) & 0xff;
419
+ out[base + 2] = (lo >>> 16) & 0xff;
420
+ out[base + 3] = (lo >>> 24) & 0xff;
421
+ out[base + 4] = hi & 0xff;
422
+ out[base + 5] = (hi >>> 8) & 0xff;
423
+ out[base + 6] = (hi >>> 16) & 0xff;
424
+ out[base + 7] = (hi >>> 24) & 0xff;
425
+ }
426
+ return out;
427
+ }
428
+
429
+ // Lowercase hex (no 0x prefix) of a byte array — used by the hex-string entry point.
430
+ function toHex(bytes) {
431
+ let s = "";
432
+ for (let i = 0; i < bytes.length; i++) {
433
+ const b = bytes[i] & 0xff;
434
+ s += (b < 16 ? "0" : "") + b.toString(16);
435
+ }
436
+ return s;
437
+ }
438
+
439
+ /**
440
+ * keccak256 over a byte buffer.
441
+ * @param {Uint8Array|Buffer|number[]} bytes input bytes
442
+ * @returns {Uint8Array} the 32-byte digest
443
+ */
444
+ function keccak256(bytes) {
445
+ if (
446
+ !(bytes instanceof Uint8Array) &&
447
+ !Array.isArray(bytes) &&
448
+ !(typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(bytes))
449
+ ) {
450
+ throw new TypeError("keccak256 requires a Uint8Array/Buffer/byte-array of input bytes");
451
+ }
452
+ return keccak256Bytes(bytes);
453
+ }
454
+
455
+ /**
456
+ * keccak256 over a byte buffer, returned as a lowercase hex string WITHOUT a 0x prefix
457
+ * (matching `js-sha3`'s keccak256().hex() output, for drop-in cross-checking).
458
+ * @param {Uint8Array|Buffer|number[]} bytes input bytes
459
+ * @returns {string} 64-char lowercase hex
460
+ */
461
+ function keccak256Hex(bytes) {
462
+ return toHex(keccak256(bytes));
463
+ }
464
+
465
+ module.exports = { keccak256, keccak256Hex };
466
+
467
+ };
468
+
469
+ // ===== module: keccak (build-generated) =====
470
+ __modules["keccak"] = function (module, exports, __require) {
471
+ "use strict";
472
+ // Inlined keccak provider for the standalone HTML page: the SAME `keccak256(bytes) -> Buffer` surface
473
+ // as verifier/lib/keccak.js, but backed by the PURE-JS vendored implementation
474
+ // (verifier/lib/keccak256-vendored.js) and returning the bundle's pure-JS Buffer (vh-buffer).
475
+ var vendored = __require("keccak256-vendored");
476
+ function keccak256(bytes) {
477
+ if (!(bytes instanceof Uint8Array)) {
478
+ throw new TypeError("keccak256 requires a Buffer/Uint8Array of input bytes");
479
+ }
480
+ return Buffer.from(vendored.keccak256(bytes));
481
+ }
482
+ module.exports = { keccak256: keccak256 };
483
+ };
484
+
485
+ // ===== module: merkle (from verifier/lib/merkle.js) =====
486
+ __modules["merkle"] = function (module, exports, __require) {
487
+ "use strict";
488
+
489
+ // verifier/lib/merkle.js — INDEPENDENT re-derivation of the family's path-bound, domain-separated
490
+ // Merkle convention, using ONLY ./keccak (js-sha3). NO ethers, NO hardhat, NO require back into cli/.
491
+ //
492
+ // WHY THIS EXISTS
493
+ // To verify an evidence seal / reconciliation seal / proof bundle OFFLINE without the producer stack,
494
+ // the independent verifier must RE-DERIVE the same per-file leaves and the same Merkle root the
495
+ // producer (cli/hash.js) computes. cli/hash.js uses `ethers` (keccak256/concat/toUtf8Bytes), which the
496
+ // verifier explicitly refuses to depend on. So this file reproduces the EXACT byte composition of
497
+ // pathLeaf / leafHash / nodeHash / buildTree from first principles — and test/verifier.cli.test.js
498
+ // cross-checks the result is byte-identical to the producer's. The two can never silently diverge.
499
+ //
500
+ // THE CONVENTION (must match cli/hash.js VERBATIM)
501
+ // * content digest c = keccak256(file bytes)
502
+ // * DIR_LEAF_DOMAIN = keccak256("verifyhash/dir-leaf/v1") (a fixed 32-byte prefix)
503
+ // * path-bound leaf pathLeaf = keccak256(DIR_LEAF_DOMAIN ++ utf8(relPath) ++ 0x00 ++ c)
504
+ // * tagged leaf leafHash = keccak256(0x00 ++ leaf)
505
+ // * interior node nodeHash = keccak256(0x01 ++ min(a,b) ++ max(a,b)) (sorted 32-byte pair)
506
+ // * tree sorted-leaf, "duplicate the lone odd node" pairing (OpenZeppelin style)
507
+ // relPath is normalized with no leading "./", exactly as the producer's toPosixRel does. CRUCIALLY
508
+ // this must be BYTE-FOR-BYTE the producer's normalization (cli/hash.js#toPosixRel) — see toPosixRel
509
+ // below — or the verifier would re-derive a DIFFERENT root than the producer sealed for some input
510
+ // class and would either falsely reject a genuine artifact or falsely accept the wrong one.
511
+
512
+ const { keccak256 } = __require("keccak");
513
+
514
+ // Domain tags, byte-identical to ContributionRegistry / cli/hash.js LEAF_TAG / NODE_TAG.
515
+ const LEAF_TAG = Buffer.from([0x00]);
516
+ const NODE_TAG = Buffer.from([0x01]);
517
+ const PATH_SEP = Buffer.from([0x00]);
518
+
519
+ // The fixed, versioned domain prefix for path-bound directory leaves: keccak256 of the ASCII tag.
520
+ const DIR_LEAF_DOMAIN_STR = "verifyhash/dir-leaf/v1";
521
+ const DIR_LEAF_DOMAIN = keccak256(Buffer.from(DIR_LEAF_DOMAIN_STR, "utf8")); // 32-byte Buffer
522
+
523
+ const HEX32_RE = /^0x[0-9a-fA-F]{64}$/;
524
+
525
+ // 0x-hex string (no 0x, lowercase) <-> 32-byte Buffer.
526
+ function hexToBuf32(hex) {
527
+ if (typeof hex !== "string" || !HEX32_RE.test(hex)) {
528
+ throw new Error(`expected a 0x-prefixed 32-byte hex string, got: ${String(hex)}`);
529
+ }
530
+ return Buffer.from(hex.slice(2), "hex");
531
+ }
532
+ function bufToHex(buf) {
533
+ return "0x" + Buffer.from(buf).toString("hex");
534
+ }
535
+
536
+ /** keccak256 of raw bytes, returned as a 0x-prefixed 32-byte hex string (matches cli/hash.js hashBytes). */
537
+ function hashBytes(bytes) {
538
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
539
+ return bufToHex(keccak256(buf));
540
+ }
541
+
542
+ /**
543
+ * Normalize a relPath EXACTLY as the producer (cli/hash.js#toPosixRel) does, so the verifier
544
+ * re-derives the IDENTICAL root the producer sealed. The producer is `split(path.sep).join("/")`
545
+ * then `.replace(/^\.\//, "")`. The artifacts the verifier reads carry relPaths the producer wrote,
546
+ * and those are produced on POSIX hosts (cli/evidence.js#loadDirEntries does the same `path.sep`
547
+ * split) — where `path.sep === "/"`, so the split/join is a no-op and a literal backslash byte is a
548
+ * CONTENT byte that survives into the hash. We therefore must NOT collapse backslashes: a previous
549
+ * version unconditionally mapped "\\"->"/", which made the verifier hash `a/b.txt` while the producer
550
+ * hashed `a\b.txt` — a silent root divergence that could falsely REJECT a genuine backslash-named
551
+ * directory or falsely ACCEPT one where `a/b.txt` and `a\b.txt` collide. All we strip is the leading
552
+ * "./", which the producer also strips on every host. (Windows-authored relPaths, if ever needed,
553
+ * must be converted to "/" on BOTH the producer and verifier sides identically — not only here.)
554
+ */
555
+ function toPosixRel(relPath) {
556
+ return String(relPath).replace(/^\.\//, "");
557
+ }
558
+
559
+ /**
560
+ * pathLeaf(relPath, contentDigest) = keccak256(DIR_LEAF_DOMAIN ++ utf8(relPath) ++ 0x00 ++ c).
561
+ * @param {string} relPath
562
+ * @param {string} contentDigest 0x bytes32
563
+ * @returns {string} 0x bytes32
564
+ */
565
+ function pathLeaf(relPath, contentDigest) {
566
+ const relBytes = Buffer.from(toPosixRel(relPath), "utf8");
567
+ const c = hexToBuf32(contentDigest);
568
+ return bufToHex(keccak256(Buffer.concat([DIR_LEAF_DOMAIN, relBytes, PATH_SEP, c])));
569
+ }
570
+
571
+ /** leafHash(c) = keccak256(LEAF_TAG ++ c). */
572
+ function leafHash(c) {
573
+ return bufToHex(keccak256(Buffer.concat([LEAF_TAG, hexToBuf32(c)])));
574
+ }
575
+
576
+ /** nodeHash(a,b) = keccak256(NODE_TAG ++ min(a,b) ++ max(a,b)) comparing as 32-byte big-endian values. */
577
+ function nodeHash(a, b) {
578
+ const A = hexToBuf32(a);
579
+ const B = hexToBuf32(b);
580
+ const [lo, hi] = Buffer.compare(A, B) <= 0 ? [A, B] : [B, A];
581
+ return bufToHex(keccak256(Buffer.concat([NODE_TAG, lo, hi])));
582
+ }
583
+
584
+ /**
585
+ * Build the sorted-leaf, domain-separated Merkle root from an array of per-file PATH-BOUND leaves
586
+ * (the same values pathLeaf produces). Leaves are sorted ascending by their 32-byte value, tagged via
587
+ * leafHash, then folded with nodeHash, pairing a lone odd node with itself — byte-identical to
588
+ * cli/hash.js buildTree's root.
589
+ * @param {string[]} leaves array of 0x bytes32 path-bound leaves
590
+ * @returns {string} the 0x bytes32 root
591
+ */
592
+ function rootFromLeaves(leaves) {
593
+ if (!Array.isArray(leaves) || leaves.length === 0) {
594
+ throw new Error("cannot build a Merkle tree from zero leaves");
595
+ }
596
+ const sorted = leaves
597
+ .slice()
598
+ .sort((a, b) => Buffer.compare(hexToBuf32(a), hexToBuf32(b)));
599
+ let layer = sorted.map((c) => leafHash(c));
600
+ while (layer.length > 1) {
601
+ const next = [];
602
+ for (let i = 0; i < layer.length; i += 2) {
603
+ const right = i + 1 < layer.length ? layer[i + 1] : layer[i];
604
+ next.push(nodeHash(layer[i], right));
605
+ }
606
+ layer = next;
607
+ }
608
+ return layer[0];
609
+ }
610
+
611
+ /**
612
+ * Re-derive the top-level root from a flat list of { relPath, contentHash } — the SAME computation the
613
+ * seal cores use: pathLeaf each, then rootFromLeaves. PURE.
614
+ * @param {{relPath:string, contentHash:string}[]} flat
615
+ * @returns {string} 0x bytes32 root
616
+ */
617
+ function rootFromFlat(flat) {
618
+ return rootFromLeaves(flat.map((e) => pathLeaf(e.relPath, e.contentHash)));
619
+ }
620
+
621
+ module.exports = {
622
+ HEX32_RE,
623
+ DIR_LEAF_DOMAIN_STR,
624
+ hashBytes,
625
+ toPosixRel,
626
+ pathLeaf,
627
+ leafHash,
628
+ nodeHash,
629
+ rootFromLeaves,
630
+ rootFromFlat,
631
+ };
632
+
633
+ };
634
+
635
+ // ===== module: canonical (from verifier/lib/canonical.js) =====
636
+ __modules["canonical"] = function (module, exports, __require) {
637
+ "use strict";
638
+
639
+ // verifier/lib/canonical.js — INDEPENDENT canonical UNSIGNED serialization.
640
+ //
641
+ // WHY THIS EXISTS
642
+ // For the independent `verifier/` to sign/hash over BYTE-IDENTICAL input to the production path, it must
643
+ // reproduce the family's canonical UNSIGNED serialization itself — WITHOUT importing the producer code in
644
+ // `cli/`. If the verifier imported `cli/dataset.js`'s `serializeAttestation`, a cross-check would be
645
+ // circular (it would be comparing a function to itself). So this file re-derives the SAME byte string,
646
+ // from first principles, and the cross-check test asserts it equals what the producer emits.
647
+ //
648
+ // THE CANONICAL CONVENTION (must match cli/core/attestation.js + cli/dataset.js#serializeAttestation)
649
+ // * A FIXED key order — NOT JSON.stringify's insertion order by accident, but an EXPLICIT ordered key
650
+ // list per object shape. We emit keys in that exact order.
651
+ // * NO insignificant whitespace (separators ",", ":").
652
+ // * A SINGLE trailing newline ("\n") terminating the document.
653
+ // The result is byte-deterministic: the same logical value always serializes to the same bytes.
654
+
655
+ /**
656
+ * Serialize a value to canonical JSON with an EXPLICIT key order, no insignificant whitespace, and NO
657
+ * trailing newline (the newline is the document-level convention added by the envelope serializers below).
658
+ *
659
+ * Key order: when `keyOrder[<path-or-shape>]` is provided we use it; otherwise keys are emitted in the
660
+ * object's own insertion order (matching the producer's explicit object literals, which V8 preserves).
661
+ * Because the producers always build their canonical objects via explicit ordered literals, reproducing
662
+ * that same ordered literal here yields byte-identical output WITHOUT a generic key-sorting pass.
663
+ *
664
+ * This is a minimal, dependency-free JSON emitter that matches JSON.stringify's escaping for the value
665
+ * shapes this family uses (strings, integers, booleans, null, nested objects/arrays).
666
+ *
667
+ * @param {*} value
668
+ * @returns {string} canonical JSON (no trailing newline)
669
+ */
670
+ function canonicalJson(value) {
671
+ // JSON.stringify with no spacing already emits ","/":" separators and standard string escaping with no
672
+ // insignificant whitespace. The ONLY thing it does not do for us is reorder keys — but the family's
673
+ // canonical objects are built as explicit ordered literals, so insertion order IS the canonical order.
674
+ // We therefore use JSON.stringify directly on a value whose keys are already in canonical order. This is
675
+ // intentionally the SAME primitive the producer uses, but driven from an INDEPENDENTLY constructed,
676
+ // explicitly-ordered object here (so the bytes are reproduced, not imported).
677
+ return JSON.stringify(value);
678
+ }
679
+
680
+ /**
681
+ * Reproduce the canonical UNSIGNED dataset-attestation bytes, byte-for-byte identical to
682
+ * `cli/dataset.js#serializeAttestation` — WITHOUT importing it.
683
+ *
684
+ * Canonical top-level key order (from the producer's explicit object literal):
685
+ * kind, schemaVersion, note, root, fileCount, manifestDigest, signed, signature
686
+ * then a single trailing newline.
687
+ *
688
+ * @param {object} env a validated UNSIGNED attestation envelope
689
+ * @returns {string} the canonical serialization (newline-terminated)
690
+ */
691
+ function serializeUnsignedDatasetAttestation(env) {
692
+ if (env == null || typeof env !== "object" || Array.isArray(env)) {
693
+ throw new Error("serializeUnsignedDatasetAttestation requires an attestation envelope object");
694
+ }
695
+ // Build the canonical object via an EXPLICIT ordered literal — independently of the producer.
696
+ const canonical = {
697
+ kind: env.kind,
698
+ schemaVersion: env.schemaVersion,
699
+ note: env.note,
700
+ root: env.root,
701
+ fileCount: env.fileCount,
702
+ manifestDigest: env.manifestDigest,
703
+ signed: env.signed,
704
+ signature: env.signature,
705
+ };
706
+ return canonicalJson(canonical) + "\n";
707
+ }
708
+
709
+ /**
710
+ * Generic canonical envelope serializer for the family's signed-attestation containers, reproducing
711
+ * `cli/core/attestation.js#serializeSignedAttestation` byte-for-byte WITHOUT importing it.
712
+ *
713
+ * Canonical key order: kind, schemaVersion, note, attestation, signature{scheme,signer,signature}
714
+ * then a single trailing newline.
715
+ *
716
+ * @param {object} container a signed-attestation container
717
+ * @returns {string} the canonical serialization (newline-terminated)
718
+ */
719
+ function serializeSignedContainer(container) {
720
+ if (container == null || typeof container !== "object" || Array.isArray(container)) {
721
+ throw new Error("serializeSignedContainer requires a signed-attestation container object");
722
+ }
723
+ const sig = container.signature || {};
724
+ const canonical = {
725
+ kind: container.kind,
726
+ schemaVersion: container.schemaVersion,
727
+ note: container.note,
728
+ attestation: container.attestation,
729
+ signature: {
730
+ scheme: sig.scheme,
731
+ signer: sig.signer,
732
+ signature: sig.signature,
733
+ },
734
+ };
735
+ return canonicalJson(canonical) + "\n";
736
+ }
737
+
738
+ // The reserved relPath of the synthetic HEADER leaf a reconciliation seal binds its verdict + input
739
+ // role partition into, byte-identical to trustledger/seal.js#SEAL_HEADER_RELPATH. A real file may not
740
+ // occupy it; the verifier folds the header content in under this relPath when re-deriving the root.
741
+ const TRUST_SEAL_HEADER_RELPATH = "__trustledger.seal-header__v1";
742
+
743
+ /**
744
+ * Reproduce the canonical "content" bytes of a reconciliation seal's verdict/role HEADER entry, byte-for-
745
+ * byte identical to trustledger/seal.js#_headerBytes — WITHOUT importing it. The header binds the recorded
746
+ * verdict (pass/reportDate/period) + each input's logical role→relPath into the SAME committed Merkle
747
+ * root as the files, so a verdict/role edit changes the header content -> its leaf -> the root.
748
+ *
749
+ * Canonical layout (FIXED key order, no insignificant whitespace, roles sorted by role):
750
+ * { v: 1, verdict: { pass, reportDate, period }, roles: [{ role, relPath }, ...] }
751
+ *
752
+ * @param {object} verdict { pass, reportDate, period }
753
+ * @param {{role:string, relPath:string}[]} inputs the seal's input role bindings
754
+ * @returns {Buffer} the canonical UTF-8 header content
755
+ */
756
+ function trustSealHeaderBytes(verdict, inputs) {
757
+ const canonical = {
758
+ v: 1,
759
+ verdict: {
760
+ pass: verdict.pass,
761
+ reportDate: verdict.reportDate,
762
+ period: verdict.period == null ? null : String(verdict.period),
763
+ },
764
+ roles: inputs
765
+ .map((i) => ({ role: i.role, relPath: i.relPath }))
766
+ .sort((a, b) => (a.role < b.role ? -1 : a.role > b.role ? 1 : 0)),
767
+ };
768
+ return Buffer.from(JSON.stringify(canonical), "utf8");
769
+ }
770
+
771
+ module.exports = {
772
+ canonicalJson,
773
+ serializeUnsignedDatasetAttestation,
774
+ serializeSignedContainer,
775
+ TRUST_SEAL_HEADER_RELPATH,
776
+ trustSealHeaderBytes,
777
+ };
778
+
779
+ };
780
+
781
+ // ===== module: secp256k1-recover (from verifier/lib/secp256k1-recover.js) =====
782
+ __modules["secp256k1-recover"] = function (module, exports, __require) {
783
+ "use strict";
784
+
785
+ // verifier/lib/secp256k1-recover.js — INDEPENDENT EIP-191 personal_sign signer recovery.
786
+ //
787
+ // WHY THIS EXISTS
788
+ // `verifier/` is a near-zero-dependency, SECOND implementation of the family's signature recovery, kept
789
+ // deliberately separate from the production ethers path so the two can be CROSS-CHECKED and can never
790
+ // silently drift (the anti-divergence guard in test/verifier.crypto.test.js). This file recovers the
791
+ // secp256k1 signer ADDRESS from an `eip191-personal-sign` 65-byte (r||s||v) signature over a message,
792
+ // using ONLY:
793
+ // * `js-sha3` (via ./keccak) for keccak256, and
794
+ // * a single, tiny, vendored elliptic-curve routine (below) for the secp256k1 public-key RECOVERY.
795
+ // It does NOT require `ethers`, `hardhat`, `cli/`, or `trustledger/`.
796
+ //
797
+ // THE secp256k1 ROUTINE (vendored, audited, standard)
798
+ // Public-key recovery from an ECDSA signature is textbook curve math over the secp256k1 group
799
+ // (SEC 1, §4.1.6 / §2.3). We implement exactly that with Node BigInt: affine point add/double on
800
+ // y^2 = x^3 + 7 (mod p), a constant-time-agnostic double-and-add scalar multiply, and a Tonelli-Shanks
801
+ // style square root (p ≡ 3 mod 4, so √a = a^((p+1)/4)). No randomness, no secrets — recovery is a PUBLIC
802
+ // computation over the signature + message hash, so timing/side-channels are irrelevant here. The curve
803
+ // constants are the canonical secp256k1 domain parameters.
804
+
805
+ const { keccak256 } = __require("keccak");
806
+
807
+ // ---- secp256k1 domain parameters (canonical) -------------------------------------------------------
808
+ const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; // field prime
809
+ const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; // group order
810
+ const A = 0n; // curve a
811
+ const B = 7n; // curve b
812
+ const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
813
+ const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
814
+
815
+ // ---- modular arithmetic helpers --------------------------------------------------------------------
816
+ function mod(a, m) {
817
+ const r = a % m;
818
+ return r >= 0n ? r : r + m;
819
+ }
820
+
821
+ // Modular inverse via the extended Euclidean algorithm (m is prime here, so a is invertible unless 0).
822
+ function invmod(a, m) {
823
+ a = mod(a, m);
824
+ if (a === 0n) throw new Error("secp256k1: inverse of zero");
825
+ let [old_r, r] = [a, m];
826
+ let [old_s, s] = [1n, 0n];
827
+ while (r !== 0n) {
828
+ const q = old_r / r;
829
+ [old_r, r] = [r, old_r - q * r];
830
+ [old_s, s] = [s, old_s - q * s];
831
+ }
832
+ return mod(old_s, m);
833
+ }
834
+
835
+ // Modular exponentiation (square-and-multiply).
836
+ function powmod(base, exp, m) {
837
+ base = mod(base, m);
838
+ let result = 1n;
839
+ while (exp > 0n) {
840
+ if (exp & 1n) result = mod(result * base, m);
841
+ base = mod(base * base, m);
842
+ exp >>= 1n;
843
+ }
844
+ return result;
845
+ }
846
+
847
+ // Square root mod p. secp256k1's p ≡ 3 (mod 4), so √a = a^((p+1)/4) mod p (when a is a QR).
848
+ function sqrtmod(a) {
849
+ const r = powmod(a, (P + 1n) / 4n, P);
850
+ if (mod(r * r, P) !== mod(a, P)) throw new Error("secp256k1: no square root (x not on curve)");
851
+ return r;
852
+ }
853
+
854
+ // ---- elliptic-curve point arithmetic (affine; null = point at infinity) ---------------------------
855
+ const INF = null;
856
+
857
+ function isInf(Pt) {
858
+ return Pt === INF;
859
+ }
860
+
861
+ function pointAdd(p1, p2) {
862
+ if (isInf(p1)) return p2;
863
+ if (isInf(p2)) return p1;
864
+ const [x1, y1] = p1;
865
+ const [x2, y2] = p2;
866
+ if (x1 === x2 && mod(y1 + y2, P) === 0n) return INF; // p1 = -p2
867
+ let m;
868
+ if (x1 === x2 && y1 === y2) {
869
+ // doubling: m = (3x^2 + a) / (2y)
870
+ m = mod((3n * x1 * x1 + A) * invmod(2n * y1, P), P);
871
+ } else {
872
+ m = mod((y2 - y1) * invmod(x2 - x1, P), P);
873
+ }
874
+ const x3 = mod(m * m - x1 - x2, P);
875
+ const y3 = mod(m * (x1 - x3) - y1, P);
876
+ return [x3, y3];
877
+ }
878
+
879
+ function scalarMul(k, point) {
880
+ k = mod(k, N);
881
+ let result = INF;
882
+ let addend = point;
883
+ while (k > 0n) {
884
+ if (k & 1n) result = pointAdd(result, addend);
885
+ addend = pointAdd(addend, addend);
886
+ k >>= 1n;
887
+ }
888
+ return result;
889
+ }
890
+
891
+ const G = [GX, GY];
892
+
893
+ // Decompress the curve point with x-coordinate `x` and the given y-parity (0 = even, 1 = odd).
894
+ function liftX(x, yParity) {
895
+ const alpha = mod(x * x * x + A * x + B, P); // y^2
896
+ let y = sqrtmod(alpha);
897
+ if ((y & 1n) !== BigInt(yParity)) y = mod(P - y, P);
898
+ return [x, y];
899
+ }
900
+
901
+ // ---- big-endian buffer <-> BigInt ------------------------------------------------------------------
902
+ function bufToBig(buf) {
903
+ let n = 0n;
904
+ for (const b of buf) n = (n << 8n) | BigInt(b);
905
+ return n;
906
+ }
907
+
908
+ function bigTo32(n) {
909
+ const out = Buffer.alloc(32);
910
+ for (let i = 31; i >= 0; i--) {
911
+ out[i] = Number(n & 0xffn);
912
+ n >>= 8n;
913
+ }
914
+ return out;
915
+ }
916
+
917
+ /**
918
+ * Recover the secp256k1 PUBLIC KEY from an ECDSA signature + 32-byte message hash (SEC 1, §4.1.6).
919
+ * @param {Buffer} msgHash 32-byte hash that was signed
920
+ * @param {bigint} r signature r
921
+ * @param {bigint} s signature s
922
+ * @param {number} recId recovery id 0..3 (derived from v)
923
+ * @returns {{x: bigint, y: bigint}} the recovered public-key point
924
+ */
925
+ function recoverPublicKey(msgHash, r, s, recId) {
926
+ if (r <= 0n || r >= N) throw new Error("secp256k1: r out of range");
927
+ if (s <= 0n || s >= N) throw new Error("secp256k1: s out of range");
928
+ if (recId < 0 || recId > 3) throw new Error("secp256k1: invalid recovery id");
929
+
930
+ // x = r + (recId >> 1) * N (the high bit of recId says whether r overflowed the field by one order)
931
+ const x = r + (recId >> 1 ? N : 0n);
932
+ if (x >= P) throw new Error("secp256k1: recovered x not in field");
933
+
934
+ // R = point with x-coordinate x and y-parity = (recId & 1).
935
+ const R = liftX(x, recId & 1);
936
+
937
+ // Q = r^-1 (s*R - e*G), where e = msgHash mod N.
938
+ const e = mod(bufToBig(msgHash), N);
939
+ const rInv = invmod(r, N);
940
+ const sR = scalarMul(s, R);
941
+ const eG = scalarMul(e, G);
942
+ const negEG = isInf(eG) ? INF : [eG[0], mod(P - eG[1], P)];
943
+ const Q = scalarMul(rInv, pointAdd(sR, negEG));
944
+ if (isInf(Q)) throw new Error("secp256k1: recovered point at infinity");
945
+ return { x: Q[0], y: Q[1] };
946
+ }
947
+
948
+ /**
949
+ * Derive the lowercase 0x Ethereum address from a recovered public-key point.
950
+ * address = "0x" + last 20 bytes of keccak256( X(32) || Y(32) ).
951
+ */
952
+ function pubKeyToAddress(pub) {
953
+ const raw = Buffer.concat([bigTo32(pub.x), bigTo32(pub.y)]); // 64-byte uncompressed (no 0x04 prefix)
954
+ const hash = keccak256(raw);
955
+ return "0x" + hash.slice(12).toString("hex");
956
+ }
957
+
958
+ /**
959
+ * Build the EIP-191 personal_sign pre-image for a message and return its keccak256 digest.
960
+ *
961
+ * EIP-191 personal_sign: keccak256( "\x19Ethereum Signed Message:\n" + <decimal byte length> + <message> ),
962
+ * where <message> is the EXACT canonical UTF-8 bytes (here, the canonical attestation string including its
963
+ * single trailing newline). This reproduces, byte-for-byte, what `cli/core/attestation.js` documents and
964
+ * what ethers' personal_sign hashes.
965
+ *
966
+ * @param {Buffer|Uint8Array|string} message UTF-8 message (string is encoded as UTF-8)
967
+ * @returns {Buffer} the 32-byte EIP-191 digest
968
+ */
969
+ function eip191Hash(message) {
970
+ const msgBytes = Buffer.isBuffer(message)
971
+ ? message
972
+ : message instanceof Uint8Array
973
+ ? Buffer.from(message)
974
+ : Buffer.from(String(message), "utf8");
975
+ const prefix = Buffer.from("\x19Ethereum Signed Message:\n" + msgBytes.length, "utf8");
976
+ return keccak256(Buffer.concat([prefix, msgBytes]));
977
+ }
978
+
979
+ /**
980
+ * Recover the lowercase 0x signer ADDRESS from an `eip191-personal-sign` 65-byte (r||s||v) signature over
981
+ * `message`. INDEPENDENT of ethers/hardhat — only ./keccak (js-sha3) + the vendored secp256k1 above.
982
+ *
983
+ * @param {Buffer|Uint8Array|string} message the EXACT canonical UTF-8 bytes that were signed
984
+ * @param {string|Buffer|Uint8Array} signature 65-byte r(32)||s(32)||v(1), as 0x-hex or raw bytes
985
+ * @returns {string} the recovered signer address, 0x-prefixed lowercase
986
+ */
987
+ function recoverPersonalSignAddress(message, signature) {
988
+ const sig = normalizeSig(signature);
989
+ const r = bufToBig(sig.subarray(0, 32));
990
+ const s = bufToBig(sig.subarray(32, 64));
991
+ let v = sig[64];
992
+ // Accept v in {0,1} or {27,28} (and EIP-155-ish higher v reduced to parity). recId is v's low bit.
993
+ if (v >= 27) v -= 27;
994
+ if (v !== 0 && v !== 1) {
995
+ // Fall back to parity for any non-canonical encoding; reject only the wildly invalid.
996
+ v = v & 1;
997
+ }
998
+ const digest = eip191Hash(message);
999
+ const pub = recoverPublicKey(digest, r, s, v);
1000
+ return pubKeyToAddress(pub);
1001
+ }
1002
+
1003
+ function normalizeSig(signature) {
1004
+ let buf;
1005
+ if (Buffer.isBuffer(signature)) {
1006
+ buf = signature;
1007
+ } else if (signature instanceof Uint8Array) {
1008
+ buf = Buffer.from(signature);
1009
+ } else if (typeof signature === "string") {
1010
+ const hex = signature.startsWith("0x") || signature.startsWith("0X") ? signature.slice(2) : signature;
1011
+ if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) {
1012
+ throw new Error("secp256k1: signature must be 0x-hex (even length)");
1013
+ }
1014
+ buf = Buffer.from(hex, "hex");
1015
+ } else {
1016
+ throw new TypeError("secp256k1: signature must be a 0x-hex string or byte buffer");
1017
+ }
1018
+ if (buf.length !== 65) {
1019
+ throw new Error(`secp256k1: eip191-personal-sign signature must be 65 bytes (r||s||v), got ${buf.length}`);
1020
+ }
1021
+ return buf;
1022
+ }
1023
+
1024
+ module.exports = {
1025
+ recoverPersonalSignAddress,
1026
+ eip191Hash,
1027
+ recoverPublicKey,
1028
+ pubKeyToAddress,
1029
+ // exported for tests/audit:
1030
+ _internal: { mod, invmod, powmod, sqrtmod, pointAdd, scalarMul, liftX, G, N, P },
1031
+ };
1032
+
1033
+ };
1034
+
1035
+ // ===== module: revocation-core (from verifier/lib/revocation-core.js) =====
1036
+ __modules["revocation-core"] = function (module, exports, __require) {
1037
+ "use strict";
1038
+
1039
+ // verifier/lib/revocation-core.js — the PURE (I/O-FREE, fs-FREE, path-FREE, clock-FREE) half of the
1040
+ // stack-free recipient-side KEY-REVOCATION reader + as-of decision (EPIC-51 / T-51.4, split out by T-66.1).
1041
+ //
1042
+ // WHY THIS FILE IS SEPARATE
1043
+ // T-66.1 gives the independent verifier an IN-MEMORY file-source seam (`verifyArtifactFromBytes`) whose
1044
+ // whole code path must be portable off Node (a browser page, a vm sandbox): NO `fs`, NO `path`, NO `os`,
1045
+ // NO `process` may be reachable from it — statically, at module scope (the same discipline
1046
+ // trustledger/lib/policy-bundled-loader.js established for the TrustLedger browser core). The revocation
1047
+ // DECISION (validate + recover + classify + as-of fold) was always pure, but it lived in the same module
1048
+ // as the FILE/DIR reader, which requires `fs` + `path` at module scope. This file is the decision, alone:
1049
+ // every function here is a pure function of its arguments; requiring this module touches NOTHING impure.
1050
+ // verifier/lib/revocation.js re-exports everything from here VERBATIM (the same function objects) and adds
1051
+ // the two — and only two — fs-backed conveniences (`readRevocationsFromPath`, `loadAndApply`), so every
1052
+ // existing caller keeps its exact surface and byte-identical behavior.
1053
+ //
1054
+ // EVERYTHING BELOW IS MOVED VERBATIM from verifier/lib/revocation.js (no semantic edit) — see that file's
1055
+ // header for the full design rationale (the load-bearing self-control invariant, subject scoping, and the
1056
+ // gate-for-gate parity with the producer stack's cli/core/trust-asof.js + cli/core/revocation.js).
1057
+
1058
+ const { recoverPersonalSignAddress } = __require("secp256k1-recover");
1059
+
1060
+ // ---------------------------------------------------------------------------
1061
+ // On-disk discriminators + grammars — byte-identical to cli/core/revocation.js so a producer-minted
1062
+ // revocation reads here verbatim.
1063
+ // ---------------------------------------------------------------------------
1064
+
1065
+ const SIGNED_REVOCATION_KIND = "vh-key-revocation-signed";
1066
+ const REVOCATION_KIND = "vh-key-revocation";
1067
+
1068
+ // The SCHEMA versions this build understands — byte-identical to cli/core/revocation.js's
1069
+ // SUPPORTED_*_SCHEMA_VERSIONS. The producer REJECTS (IGNORES) a revocation whose container OR embedded
1070
+ // payload carries an unsupported schemaVersion, so the verifier must too (parity).
1071
+ const REVOCATION_SCHEMA_VERSION = 1;
1072
+ const SUPPORTED_REVOCATION_SCHEMA_VERSIONS = Object.freeze([1]);
1073
+ const SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS = Object.freeze([1]);
1074
+
1075
+ // The CLOSED reason set (the producer's REVOCATION_REASON_SET, sorted). An out-of-set reason marks the
1076
+ // embedded revocation structurally malformed — the entry is IGNORED (never silently honored).
1077
+ const REVOCATION_REASON_SET = Object.freeze(["compromised", "retired", "rotated", "superseded"]);
1078
+
1079
+ // The CLOSED field set of an UNSIGNED revocation payload — byte-identical to cli/core/revocation.js's
1080
+ // REVOCATION_FIELDS. The producer HARD-rejects any extraneous/unknown key (validateRevocation), IGNORING
1081
+ // the revocation; the verifier must enforce the SAME closed set so a smuggled extra field can never make
1082
+ // the two stacks disagree (a self-signed-but-non-canonical revocation the producer ignores must be ignored
1083
+ // here too). `supersededBy` is OPTIONAL but a member of the set.
1084
+ const REVOCATION_FIELDS = Object.freeze([
1085
+ "kind",
1086
+ "schemaVersion",
1087
+ "note",
1088
+ "vendorAddress",
1089
+ "reason",
1090
+ "revokedAt",
1091
+ "supersededBy",
1092
+ ]);
1093
+
1094
+ // The standing in-band trust NOTES — copied VERBATIM from cli/core/revocation.js so the verifier pins the
1095
+ // EXACT same `note` text the producer requires. The producer's validateRevocation requires the embedded
1096
+ // payload's `note` to equal REVOCATION_TRUST_NOTE, and validateSignedAttestation requires the container's
1097
+ // `note` to equal SIGNED_REVOCATION_TRUST_NOTE; a revocation with a wrong/absent note is IGNORED by the
1098
+ // producer, so the verifier must ignore it too (parity). These strings are LOAD-BEARING for parity, not
1099
+ // for security (the signature binds the bytes regardless) — they must never drift from the producer's.
1100
+ const REVOCATION_TRUST_NOTE =
1101
+ "This is a verifyhash producer KEY REVOCATION: the holder of `vendorAddress`'s key SIGNED it, declaring " +
1102
+ "that address REVOKED as of `revokedAt` for `reason` (optionally superseded by `supersededBy`). verify " +
1103
+ "RE-DERIVES the signer from these exact bytes and REQUIRES it to equal `vendorAddress` — a key revokes " +
1104
+ "ITSELF; a third party cannot revoke a key it does not control. It proves the KEY-HOLDER's SIGNED CLAIM " +
1105
+ 'ONLY: `revokedAt` is the holder\'s self-asserted instant, NOT a trusted TIMESTAMP (it rides the human-' +
1106
+ "owned timestamp trust-root, STRATEGY.md P-3), and this is NOT a legal opinion.";
1107
+
1108
+ const SIGNED_REVOCATION_TRUST_NOTE =
1109
+ "This is a SIGNED verifyhash key-revocation container: it WRAPS (never edits) the EXACT canonical " +
1110
+ "revocation bytes in `attestation` and attaches a detached EIP-191 signature. verifyRevocation " +
1111
+ "RE-DERIVES the signer from those bytes and pins it to the embedded `vendorAddress` — it never trusts " +
1112
+ "the file's own claims. Every caveat of the embedded revocation applies. " +
1113
+ REVOCATION_TRUST_NOTE;
1114
+
1115
+ // A claimed 0x-address INSIDE the payload: 0x + 40 LOWERCASE hex (byte-determinism — mixed-case rejected).
1116
+ const PAYLOAD_ADDRESS_RE = /^0x[0-9a-f]{40}$/;
1117
+ // A recovered/expected address (the verifier lowercases everything it compares).
1118
+ const ADDRESS_RE = /^0x[0-9a-f]{40}$/;
1119
+ // A 65-byte (r||s||v) signature as 0x-hex — LOWERCASE-only, byte-for-byte the producer's EIP191_SIG_RE
1120
+ // (cli/core/attestation.js). The producer REJECTS mixed/upper-case hex for byte-determinism and IGNORES a
1121
+ // revocation carrying it; accepting mixed case here would let a third party re-encode a holder's genuine
1122
+ // revocation into one the producer drops but the verifier honors — a parity split with NO key required. So
1123
+ // the verifier pins the SAME lowercase grammar.
1124
+ const SIGNATURE_RE = /^0x[0-9a-f]{130}$/;
1125
+ // A strict, CANONICAL ISO-8601 UTC instant — the SAME grammar revokedAt / asOf are pinned to.
1126
+ const ISO_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
1127
+
1128
+ // The recovered-signer sentinel the producer core returns for an unrecoverable signature.
1129
+ const UNRECOVERABLE = "(unrecoverable)";
1130
+
1131
+ // A dedicated error type for the HARD input errors of THIS helper (a malformed asOf, a non-JSON/wrong-type
1132
+ // revocations input, an unreadable path). An individual BOGUS revocation is NEVER thrown — it is collected as
1133
+ // an ignored warning so one bad entry can never abort the evaluation of the good ones.
1134
+ class RevocationReadError extends Error {
1135
+ constructor(message) {
1136
+ super(message);
1137
+ this.name = "RevocationReadError";
1138
+ }
1139
+ }
1140
+
1141
+ function isPlainObject(v) {
1142
+ return v != null && typeof v === "object" && !Array.isArray(v);
1143
+ }
1144
+
1145
+ // ---------------------------------------------------------------------------
1146
+ // Canonical-instant parsing (asOf + revokedAt share this).
1147
+ // ---------------------------------------------------------------------------
1148
+
1149
+ function parseCanonicalInstant(value, label) {
1150
+ if (typeof value !== "string" || !ISO_INSTANT_RE.test(value)) {
1151
+ throw new RevocationReadError(
1152
+ `${label} must be an ISO-8601 UTC instant ("YYYY-MM-DDTHH:MM:SS(.mmm)Z"), got: ${String(value)}`
1153
+ );
1154
+ }
1155
+ const ms = Date.parse(value);
1156
+ if (Number.isNaN(ms) || new Date(ms).toISOString() !== value) {
1157
+ throw new RevocationReadError(
1158
+ `${label} must be a canonical ISO-8601 UTC instant (no rolled-over/impossible fields), got: ${String(value)}`
1159
+ );
1160
+ }
1161
+ return ms;
1162
+ }
1163
+
1164
+ /**
1165
+ * Resolve the effective `--as-of` instant. PURE. When the recipient supplied one, validate + use it; when
1166
+ * they did not, default to the recipient's CURRENT decision time (`nowISO`, injectable for tests). A
1167
+ * malformed explicit `--as-of` is a HARD RevocationReadError (never silently coerced to now). Mirrors
1168
+ * cli/core/trust-asof.js resolveAsOf.
1169
+ * @param {string|undefined|null} asOf
1170
+ * @param {string} nowISO the recipient's current instant (ISO-8601 UTC)
1171
+ * @returns {{ asOf: string, defaulted: boolean }}
1172
+ */
1173
+ function resolveAsOf(asOf, nowISO) {
1174
+ if (asOf !== undefined && asOf !== null && asOf !== "") {
1175
+ parseCanonicalInstant(asOf, "--as-of"); // validate shape; throws on malformed
1176
+ return { asOf, defaulted: false };
1177
+ }
1178
+ if (typeof nowISO !== "string") {
1179
+ throw new RevocationReadError("resolveAsOf requires a nowISO instant when --as-of is not given");
1180
+ }
1181
+ parseCanonicalInstant(nowISO, "nowISO"); // the injected/default now must itself be canonical
1182
+ return { asOf: nowISO, defaulted: true };
1183
+ }
1184
+
1185
+ /**
1186
+ * Serialize a validated UNSIGNED revocation payload to its CANONICAL, byte-deterministic bytes — a FIXED key
1187
+ * order, NO insignificant whitespace, a single trailing newline. This is a LINE-FOR-LINE port of
1188
+ * cli/core/revocation.js serializeRevocation: the FIXED field order (kind, schemaVersion, note,
1189
+ * vendorAddress, reason, revokedAt) with `supersededBy` appended LAST and ONLY when present. It lets the
1190
+ * verifier perform the producer's canonical-bytes BINDING check (`attestation === serializeRevocation(...)`)
1191
+ * with NO ethers — the producer's whole point that two logically-identical revocations serialize identically.
1192
+ * @param {object} payload an already-structurally-validated revocation payload
1193
+ * @returns {string} the canonical serialization (newline-terminated)
1194
+ */
1195
+ function serializeRevocation(payload) {
1196
+ const canonical = {
1197
+ kind: payload.kind,
1198
+ schemaVersion: payload.schemaVersion,
1199
+ note: payload.note,
1200
+ vendorAddress: payload.vendorAddress,
1201
+ reason: payload.reason,
1202
+ revokedAt: payload.revokedAt,
1203
+ };
1204
+ if (Object.prototype.hasOwnProperty.call(payload, "supersededBy") && payload.supersededBy !== undefined) {
1205
+ canonical.supersededBy = payload.supersededBy;
1206
+ }
1207
+ return JSON.stringify(canonical) + "\n";
1208
+ }
1209
+
1210
+ // ---------------------------------------------------------------------------
1211
+ // Structural validation of a parsed SIGNED revocation container + its embedded payload. A structurally
1212
+ // invalid container is REJECTED by THROWING (the caller catches + IGNORES it with a warning) — never
1213
+ // half-accepted.
1214
+ //
1215
+ // PARITY-CRITICAL: this MIRRORS the producer's cli/core/attestation.js validateSignedAttestation +
1216
+ // cli/core/revocation.js validateRevocation so the two stacks IGNORE the EXACT same malformed-but-self-signed
1217
+ // revocations. The producer's verdict-gating structural checks the verifier MUST replicate (or the offline
1218
+ // path reaches REVOKED where the producer reaches OK on identical inputs) are:
1219
+ // - the CONTAINER carries the right kind, a SUPPORTED schemaVersion, and the standing SIGNED note;
1220
+ // - the signature block has a known scheme, a 65-byte LOWERCASE-hex signature, and a lowercase signer;
1221
+ // - the EMBEDDED payload re-validates as a sound UNSIGNED revocation: a CLOSED field set (no extra/unknown
1222
+ // key), the right kind, a SUPPORTED schemaVersion, the standing UNSIGNED note, a lowercase vendorAddress,
1223
+ // a closed-set reason, a canonical revokedAt, an optional lowercase supersededBy; AND
1224
+ // - the WRAP-DON'T-EDIT binding: the embedded `attestation` STRING is byte-for-byte the canonical
1225
+ // re-serialization of the embedded payload (so a non-canonical / reordered / whitespace variant is
1226
+ // IGNORED, exactly as the producer ignores it).
1227
+ // The signature recovery (verifyRevocation) is what makes a revocation SAFE against forgers; these structural
1228
+ // checks are what make the verifier's VERDICT EQUAL the producer's on every malformed input it sees.
1229
+ // ---------------------------------------------------------------------------
1230
+
1231
+ function validateSignedRevocation(obj) {
1232
+ if (!isPlainObject(obj)) {
1233
+ throw new RevocationReadError("revocation container must be a JSON object");
1234
+ }
1235
+ if (obj.kind !== SIGNED_REVOCATION_KIND) {
1236
+ throw new RevocationReadError(
1237
+ `not a signed key-revocation (kind ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(SIGNED_REVOCATION_KIND)})`
1238
+ );
1239
+ }
1240
+ // The CONTAINER schemaVersion must be supported (the producer's validateSignedAttestation rejects an
1241
+ // unsupported one before any recovery — so the verifier must too).
1242
+ if (!SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
1243
+ throw new RevocationReadError(
1244
+ `unsupported signed revocation schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
1245
+ `(this build understands ${JSON.stringify(SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS)})`
1246
+ );
1247
+ }
1248
+ // The CONTAINER note must be the standing SIGNED note (the producer pins it; a drifted note is IGNORED).
1249
+ if (obj.note !== SIGNED_REVOCATION_TRUST_NOTE) {
1250
+ throw new RevocationReadError("signed revocation `note` must be the standing SIGNED_REVOCATION_TRUST_NOTE");
1251
+ }
1252
+ if (typeof obj.attestation !== "string") {
1253
+ throw new RevocationReadError("signed revocation must embed the canonical UNSIGNED bytes as a string `attestation`");
1254
+ }
1255
+ const sig = obj.signature;
1256
+ if (!isPlainObject(sig)) {
1257
+ throw new RevocationReadError("signed revocation is missing a { scheme, signer, signature } signature block");
1258
+ }
1259
+ if (sig.scheme !== "eip191-personal-sign") {
1260
+ throw new RevocationReadError(
1261
+ `unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
1262
+ );
1263
+ }
1264
+ // LOWERCASE-only signer + signature, byte-for-byte the producer's ADDRESS_RE / EIP191_SIG_RE — a
1265
+ // mixed/upper-case re-encoding is IGNORED by the producer, so it must be ignored here too.
1266
+ if (typeof sig.signer !== "string" || !PAYLOAD_ADDRESS_RE.test(sig.signer)) {
1267
+ throw new RevocationReadError(
1268
+ "signed revocation signer must be a 0x-prefixed 20-byte LOWERCASE-hex address (mixed/upper case rejected for byte-determinism)"
1269
+ );
1270
+ }
1271
+ if (typeof sig.signature !== "string" || !SIGNATURE_RE.test(sig.signature)) {
1272
+ throw new RevocationReadError(
1273
+ "signed revocation signature must be a 65-byte (r||s||v) 0x-prefixed LOWERCASE-hex string (mixed/upper case rejected for byte-determinism)"
1274
+ );
1275
+ }
1276
+
1277
+ // Parse + strictly validate the embedded revocation payload (the producer's validateRevocation, mirrored).
1278
+ let rev;
1279
+ try {
1280
+ rev = JSON.parse(obj.attestation);
1281
+ } catch (e) {
1282
+ throw new RevocationReadError(`embedded revocation is not valid JSON: ${e.message}`);
1283
+ }
1284
+ if (!isPlainObject(rev)) {
1285
+ throw new RevocationReadError("embedded revocation payload must be a JSON object");
1286
+ }
1287
+ // CLOSED FIELD SET: an unknown/extraneous key is a HARD reject (the producer IGNORES such a revocation).
1288
+ for (const key of Object.keys(rev)) {
1289
+ if (!REVOCATION_FIELDS.includes(key)) {
1290
+ throw new RevocationReadError(
1291
+ `revocation has an unknown field: ${JSON.stringify(key)} (the closed field set is ${JSON.stringify(REVOCATION_FIELDS)})`
1292
+ );
1293
+ }
1294
+ }
1295
+ if (rev.kind !== REVOCATION_KIND) {
1296
+ throw new RevocationReadError(
1297
+ `embedded payload is not a key revocation (kind ${JSON.stringify(rev.kind)}; expected ${JSON.stringify(REVOCATION_KIND)})`
1298
+ );
1299
+ }
1300
+ if (!SUPPORTED_REVOCATION_SCHEMA_VERSIONS.includes(rev.schemaVersion)) {
1301
+ throw new RevocationReadError(
1302
+ `unsupported revocation schemaVersion: ${JSON.stringify(rev.schemaVersion)} ` +
1303
+ `(this build understands ${JSON.stringify(SUPPORTED_REVOCATION_SCHEMA_VERSIONS)})`
1304
+ );
1305
+ }
1306
+ if (rev.note !== REVOCATION_TRUST_NOTE) {
1307
+ throw new RevocationReadError("revocation `note` must be the standing REVOCATION_TRUST_NOTE (caveat must not drift)");
1308
+ }
1309
+ if (typeof rev.vendorAddress !== "string" || !PAYLOAD_ADDRESS_RE.test(rev.vendorAddress)) {
1310
+ throw new RevocationReadError(
1311
+ `revocation vendorAddress must be a 0x-prefixed 20-byte LOWERCASE-hex address, got: ${String(rev.vendorAddress)}`
1312
+ );
1313
+ }
1314
+ if (typeof rev.reason !== "string" || !REVOCATION_REASON_SET.includes(rev.reason)) {
1315
+ throw new RevocationReadError(
1316
+ `revocation reason must be one of ${JSON.stringify(REVOCATION_REASON_SET)}, got: ${JSON.stringify(rev.reason)}`
1317
+ );
1318
+ }
1319
+ parseCanonicalInstant(rev.revokedAt, "revocation revokedAt"); // throws on a non-canonical instant
1320
+ if (
1321
+ Object.prototype.hasOwnProperty.call(rev, "supersededBy") &&
1322
+ rev.supersededBy !== undefined &&
1323
+ (typeof rev.supersededBy !== "string" || !PAYLOAD_ADDRESS_RE.test(rev.supersededBy))
1324
+ ) {
1325
+ throw new RevocationReadError(
1326
+ `revocation supersededBy, when present, must be a 0x-prefixed 20-byte LOWERCASE-hex address, got: ${String(rev.supersededBy)}`
1327
+ );
1328
+ }
1329
+ // WRAP-DON'T-EDIT BINDING (the producer's `obj.attestation !== cfg.serializeUnsigned(embedded)` gate). The
1330
+ // embedded STRING must be byte-for-byte the canonical re-serialization of the embedded payload — so a
1331
+ // reordered-keys / extra-whitespace / otherwise non-canonical (but genuinely self-signed) variant is
1332
+ // IGNORED here exactly as the producer ignores it. THIS is the check that closes the headline parity gap.
1333
+ if (obj.attestation !== serializeRevocation(rev)) {
1334
+ throw new RevocationReadError(
1335
+ "embedded revocation is not in canonical form (the signed-over bytes must be byte-for-byte the canonical serialization)"
1336
+ );
1337
+ }
1338
+ return { container: obj, revocation: rev };
1339
+ }
1340
+
1341
+ /**
1342
+ * Verify (purely, OFFLINE) a parsed SIGNED revocation container — the STACK-FREE mirror of the producer's
1343
+ * verifyRevocation. It recovers the signer from the embedded canonical bytes + signature and:
1344
+ * (1) confirms it equals the container's CLAIMED `signer` (signatureMatchesSigner — ALWAYS run);
1345
+ * (2) confirms it equals the revocation's OWN embedded `vendorAddress` (vendorAddressMatchesSigner — the
1346
+ * load-bearing SELF-CONTROL check: a key revokes ITSELF).
1347
+ * The verdict is ACCEPTED only when BOTH pass; a forged/tampered/third-party revocation is a clean REJECTED.
1348
+ * A structurally invalid container THROWS (RevocationReadError) before any recovery, so an ordinary REJECTED
1349
+ * verdict only ever describes a STRUCTURALLY SOUND revocation whose signature simply doesn't back its claims.
1350
+ *
1351
+ * @param {object} container a parsed signed-revocation container object
1352
+ * @returns {{ accepted, recoveredSigner, claimedSigner, vendorAddress, reason, revokedAt, supersededBy, failedChecks }}
1353
+ */
1354
+ function verifyRevocation(container) {
1355
+ const { revocation } = validateSignedRevocation(container);
1356
+ const claimedSigner = container.signature.signer.toLowerCase();
1357
+ const vendorAddress = revocation.vendorAddress;
1358
+
1359
+ // Recover the signer from the EXACT embedded bytes. A tampered/corrupt signature can be UNRECOVERABLE (no
1360
+ // valid curve point) — that throws; we map it to the "(unrecoverable)" sentinel, never a crash, mirroring
1361
+ // the producer core's catch.
1362
+ let recoveredSigner;
1363
+ try {
1364
+ recoveredSigner = recoverPersonalSignAddress(container.attestation, container.signature.signature);
1365
+ } catch (_) {
1366
+ recoveredSigner = UNRECOVERABLE;
1367
+ }
1368
+
1369
+ const signatureMatchesSigner = recoveredSigner === claimedSigner;
1370
+ const vendorAddressMatchesSigner = recoveredSigner === vendorAddress;
1371
+
1372
+ const failedChecks = [];
1373
+ if (!signatureMatchesSigner) failedChecks.push("signatureMatchesSigner");
1374
+ if (!vendorAddressMatchesSigner) failedChecks.push("vendorAddressMatchesSigner");
1375
+
1376
+ return {
1377
+ accepted: failedChecks.length === 0,
1378
+ recoveredSigner,
1379
+ claimedSigner,
1380
+ vendorAddress,
1381
+ reason: revocation.reason,
1382
+ revokedAt: revocation.revokedAt,
1383
+ supersededBy: Object.prototype.hasOwnProperty.call(revocation, "supersededBy")
1384
+ ? revocation.supersededBy
1385
+ : null,
1386
+ failedChecks,
1387
+ };
1388
+ }
1389
+
1390
+ // ---------------------------------------------------------------------------
1391
+ // Normalize the `revocations` input into a flat array of entries to evaluate. PURE. Accepts an ARRAY of
1392
+ // already-parsed containers (or JSON strings), a single container object, or a JSON STRING of either (a
1393
+ // bundle file is a JSON ARRAY of containers, or a single container object). A per-entry parse failure becomes
1394
+ // a `_parseError` marker (IGNORED with a warning); a WHOLE-input parse failure HARD-errors. Mirrors
1395
+ // cli/core/trust-asof.js normalizeRevocationsInput.
1396
+ // ---------------------------------------------------------------------------
1397
+
1398
+ function normalizeRevocationsInput(revocations) {
1399
+ if (typeof revocations === "string") {
1400
+ let parsed;
1401
+ try {
1402
+ parsed = JSON.parse(revocations);
1403
+ } catch (e) {
1404
+ throw new RevocationReadError(`revocations input is not valid JSON: ${e.message}`);
1405
+ }
1406
+ return normalizeRevocationsInput(parsed);
1407
+ }
1408
+ if (Array.isArray(revocations)) {
1409
+ return revocations.map((el) => {
1410
+ if (typeof el === "string") {
1411
+ try {
1412
+ return JSON.parse(el);
1413
+ } catch (e) {
1414
+ return { _parseError: `entry is not valid JSON: ${e.message}`, _raw: el };
1415
+ }
1416
+ }
1417
+ return el;
1418
+ });
1419
+ }
1420
+ if (isPlainObject(revocations)) {
1421
+ return [revocations];
1422
+ }
1423
+ throw new RevocationReadError(
1424
+ "revocations input must be a signed-revocation container, an array of them, or JSON text of either"
1425
+ );
1426
+ }
1427
+
1428
+ // Classify ONE already-parsed revocation entry against the subject + as-of pivot. PURE. Mirrors
1429
+ // cli/core/trust-asof.js classifyRevocation exactly (the same `applies`/`later`/`irrelevant`/`ignored`
1430
+ // outcomes + the inclusive `revokedAt <= asOf` boundary).
1431
+ function classifyRevocation(entry, subject, asOfMs) {
1432
+ if (entry && entry._parseError) {
1433
+ return { kind: "ignored", warning: `ignored an unparseable revocation entry (${entry._parseError})` };
1434
+ }
1435
+ let v;
1436
+ try {
1437
+ v = verifyRevocation(entry);
1438
+ } catch (e) {
1439
+ return { kind: "ignored", warning: `ignored a malformed/foreign revocation (${e.message})` };
1440
+ }
1441
+ if (!v.accepted) {
1442
+ return {
1443
+ kind: "ignored",
1444
+ warning:
1445
+ `ignored a revocation that does not verify (failed: ${v.failedChecks.join(", ")}; ` +
1446
+ `vendorAddress ${v.vendorAddress}) — a forged/tampered/third-party revocation never downgrades trust`,
1447
+ };
1448
+ }
1449
+ if (v.vendorAddress !== subject) {
1450
+ return { kind: "irrelevant", vendorAddress: v.vendorAddress };
1451
+ }
1452
+ const revokedAtMs = Date.parse(v.revokedAt);
1453
+ const detail = {
1454
+ vendorAddress: v.vendorAddress,
1455
+ reason: v.reason,
1456
+ revokedAt: v.revokedAt,
1457
+ supersededBy: v.supersededBy,
1458
+ };
1459
+ // Inclusive on the revoked side: a revocation effective EXACTLY at the as-of instant counts as revoked.
1460
+ if (revokedAtMs <= asOfMs) {
1461
+ return { kind: "applies", ...detail };
1462
+ }
1463
+ return { kind: "later", ...detail };
1464
+ }
1465
+
1466
+ /**
1467
+ * THE RECIPIENT-SIDE TRUST-DECISION-AS-OF. PURE / OFFLINE / KEY-FREE / I/O-FREE / CLOCK-FREE. Identical
1468
+ * semantics to cli/core/trust-asof.js evaluateTrustAsOf (so verify-vh's downgrade matches the producer's
1469
+ * byte-for-byte). Returns a stable decision block.
1470
+ * @param {object} params { subject, asOf, revocations }
1471
+ * @returns {{ status, revoked, subject, asOf, governing, laterRevoked, counts, ignored }}
1472
+ */
1473
+ function evaluateTrustAsOf(params) {
1474
+ if (!isPlainObject(params)) {
1475
+ throw new RevocationReadError("evaluateTrustAsOf requires { subject, asOf, revocations }");
1476
+ }
1477
+ const { subject, asOf, revocations } = params;
1478
+ if (typeof subject !== "string" || subject.length === 0) {
1479
+ throw new RevocationReadError("evaluateTrustAsOf requires a string `subject` (the artifact's recovered signer)");
1480
+ }
1481
+ const asOfMs = parseCanonicalInstant(asOf, "--as-of");
1482
+ const entries = normalizeRevocationsInput(revocations);
1483
+
1484
+ // A non-address subject (the "(unrecoverable)" sentinel) cannot be matched by any revocation — still
1485
+ // evaluate every entry (so forged ones are reported as ignored), but no SOUND revocation can apply.
1486
+ const subjectIsAddress = ADDRESS_RE.test(subject);
1487
+
1488
+ const applicable = [];
1489
+ const later = [];
1490
+ let irrelevant = 0;
1491
+ const ignored = [];
1492
+
1493
+ for (const entry of entries) {
1494
+ const c = classifyRevocation(entry, subject, asOfMs);
1495
+ if (c.kind === "ignored") ignored.push(c.warning);
1496
+ else if (c.kind === "irrelevant") irrelevant += 1;
1497
+ else if (c.kind === "later") later.push(c);
1498
+ else if (c.kind === "applies") applicable.push(c);
1499
+ }
1500
+
1501
+ // The GOVERNING revocation is the EARLIEST applicable one (smallest revokedAt), tie-broken deterministically
1502
+ // on vendorAddress then reason — the instant from which the key was no longer trustworthy.
1503
+ const sortByEffective = (a, b) =>
1504
+ Date.parse(a.revokedAt) - Date.parse(b.revokedAt) ||
1505
+ (a.vendorAddress < b.vendorAddress ? -1 : a.vendorAddress > b.vendorAddress ? 1 : 0) ||
1506
+ (a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
1507
+
1508
+ const govern = (arr) => {
1509
+ if (arr.length === 0) return null;
1510
+ const [g] = arr.slice().sort(sortByEffective);
1511
+ return { vendorAddress: g.vendorAddress, reason: g.reason, revokedAt: g.revokedAt, supersededBy: g.supersededBy };
1512
+ };
1513
+
1514
+ const governing = govern(applicable);
1515
+ const laterRevoked = governing ? null : govern(later);
1516
+
1517
+ let status;
1518
+ if (governing) status = "REVOKED";
1519
+ else if (!subjectIsAddress) status = "UNEVALUABLE";
1520
+ else status = "OK";
1521
+
1522
+ return {
1523
+ status,
1524
+ revoked: status === "REVOKED",
1525
+ subject,
1526
+ asOf,
1527
+ governing,
1528
+ laterRevoked,
1529
+ counts: {
1530
+ total: entries.length,
1531
+ applicable: applicable.length,
1532
+ later: later.length,
1533
+ irrelevant,
1534
+ ignored: ignored.length,
1535
+ },
1536
+ ignored,
1537
+ };
1538
+ }
1539
+
1540
+ /**
1541
+ * Fold a TRUST-DECISION-AS-OF onto an existing verify-vh result, OFFLINE. PURE. Mirrors cli/core/trust-asof.js
1542
+ * applyToVerifyResult: it NEVER upgrades a verdict — an already-REJECTED artifact stays rejected; the
1543
+ * trust-as-of only ever ADDS a REVOKED downgrade on top of an otherwise-ACCEPTED artifact. Returns a NEW
1544
+ * result object (the original is not mutated): the original fields PLUS `trustAsOf`, with accepted/verdict/
1545
+ * reason updated when REVOKED.
1546
+ *
1547
+ * The `subject` is the artifact's RECOVERED signer. When the signature did not even recover (the
1548
+ * "(unrecoverable)" sentinel / a null), no revocation can bind — the decision is UNEVALUABLE and never
1549
+ * changes the (already-rejected) verdict.
1550
+ *
1551
+ * @param {object} params { result, revocations, asOf }
1552
+ * @returns {object} a new result with `trustAsOf` attached
1553
+ */
1554
+ function applyToVerifyResult(params) {
1555
+ if (!isPlainObject(params) || !isPlainObject(params.result)) {
1556
+ throw new RevocationReadError("applyToVerifyResult requires { result, revocations, asOf }");
1557
+ }
1558
+ const { result, revocations, asOf } = params;
1559
+ // The subject is the recovered signer. verify-vh leaves recoveredSigner null for an UNSIGNED artifact and
1560
+ // sets the "(unrecoverable)" sentinel for a broken signature — both are non-addresses, so neither binds.
1561
+ const subject =
1562
+ typeof result.recoveredSigner === "string" && result.recoveredSigner.length > 0
1563
+ ? result.recoveredSigner
1564
+ : UNRECOVERABLE;
1565
+
1566
+ const decision = evaluateTrustAsOf({ subject, asOf, revocations });
1567
+ const out = { ...result, trustAsOf: decision };
1568
+
1569
+ if (decision.revoked) {
1570
+ // The ONLY downgrading path: an otherwise-ACCEPTED artifact whose signer was revoked-before-as-of becomes
1571
+ // REVOKED (exit 3). We flip accepted=false + set a distinct REVOKED verdict + a named reason so the
1572
+ // existing `accepted ? 0 : 3` exit mapping yields exit 3, byte-for-byte with the producer.
1573
+ out.accepted = false;
1574
+ out.verdict = "REVOKED";
1575
+ out.reason = "key_revoked_as_of";
1576
+ }
1577
+ return out;
1578
+ }
1579
+
1580
+ /**
1581
+ * Render the human-readable TRUST-DECISION-AS-OF lines verify-vh appends to its report. PURE. Returns an
1582
+ * array of lines. Mirrors cli/core/trust-asof.js renderTrustAsOf's content so the two stacks read the same.
1583
+ * @param {object} decision the object evaluateTrustAsOf returns
1584
+ * @param {{ defaulted?: boolean, indent?: string }} [ctx]
1585
+ * @returns {string[]} lines
1586
+ */
1587
+ function renderTrustAsOf(decision, ctx = {}) {
1588
+ const I = ctx.indent || "";
1589
+ const L = [];
1590
+ const asOfNote = ctx.defaulted ? " (defaulted to now; pass --as-of <ISO> to pin the decision instant)" : "";
1591
+ L.push(`${I}revocation check (as of ${decision.asOf})${asOfNote}:`);
1592
+ if (decision.status === "REVOKED") {
1593
+ const g = decision.governing;
1594
+ L.push(
1595
+ `${I} [REVOKED] the signing key (${g.vendorAddress}) was REVOKED as of ${g.revokedAt} ` +
1596
+ `(reason: ${g.reason})${g.supersededBy ? `, superseded by ${g.supersededBy}` : ""} — at or before ` +
1597
+ `the as-of instant. This artifact is NOT trustworthy as of ${decision.asOf}.`
1598
+ );
1599
+ } else if (decision.status === "UNEVALUABLE") {
1600
+ L.push(`${I} [skip] the signature did not recover to a key — no subject to evaluate revocations against.`);
1601
+ } else {
1602
+ L.push(`${I} [OK] no applicable revocation: the signing key was not revoked as of ${decision.asOf}.`);
1603
+ if (decision.laterRevoked) {
1604
+ const lr = decision.laterRevoked;
1605
+ L.push(
1606
+ `${I} [note] this key (${lr.vendorAddress}) IS revoked as of ${lr.revokedAt} ` +
1607
+ `(reason: ${lr.reason})${lr.supersededBy ? `, superseded by ${lr.supersededBy}` : ""} — AFTER your ` +
1608
+ `as-of instant, so it does NOT downgrade THIS decision (informational).`
1609
+ );
1610
+ }
1611
+ }
1612
+ for (const w of decision.ignored) {
1613
+ L.push(`${I} [warning] ${w}`);
1614
+ }
1615
+ return L;
1616
+ }
1617
+
1618
+ module.exports = {
1619
+ RevocationReadError,
1620
+ SIGNED_REVOCATION_KIND,
1621
+ REVOCATION_KIND,
1622
+ REVOCATION_SCHEMA_VERSION,
1623
+ SUPPORTED_REVOCATION_SCHEMA_VERSIONS,
1624
+ SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS,
1625
+ REVOCATION_REASON_SET,
1626
+ REVOCATION_FIELDS,
1627
+ REVOCATION_TRUST_NOTE,
1628
+ SIGNED_REVOCATION_TRUST_NOTE,
1629
+ ISO_INSTANT_RE,
1630
+ UNRECOVERABLE,
1631
+ isPlainObject,
1632
+ parseCanonicalInstant,
1633
+ resolveAsOf,
1634
+ serializeRevocation,
1635
+ validateSignedRevocation,
1636
+ verifyRevocation,
1637
+ normalizeRevocationsInput,
1638
+ classifyRevocation,
1639
+ evaluateTrustAsOf,
1640
+ applyToVerifyResult,
1641
+ renderTrustAsOf,
1642
+ };
1643
+
1644
+ };
1645
+
1646
+ // ===== module: verify-vh-engine (from verifier/verify-vh.js) =====
1647
+ __modules["verify-vh-engine"] = function (module, exports, __require) {
1648
+ "use strict";
1649
+ // BUILD-GENERATED PREAMBLE (verifier/build-standalone-html.js): the four module-scope bindings the
1650
+ // T-66.1 engine slice references, resolved through the bundle's own __require graph. `revocation`
1651
+ // binds the PURE decision core directly (verifier/lib/revocation-core.js) — the engine slice only
1652
+ // ever touches the pure surface (proven by test/verifier.browser-core.test.js), never the fs reader.
1653
+ var merkle = __require("merkle");
1654
+ var canonical = __require("canonical");
1655
+ var recoverPersonalSignAddress = __require("secp256k1-recover").recoverPersonalSignAddress;
1656
+ var revocation = __require("revocation-core");
1657
+ // ---- the verbatim T-66.1 engine slice of verifier/verify-vh.js follows. ----
1658
+ // EVERYTHING between this marker and the matching END marker is the PURE verify engine: it performs NO
1659
+ // I/O of its own and never touches fs / os / path / process / child_process — every byte it verifies
1660
+ // arrives through the injected `readEntry` seam (or as an argument). Its only outside references are the
1661
+ // four module bindings above, all of which resolve to PURE modules for the functions used here:
1662
+ // `merkle`, `canonical`, `recoverPersonalSignAddress`, and the PURE decision half of `revocation`
1663
+ // (./lib/revocation-core.js re-exports — never the fs-backed readRevocationsFromPath/loadAndApply).
1664
+ // test/verifier.browser-core.test.js enforces all of this mechanically; the markers also make the block
1665
+ // mechanically extractable (vm / browser bundling, EPIC-66).
1666
+
1667
+ // CI-gateable exit contract, mirroring the producer family (vh verify-seal / vh evidence verify):
1668
+ // 0 ok / 3 rejected / 2 usage / 1 IO. Stable; a future CI/indexer keys on these.
1669
+ const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, REJECTED: 3 });
1670
+
1671
+ // A usage error the CLI maps to exit 2 (vs an IO error -> 1, vs a clean REJECTED verdict -> 3).
1672
+ class UsageError extends Error {}
1673
+ class IOError extends Error {}
1674
+
1675
+ // The on-disk `kind` discriminators of every artifact family this verifier understands. Bare and signed
1676
+ // variants are listed so auto-detect routes correctly. Disjoint, versioned strings — a foreign/random
1677
+ // JSON file falls through to a clear "unrecognized artifact" usage error rather than a misread.
1678
+ const KINDS = Object.freeze({
1679
+ EVIDENCE_SEAL: "vh.evidence-seal",
1680
+ EVIDENCE_SEAL_SIGNED: "vh.evidence-seal-signed",
1681
+ TRUST_SEAL: "trustledger.reconcile-seal",
1682
+ TRUST_SEAL_SIGNED: "trustledger.reconcile-seal-signed",
1683
+ DATASET_ATTESTATION: "verifyhash.dataset-attestation",
1684
+ DATASET_ATTESTATION_SIGNED: "verifyhash.dataset-attestation-signed",
1685
+ DATASET_ATTESTATION_TIMESTAMPED: "verifyhash.dataset-attestation-timestamped",
1686
+ PROOF: "verifyhash.merkle-proof",
1687
+ AGENT_PACKET: "vh.agent-session-packet",
1688
+ });
1689
+
1690
+ const TRUST_NOTE =
1691
+ "verify-vh is an INDEPENDENT, read-only, OFFLINE verifier. It RE-DERIVES the keccak root from the " +
1692
+ "bytes you hold and recovers the signer with no producer stack. It proves TAMPER-EVIDENCE + WHO " +
1693
+ "vouched — NOT a trusted timestamp and NOT a legal opinion.";
1694
+
1695
+ // ---------------------------------------------------------------------------
1696
+ // Address normalization + recovery helpers. The verifier compares addresses as LOWERCASE 0x-hex (the
1697
+ // canonical byte-deterministic form the producer records); a caller may paste an EIP-55-checksummed
1698
+ // --vendor and we lowercase it (a checksum mismatch is not our concern — we compare 20 raw bytes).
1699
+ // ---------------------------------------------------------------------------
1700
+
1701
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
1702
+
1703
+ function normalizeAddress(addr, label) {
1704
+ if (typeof addr !== "string" || !ADDRESS_RE.test(addr)) {
1705
+ throw new UsageError(`${label} must be a 0x-prefixed 20-byte hex address, got: ${String(addr)}`);
1706
+ }
1707
+ return addr.toLowerCase();
1708
+ }
1709
+
1710
+ // Recover the EIP-191 signer over the embedded canonical bytes. A tampered/corrupt signature can be
1711
+ // UNRECOVERABLE (no valid curve point) — that throws, which the caller turns into a `bad_signature`
1712
+ // REJECTED verdict, never a crash. Returns lowercase 0x-hex, or null if recovery failed.
1713
+ function tryRecover(message, signature) {
1714
+ try {
1715
+ return recoverPersonalSignAddress(message, signature);
1716
+ } catch (_) {
1717
+ return null;
1718
+ }
1719
+ }
1720
+
1721
+ // ---------------------------------------------------------------------------
1722
+ // Signed-container decoding. A signed artifact carries the embedded UNSIGNED payload as the EXACT
1723
+ // canonical bytes (a STRING) in `attestation`, plus a { scheme, signer, signature } block. The signed
1724
+ // MESSAGE is that embedded string verbatim, so signer recovery runs over `container.attestation`.
1725
+ // ---------------------------------------------------------------------------
1726
+
1727
+ function decodeSigned(container) {
1728
+ const sig = container && container.signature;
1729
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
1730
+ throw new IOError("signed artifact is missing a { scheme, signer, signature } signature block");
1731
+ }
1732
+ if (sig.scheme !== "eip191-personal-sign") {
1733
+ throw new IOError(
1734
+ `unsupported signature scheme: ${JSON.stringify(sig.scheme)} ` +
1735
+ "(this verifier understands eip191-personal-sign)"
1736
+ );
1737
+ }
1738
+ if (typeof container.attestation !== "string") {
1739
+ throw new IOError("signed artifact must embed the canonical UNSIGNED bytes as a string `attestation`");
1740
+ }
1741
+ if (typeof sig.signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(sig.signature)) {
1742
+ throw new IOError("signed artifact signature must be a 65-byte (r||s||v) 0x-hex string");
1743
+ }
1744
+ if (typeof sig.signer !== "string" || !ADDRESS_RE.test(sig.signer)) {
1745
+ throw new IOError("signed artifact signer must be a 0x-prefixed 20-byte hex address");
1746
+ }
1747
+ let embedded;
1748
+ try {
1749
+ embedded = JSON.parse(container.attestation);
1750
+ } catch (e) {
1751
+ throw new IOError(`embedded attestation is not valid JSON: ${e.message}`);
1752
+ }
1753
+ return { embedded, message: container.attestation, claimedSigner: sig.signer.toLowerCase(), signature: sig.signature };
1754
+ }
1755
+
1756
+ // ---------------------------------------------------------------------------
1757
+ // Per-file re-derivation, shared by every seal kind AND by both file sources. Given the sealed
1758
+ // { relPath, contentHash } entries and a `readEntry` source, fetch each referenced file's bytes through
1759
+ // the source, recompute its contentHash, and localize the outcome to MATCH / CHANGED / MISSING /
1760
+ // ESCAPED; a file present under a sealed relPath that is NOT in the seal cannot occur here (we only read
1761
+ // sealed relPaths) — UNEXPECTED is reported only for seals where the producer enumerates a directory
1762
+ // (evidence seal verify re-walks the dir). For artifact verification we follow the producer's read
1763
+ // model: read exactly the relPaths the artifact names from the source.
1764
+ //
1765
+ // SECURITY — CONFINEMENT LIVES IN THE SOURCE. `relPath` values come straight from the attacker-controlled
1766
+ // artifact JSON (the threat model is attacker-controls-the-input, victim-runs-on-their-own-machine: a
1767
+ // malicious producer hands a counterparty a "verify me" artifact, hoping its relPaths probe the
1768
+ // counterparty's filesystem). Each source therefore CONFINES every read BEFORE touching its backing
1769
+ // store and answers `{ status: "escaped" }` for a hostile relPath (absolute, a `..` traversal component,
1770
+ // or — for the disk source — a resolved/realpath escape of baseDir). An escaped entry is recorded ONLY by
1771
+ // relPath (the attacker's string) — we NEVER hash it and NEVER emit an actualContentHash for it, so the
1772
+ // verdict can never become a content-confirmation / hash-disclosure oracle over a file outside the
1773
+ // source. A `path_escape` entry is a hard REJECTED verdict.
1774
+ // ---------------------------------------------------------------------------
1775
+
1776
+ function classifyFilesWith(sealedEntries, readEntry) {
1777
+ const changed = [];
1778
+ const missing = [];
1779
+ const matched = [];
1780
+ const escaped = []; // { relPath } only — NEVER a hash; a confinement reject, read nothing
1781
+ const flat = []; // { relPath, contentHash } actually-present, for the root re-derivation
1782
+
1783
+ for (const e of sealedEntries) {
1784
+ const relPath = e.relPath;
1785
+ const r = readEntry(relPath);
1786
+ if (r.status === "escaped") {
1787
+ escaped.push({ relPath: String(relPath) });
1788
+ continue;
1789
+ }
1790
+ if (r.status === "missing") {
1791
+ missing.push({ relPath });
1792
+ continue;
1793
+ }
1794
+ const actual = merkle.hashBytes(r.bytes);
1795
+ flat.push({ relPath, contentHash: actual });
1796
+ if (actual.toLowerCase() === String(e.contentHash).toLowerCase()) {
1797
+ matched.push({ relPath, contentHash: actual });
1798
+ } else {
1799
+ changed.push({ relPath, expectedContentHash: e.contentHash, actualContentHash: actual });
1800
+ }
1801
+ }
1802
+ return { matched, changed, missing, escaped, flat };
1803
+ }
1804
+
1805
+ // ---------------------------------------------------------------------------
1806
+ // Verify an EVIDENCE seal (bare or the embedded seal of a signed container). The seal lists `files`
1807
+ // [{ relPath, contentHash, leaf }] + `root`. We re-derive the root from the bytes the source holds and
1808
+ // localize any tamper. NO header (evidence seals bind only the file set). UNEXPECTED files (present
1809
+ // under a sealed-sibling tree but not named) are NOT scanned here — the artifact names exactly what it
1810
+ // commits to; the producer's `vh evidence verify` re-walks the dir, but the standalone verifier verifies
1811
+ // what the artifact REFERENCES (read-only, no directory walk). NOTE an "extra" file is still caught
1812
+ // structurally: the sealed root commits to the FULL file set, so a seal doctored to omit an entry can
1813
+ // never keep its root (root_mismatch), and a signed seal edited that way breaks its signature.
1814
+ // ---------------------------------------------------------------------------
1815
+
1816
+ function verifyEvidenceSealWith(seal, readEntry) {
1817
+ if (!Array.isArray(seal.files) || seal.files.length === 0) {
1818
+ throw new IOError("evidence seal `files` must be a non-empty array");
1819
+ }
1820
+ if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
1821
+ throw new IOError("evidence seal `root` must be a 0x-prefixed 32-byte hex string");
1822
+ }
1823
+ const { matched, changed, missing, escaped, flat } = classifyFilesWith(seal.files, readEntry);
1824
+
1825
+ // The AUTHORITATIVE root is re-derived from the bytes actually held — never the seal's stored root.
1826
+ // A partial/changed set yields a different root; rootMatches goes false.
1827
+ let recomputedRoot = null;
1828
+ if (flat.length > 0) {
1829
+ try {
1830
+ recomputedRoot = merkle.rootFromFlat(flat);
1831
+ } catch (_) {
1832
+ recomputedRoot = null;
1833
+ }
1834
+ }
1835
+ const rootMatches =
1836
+ missing.length === 0 &&
1837
+ changed.length === 0 &&
1838
+ escaped.length === 0 &&
1839
+ recomputedRoot != null &&
1840
+ recomputedRoot.toLowerCase() === seal.root.toLowerCase();
1841
+
1842
+ return {
1843
+ matched,
1844
+ changed,
1845
+ missing,
1846
+ escaped,
1847
+ unexpected: [],
1848
+ sealedRoot: seal.root,
1849
+ recomputedRoot,
1850
+ rootMatches,
1851
+ filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
1852
+ };
1853
+ }
1854
+
1855
+ // ---------------------------------------------------------------------------
1856
+ // Verify a TRUST (reconciliation) seal (bare or embedded). The seal lists `inputs` (role+relPath+
1857
+ // contentHash+leaf) and `outputs` (relPath+contentHash+leaf), plus a `verdict` + `root`. The root commits
1858
+ // to all inputs + outputs PLUS a synthetic verdict/role HEADER leaf. We re-derive the root from the held
1859
+ // bytes AND the header content recomputed from the seal's OWN verdict + input role bindings — so a
1860
+ // verdict/role edit (which lives in the seal, not a file) still changes the recomputed root. Inputs are
1861
+ // sealed by basename and resolve through the source (the portable handoff ships sources next to the seal).
1862
+ // ---------------------------------------------------------------------------
1863
+
1864
+ function verifyTrustSealWith(seal, readEntry) {
1865
+ if (!Array.isArray(seal.inputs) || seal.inputs.length === 0) {
1866
+ throw new IOError("trust seal `inputs` must be a non-empty array");
1867
+ }
1868
+ if (!Array.isArray(seal.outputs) || seal.outputs.length === 0) {
1869
+ throw new IOError("trust seal `outputs` must be a non-empty array");
1870
+ }
1871
+ if (typeof seal.root !== "string" || !merkle.HEX32_RE.test(seal.root)) {
1872
+ throw new IOError("trust seal `root` must be a 0x-prefixed 32-byte hex string");
1873
+ }
1874
+ if (seal.verdict == null || typeof seal.verdict !== "object") {
1875
+ throw new IOError("trust seal is missing its `verdict` block");
1876
+ }
1877
+
1878
+ const sealedEntries = [
1879
+ ...seal.inputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: e.role })),
1880
+ ...seal.outputs.map((e) => ({ relPath: e.relPath, contentHash: e.contentHash, role: null })),
1881
+ ];
1882
+ const { matched, changed, missing, escaped, flat } = classifyFilesWith(sealedEntries, readEntry);
1883
+
1884
+ // Re-derive the root: the held file leaves PLUS the verdict/role HEADER leaf (content recomputed
1885
+ // from the seal's own verdict + input role bindings). The header is folded in as one more (relPath,
1886
+ // content) pair under the reserved header relPath — exactly the producer's binding.
1887
+ let recomputedRoot = null;
1888
+ // Only attempt the root re-derivation when no file is MISSING or ESCAPED (a partial set can never
1889
+ // re-derive the sealed root anyway, and the header binds the FULL committed structure).
1890
+ if (missing.length === 0 && escaped.length === 0 && flat.length === seal.inputs.length + seal.outputs.length) {
1891
+ try {
1892
+ const headerBytes = canonical.trustSealHeaderBytes(
1893
+ seal.verdict,
1894
+ seal.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
1895
+ );
1896
+ const committed = [
1897
+ ...flat,
1898
+ { relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
1899
+ ];
1900
+ recomputedRoot = merkle.rootFromFlat(committed);
1901
+ } catch (_) {
1902
+ recomputedRoot = null;
1903
+ }
1904
+ }
1905
+ const rootMatches =
1906
+ escaped.length === 0 &&
1907
+ recomputedRoot != null &&
1908
+ recomputedRoot.toLowerCase() === seal.root.toLowerCase();
1909
+
1910
+ return {
1911
+ matched,
1912
+ changed,
1913
+ missing,
1914
+ escaped,
1915
+ unexpected: [],
1916
+ sealedRoot: seal.root,
1917
+ recomputedRoot,
1918
+ rootMatches,
1919
+ filesOk: changed.length === 0 && missing.length === 0 && escaped.length === 0 && rootMatches,
1920
+ };
1921
+ }
1922
+
1923
+ // ---------------------------------------------------------------------------
1924
+ // Verify a DATASET attestation (bare/signed/timestamped). A dataset attestation commits to the dataset
1925
+ // IDENTITY (root, fileCount, manifestDigest) — it does NOT carry the per-file list, so there are no
1926
+ // sibling bytes to re-derive a Merkle root from without the original manifest. The independent verifier
1927
+ // therefore confirms the embedded identity is well-formed + (for signed) recovers/pins the signer; the
1928
+ // `root` is the dataset's, carried as-is. (`vh dataset verify <dir> --manifest` is the path that
1929
+ // re-derives a root from a live tree; the attestation alone has no tree to re-walk.)
1930
+ // ---------------------------------------------------------------------------
1931
+
1932
+ function verifyDatasetAttestation(att) {
1933
+ for (const f of ["root", "manifestDigest"]) {
1934
+ if (typeof att[f] !== "string" || !merkle.HEX32_RE.test(att[f])) {
1935
+ throw new IOError(`dataset attestation ${f} must be a 0x-prefixed 32-byte hex string`);
1936
+ }
1937
+ }
1938
+ if (!Number.isInteger(att.fileCount) || att.fileCount < 1) {
1939
+ throw new IOError("dataset attestation fileCount must be a positive integer");
1940
+ }
1941
+ return {
1942
+ matched: [],
1943
+ changed: [],
1944
+ missing: [],
1945
+ escaped: [],
1946
+ unexpected: [],
1947
+ sealedRoot: att.root,
1948
+ recomputedRoot: null,
1949
+ rootMatches: null, // no sibling bytes to re-derive a root from (identity-only artifact)
1950
+ filesOk: true, // structural identity is sound; the binding is via the signature for signed variants
1951
+ identityOnly: true,
1952
+ };
1953
+ }
1954
+
1955
+ // ---------------------------------------------------------------------------
1956
+ // Verify a PROOF bundle. A proof artifact carries { root, leaf, contentHash, relPath, proof[] }. We
1957
+ // RE-DERIVE the leaf from relPath + contentHash, then fold leafHash(leaf) up through the proof siblings
1958
+ // with nodeHash and confirm it reproduces `root` — byte-identically to the on-chain verifyLeaf, but
1959
+ // fully OFFLINE. (The on-chain "is this root anchored" check is out of scope for the offline verifier.)
1960
+ // ---------------------------------------------------------------------------
1961
+
1962
+ function verifyProofBundle(art) {
1963
+ for (const f of ["root", "leaf", "contentHash"]) {
1964
+ if (typeof art[f] !== "string" || !merkle.HEX32_RE.test(art[f])) {
1965
+ throw new IOError(`proof artifact ${f} must be a 0x-prefixed 32-byte hex string`);
1966
+ }
1967
+ }
1968
+ if (typeof art.relPath !== "string" || art.relPath.length === 0) {
1969
+ throw new IOError("proof artifact relPath must be a non-empty string");
1970
+ }
1971
+ if (!Array.isArray(art.proof)) {
1972
+ throw new IOError("proof artifact `proof` must be an array of 0x 32-byte hex siblings");
1973
+ }
1974
+ const derivedLeaf = merkle.pathLeaf(art.relPath, art.contentHash);
1975
+ const leafMatches = derivedLeaf.toLowerCase() === art.leaf.toLowerCase();
1976
+ let computed = merkle.leafHash(art.leaf);
1977
+ for (const sib of art.proof) {
1978
+ computed = merkle.nodeHash(computed, sib);
1979
+ }
1980
+ const foldsToRoot = computed.toLowerCase() === art.root.toLowerCase();
1981
+ return {
1982
+ matched: leafMatches && foldsToRoot ? [{ relPath: art.relPath, contentHash: art.contentHash }] : [],
1983
+ changed:
1984
+ leafMatches && foldsToRoot ? [] : [{ relPath: art.relPath, expectedContentHash: art.root, actualContentHash: computed }],
1985
+ missing: [],
1986
+ escaped: [],
1987
+ unexpected: [],
1988
+ sealedRoot: art.root,
1989
+ recomputedRoot: computed,
1990
+ rootMatches: leafMatches && foldsToRoot,
1991
+ filesOk: leafMatches && foldsToRoot,
1992
+ proof: { derivedLeaf, leafMatches, foldsToRoot },
1993
+ };
1994
+ }
1995
+
1996
+ // ---------------------------------------------------------------------------
1997
+ // Verify an AGENT-SESSION packet (T-68.3 — the AgentTrace funnel leg, FREE surface only).
1998
+ //
1999
+ // A `*.vhagent.json` packet is SELF-CONTAINED: it carries its ordered event list (full and/or
2000
+ // REDACTED), a per-event leaf expectation list, and an RFC-6962-style ordered Merkle head
2001
+ // { size, root } — there are NO sibling files to read, so `readEntry` is never consulted. This block
2002
+ // RE-DERIVES every event leaf and the root from the events the packet holds, exactly as the producer's
2003
+ // `vh agent verify` does, but from an INDEPENDENT implementation surface: everything below is written
2004
+ // against the verifier's OWN dependency-free keccak (merkle.hashBytes) — it imports NOTHING from cli/.
2005
+ //
2006
+ // THE CONVENTION (must match cli/core/agent-session.js + cli/journal-log.js VERBATIM):
2007
+ // * payloadHash = keccak256(utf8(payload)) (the payload COMMITMENT)
2008
+ // * event leaf = keccak256(utf8(JSON.stringify([
2009
+ // LEAF_DOMAIN, seq, ts, actor, type, payloadHash, canonicalMetaJson|null ])))
2010
+ // — the payload participates ONLY via its commitment, so a FULL event and its REDACTED twin
2011
+ // (payload dropped, commitment carried, `redacted: true`) derive the IDENTICAL leaf: redaction
2012
+ // changes neither the leaves nor the root (it can WITHHOLD, never silently ALTER).
2013
+ // * the ordered tree (RFC 6962, position-bound, NO sorting — the OPPOSITE of the evidence tree):
2014
+ // leaf node = keccak256(0x00 || leaf) interior = keccak256(0x01 || left || right)
2015
+ // MTH(D[0:n]) = interior(MTH(D[0:k]), MTH(D[k:n])), k = largest power of two < n
2016
+ // empty log root = keccak256(utf8("vh.journal-log/v1:empty-root"))
2017
+ // * a SIGNED packet carries `headAttestation`: a detached EIP-191 personal-sign over the EXACT
2018
+ // canonical head-payload bytes (the embedded `attestation` string). The signature wraps the HEAD,
2019
+ // so ONE signature stays valid for every redacted copy of the same sealed session.
2020
+ //
2021
+ // VERDICTS: event-level tamper (a payload that no longer matches its carried commitment — including a
2022
+ // REDACTED event whose commitment was forged — or a leaf that no longer matches its expectation) is a
2023
+ // REJECT NAMING THE SEQ; a tampered head is `root_mismatch`; a forged signature is `bad_signature`; a
2024
+ // sound signature by the wrong signer under a --vendor pin is `wrong_issuer`; a --vendor pin on an
2025
+ // UNSIGNED packet is `unsigned_cannot_pin_vendor` (a stripped signature never passes a pinned verify).
2026
+ // The recompute is AUTHORITATIVE: the packet is an untrusted container and its stored hashes are only
2027
+ // EXPECTATIONS checked against.
2028
+ // ---------------------------------------------------------------------------
2029
+
2030
+ // The producer's in-band trust note, REQUIRED verbatim (the packetseal discipline: the caveat may not
2031
+ // drift; a packet whose note was edited is structurally invalid, exactly as `vh agent verify` treats it).
2032
+ const AGENT_TRUST_NOTE =
2033
+ "This agent-session packet is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and " +
2034
+ "NOT a claim the agent behaved well. Its ordered Merkle `head` {size, root} (RFC-6962-style, " +
2035
+ "position-bound) commits to every event: verify RE-DERIVES each event leaf — recomputing the " +
2036
+ "payload hash commitment for a FULL event, checking the carried commitment for a REDACTED one — " +
2037
+ "and the root from the events you hold, and a REJECT names the first offending event seq. " +
2038
+ "Redaction WITHHOLDS a payload behind its hash commitment without changing any leaf or the root: " +
2039
+ "it can hide, never silently alter. Event `ts` fields are SELF-ASSERTED metadata (recorded, never " +
2040
+ 'verified against any clock); "sealed at time T" rides the human-owned signing/timestamp ' +
2041
+ "trust-root (STRATEGY.md P-3). Garbage-in is out of scope: the head proves the LOG is intact and " +
2042
+ "append-only, not that the log faithfully records what the agent actually did. The packet is an " +
2043
+ "UNTRUSTED transport container: verify never trusts the packet's own stored hashes.";
2044
+
2045
+ const AGENT_SIGNED_HEAD_TRUST_NOTE =
2046
+ "This is a SIGNED agent-session HEAD attestation: it WRAPS (never edits) the EXACT canonical head " +
2047
+ "bytes in `attestation` and attaches a detached EIP-191 signature. It asserts the holder of the " +
2048
+ "`signer` key vouched for THIS session head {size, root} at signing time. Because event leaves " +
2049
+ "are redaction-safe, the SAME signature stays valid for every redacted copy of the sealed session " +
2050
+ "(redaction changes neither leaves nor root). It does NOT prove a timestamp (no \"sealed since " +
2051
+ "T\" — still the human trust-root P-3) and is NOT a legal opinion. Every caveat of the packet " +
2052
+ "applies. " +
2053
+ AGENT_TRUST_NOTE;
2054
+
2055
+ const AGENT_HEAD_KIND = "vh.agent-head";
2056
+ const AGENT_SIGNED_HEAD_KIND = "vh.agent-head-signed";
2057
+ const AGENT_PACKET_SCHEMA_VERSIONS = Object.freeze([1]);
2058
+ const AGENT_EVENT_TYPES = Object.freeze(["prompt", "completion", "tool_call", "tool_result", "note"]);
2059
+ const AGENT_EVENT_FIELDS = Object.freeze([
2060
+ "seq",
2061
+ "ts",
2062
+ "actor",
2063
+ "type",
2064
+ "payload",
2065
+ "payloadHash",
2066
+ "redacted",
2067
+ "meta",
2068
+ ]);
2069
+ const AGENT_LEAF_DOMAIN = "vh.agent-session/v1:event-leaf";
2070
+ const AGENT_EMPTY_ROOT_DOMAIN = "vh.journal-log/v1:empty-root";
2071
+ const AGENT_META_MAX_DEPTH = 32;
2072
+ const AGENT_META_MAX_NODES = 100000;
2073
+
2074
+ // Canonical-case wire shapes (the producer emits lowercase-only hex; mixed case is a foreign artifact).
2075
+ const AGENT_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
2076
+ const AGENT_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
2077
+ const AGENT_SIG_LC_RE = /^0x[0-9a-f]{130}$/;
2078
+
2079
+ // STRICT UTF-8 encoder that MIRRORS the producer's ethers `toUtf8Bytes` byte-for-byte (verified over
2080
+ // the whole 0x0000..0xFFFF code-unit space + surrogate edge cases). ethers' default error mode THROWS
2081
+ // only on a lone HIGH surrogate (an unfinished pair, no code point) — so this returns null there — but
2082
+ // it ENCODES a lone LOW surrogate as its literal 3-byte sequence (U+DC00 -> ed b0 80), NOT an error;
2083
+ // so a lone low surrogate falls straight through to the c<0x10000 branch below (matching the producer,
2084
+ // whose commitment over such a payload is well-defined). Pure JS; no TextEncoder (which would silently
2085
+ // substitute U+FFFD and DIVERGE from the producer). null => the event's commitment is undefined here
2086
+ // exactly as it is for the producer, so both sides reject in lockstep (fail-closed, never a mismatch).
2087
+ function agentUtf8Bytes(str) {
2088
+ const out = [];
2089
+ for (let i = 0; i < str.length; i++) {
2090
+ let c = str.charCodeAt(i);
2091
+ if (c >= 0xd800 && c <= 0xdbff) {
2092
+ const lo = i + 1 < str.length ? str.charCodeAt(i + 1) : -1;
2093
+ if (lo < 0xdc00 || lo > 0xdfff) return null; // lone HIGH surrogate (ethers THROWS; no code point)
2094
+ c = (c - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
2095
+ i++;
2096
+ }
2097
+ // A lone LOW surrogate (0xdc00..0xdfff) is NOT special-cased: ethers encodes it as its 3-byte form
2098
+ // via the c<0x10000 branch, so we do too — deleting the old lone-low `return null` that FALSELY
2099
+ // rejected genuine packets carrying truncated-UTF-16 / arbitrary-tool-result bytes.
2100
+ if (c < 0x80) out.push(c);
2101
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
2102
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
2103
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
2104
+ }
2105
+ return new Uint8Array(out);
2106
+ }
2107
+
2108
+ // 0x-hex -> bytes, and a tiny concat — the only byte plumbing the ordered tree needs.
2109
+ function agentHexToBytes(hex) {
2110
+ const s = hex.slice(2);
2111
+ const out = new Uint8Array(s.length / 2);
2112
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
2113
+ return out;
2114
+ }
2115
+ function agentConcatBytes(list) {
2116
+ let total = 0;
2117
+ for (const b of list) total += b.length;
2118
+ const out = new Uint8Array(total);
2119
+ let off = 0;
2120
+ for (const b of list) {
2121
+ out.set(b, off);
2122
+ off += b.length;
2123
+ }
2124
+ return out;
2125
+ }
2126
+
2127
+ // RFC-6962 domain-separated hashing over the verifier's OWN keccak (merkle.hashBytes — the same
2128
+ // independent primitive every other artifact family here is re-derived with). Children fold in TREE
2129
+ // ORDER (never sorted): position IS meaning in an ordered session log.
2130
+ function agentLeafNodeHash(leafHex) {
2131
+ return merkle.hashBytes(agentConcatBytes([Uint8Array.of(0x00), agentHexToBytes(leafHex)]));
2132
+ }
2133
+ function agentInteriorHash(leftHex, rightHex) {
2134
+ return merkle.hashBytes(
2135
+ agentConcatBytes([Uint8Array.of(0x01), agentHexToBytes(leftHex), agentHexToBytes(rightHex)])
2136
+ );
2137
+ }
2138
+
2139
+ // MTH (RFC 6962 §2.1) over the ORDERED leaf values; the empty log has a domain-separated constant root.
2140
+ function agentTreeRoot(leaves) {
2141
+ if (leaves.length === 0) return merkle.hashBytes(agentUtf8Bytes(AGENT_EMPTY_ROOT_DOMAIN));
2142
+ function mth(lo, hi) {
2143
+ const n = hi - lo;
2144
+ if (n === 1) return agentLeafNodeHash(leaves[lo]);
2145
+ let k = 1;
2146
+ while (k * 2 < n) k *= 2;
2147
+ return agentInteriorHash(mth(lo, lo + k), mth(lo + k, hi));
2148
+ }
2149
+ return mth(0, leaves.length);
2150
+ }
2151
+
2152
+ // A "plain" JSON-shaped object (prototype Object.prototype or null) — the same strictness the producer
2153
+ // applies, so what is hashed is exactly what could be written to disk and read back.
2154
+ function agentIsPlainObject(v) {
2155
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
2156
+ const proto = Object.getPrototypeOf(v);
2157
+ return proto === Object.prototype || proto === null;
2158
+ }
2159
+
2160
+ // Canonical JSON for `meta`: keys SORTED, only JSON-representable values, depth capped, and a TOTAL
2161
+ // work budget so a shared-reference DAG can never hang the verifier. Returns the canonical text or
2162
+ // null (reject) — byte-identical to the producer's canonicalization for every accepted value.
2163
+ function agentCanonicalJson(value, depth, budget) {
2164
+ if (depth > AGENT_META_MAX_DEPTH) return null;
2165
+ if (++budget.n > AGENT_META_MAX_NODES) return null;
2166
+ if (value === null) return "null";
2167
+ const t = typeof value;
2168
+ if (t === "boolean") return value ? "true" : "false";
2169
+ if (t === "number") return Number.isFinite(value) ? JSON.stringify(value) : null;
2170
+ if (t === "string") return JSON.stringify(value);
2171
+ if (Array.isArray(value)) {
2172
+ const parts = [];
2173
+ for (const item of value) {
2174
+ const p = agentCanonicalJson(item, depth + 1, budget);
2175
+ if (p === null) return null;
2176
+ parts.push(p);
2177
+ }
2178
+ return "[" + parts.join(",") + "]";
2179
+ }
2180
+ if (agentIsPlainObject(value)) {
2181
+ const keys = Object.keys(value).sort();
2182
+ const parts = [];
2183
+ for (const k of keys) {
2184
+ const p = agentCanonicalJson(value[k], depth + 1, budget);
2185
+ if (p === null) return null;
2186
+ parts.push(JSON.stringify(k) + ":" + p);
2187
+ }
2188
+ return "{" + parts.join(",") + "}";
2189
+ }
2190
+ return null;
2191
+ }
2192
+
2193
+ // The payload COMMITMENT: keccak256 over the payload's UTF-8 bytes. null on a non-string or a string
2194
+ // with no UTF-8 encoding (a lone HIGH surrogate — where ethers throws) — TOTAL, mirrors the producer
2195
+ // exactly (a lone LOW surrogate IS encodable, so it commits rather than rejecting).
2196
+ function agentPayloadHash(payload) {
2197
+ if (typeof payload !== "string") return null;
2198
+ const bytes = agentUtf8Bytes(payload);
2199
+ return bytes === null ? null : merkle.hashBytes(bytes);
2200
+ }
2201
+
2202
+ // STRICT validation of one canonical event — an INDEPENDENT re-implementation of the producer's rules
2203
+ // (closed field set; exactly the FULL or REDACTED shape; a carried commitment on a full event must
2204
+ // equal the recomputed one). Never throws; every failure is a named { ok:false, reason, field? } (the
2205
+ // commitment-mismatch reject also carries carried/recomputed so the caller can localize the change).
2206
+ function agentValidateEvent(event) {
2207
+ try {
2208
+ if (!agentIsPlainObject(event)) return { ok: false, reason: "EVENT_NOT_OBJECT" };
2209
+ for (const k of Object.keys(event)) {
2210
+ if (!AGENT_EVENT_FIELDS.includes(k)) return { ok: false, reason: "EVENT_UNKNOWN_FIELD", field: k };
2211
+ }
2212
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
2213
+ return { ok: false, reason: "EVENT_BAD_SEQ", field: "seq" };
2214
+ }
2215
+ if (typeof event.ts !== "string") return { ok: false, reason: "EVENT_BAD_TS", field: "ts" };
2216
+ if (typeof event.actor !== "string" || event.actor.length === 0) {
2217
+ return { ok: false, reason: "EVENT_BAD_ACTOR", field: "actor" };
2218
+ }
2219
+ if (!AGENT_EVENT_TYPES.includes(event.type)) return { ok: false, reason: "EVENT_BAD_TYPE", field: "type" };
2220
+ const hasPayload = "payload" in event;
2221
+ const hasHash = "payloadHash" in event;
2222
+ if (hasPayload && typeof event.payload !== "string") {
2223
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
2224
+ }
2225
+ if (hasHash && !(typeof event.payloadHash === "string" && merkle.HEX32_RE.test(event.payloadHash))) {
2226
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
2227
+ }
2228
+ if ("redacted" in event && typeof event.redacted !== "boolean") {
2229
+ return { ok: false, reason: "EVENT_BAD_REDACTED_FLAG", field: "redacted" };
2230
+ }
2231
+ if (!hasPayload && !hasHash) return { ok: false, reason: "EVENT_MISSING_PAYLOAD", field: "payload" };
2232
+ if (event.redacted === true && hasPayload) {
2233
+ return { ok: false, reason: "EVENT_REDACTED_WITH_PAYLOAD", field: "redacted" };
2234
+ }
2235
+ if (event.redacted === true && !hasHash) {
2236
+ return { ok: false, reason: "EVENT_BAD_PAYLOAD_HASH", field: "payloadHash" };
2237
+ }
2238
+ if (!hasPayload && event.redacted !== true) {
2239
+ return { ok: false, reason: "EVENT_UNFLAGGED_REDACTION", field: "redacted" };
2240
+ }
2241
+ let commitment;
2242
+ if (hasPayload) {
2243
+ commitment = agentPayloadHash(event.payload);
2244
+ if (commitment === null) return { ok: false, reason: "EVENT_BAD_PAYLOAD", field: "payload" };
2245
+ if (hasHash && commitment !== event.payloadHash.toLowerCase()) {
2246
+ return {
2247
+ ok: false,
2248
+ reason: "EVENT_PAYLOAD_HASH_MISMATCH",
2249
+ field: "payloadHash",
2250
+ carried: event.payloadHash.toLowerCase(),
2251
+ recomputed: commitment,
2252
+ };
2253
+ }
2254
+ } else {
2255
+ commitment = event.payloadHash.toLowerCase();
2256
+ }
2257
+ let metaJson = null;
2258
+ if ("meta" in event) {
2259
+ metaJson = agentCanonicalJson(event.meta, 0, { n: 0 });
2260
+ if (metaJson === null) return { ok: false, reason: "EVENT_BAD_META", field: "meta" };
2261
+ }
2262
+ return { ok: true, redacted: !hasPayload, payloadHash: commitment, metaJson };
2263
+ } catch (_) {
2264
+ return { ok: false, reason: "HOSTILE_INPUT" };
2265
+ }
2266
+ }
2267
+
2268
+ // The redaction-safe LEAF VALUE of one validated event: the fixed-position JSON array preimage with
2269
+ // the payload represented ONLY by its commitment (so a full event and its redacted twin derive the
2270
+ // identical leaf). Returns null only for an encoding fault (kept total).
2271
+ function agentEventLeaf(event, validated) {
2272
+ const encoded = JSON.stringify([
2273
+ AGENT_LEAF_DOMAIN,
2274
+ event.seq,
2275
+ event.ts,
2276
+ event.actor,
2277
+ event.type,
2278
+ validated.payloadHash,
2279
+ validated.metaJson,
2280
+ ]);
2281
+ const bytes = agentUtf8Bytes(encoded);
2282
+ return bytes === null ? null : merkle.hashBytes(bytes);
2283
+ }
2284
+
2285
+ // The shared { size, root } head shape. Throws IOError (a malformed/foreign artifact, exit 1 — the same
2286
+ // class `vh agent verify` gives a structurally invalid packet).
2287
+ function validateAgentHeadShape(head, label) {
2288
+ if (head == null || typeof head !== "object" || Array.isArray(head)) {
2289
+ throw new IOError(`${label} \`head\` must be a { size, root } object`);
2290
+ }
2291
+ for (const k of Object.keys(head)) {
2292
+ if (k !== "size" && k !== "root") {
2293
+ throw new IOError(`${label} head has unknown field: ${JSON.stringify(k)}`);
2294
+ }
2295
+ }
2296
+ if (!Number.isSafeInteger(head.size) || head.size < 0) {
2297
+ throw new IOError(`${label} head.size must be a non-negative integer, got: ${String(head.size)}`);
2298
+ }
2299
+ if (typeof head.root !== "string" || !AGENT_HEX32_LC_RE.test(head.root)) {
2300
+ throw new IOError(
2301
+ `${label} head.root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(head.root)}`
2302
+ );
2303
+ }
2304
+ }
2305
+
2306
+ // STRICT structural validation of the OPTIONAL signed-head container: the exact canonical embedded
2307
+ // bytes, a known scheme, lowercase signer/signature, and an embedded head payload in canonical form.
2308
+ // Returns { embeddedHead } for the binding check. Throws IOError on any structural defect.
2309
+ function validateAgentSignedHead(container) {
2310
+ const label = "agent-session packet headAttestation";
2311
+ if (container == null || typeof container !== "object" || Array.isArray(container)) {
2312
+ throw new IOError(`${label} must be a JSON object`);
2313
+ }
2314
+ const KNOWN = ["kind", "schemaVersion", "note", "attestation", "signature"];
2315
+ for (const k of Object.keys(container)) {
2316
+ if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
2317
+ }
2318
+ if (container.kind !== AGENT_SIGNED_HEAD_KIND) {
2319
+ throw new IOError(
2320
+ `${label} kind must be ${JSON.stringify(AGENT_SIGNED_HEAD_KIND)}, got: ${JSON.stringify(container.kind)}`
2321
+ );
2322
+ }
2323
+ if (container.schemaVersion !== 1) {
2324
+ throw new IOError(`${label} has unsupported schemaVersion: ${JSON.stringify(container.schemaVersion)}`);
2325
+ }
2326
+ if (container.note !== AGENT_SIGNED_HEAD_TRUST_NOTE) {
2327
+ throw new IOError(`${label} note must be the standing signed-head trust note (caveat must not drift)`);
2328
+ }
2329
+ if (typeof container.attestation !== "string") {
2330
+ throw new IOError(`${label} must embed the canonical UNSIGNED head bytes as a string \`attestation\``);
2331
+ }
2332
+ let embedded;
2333
+ try {
2334
+ embedded = JSON.parse(container.attestation);
2335
+ } catch (e) {
2336
+ throw new IOError(`${label} embedded attestation is not valid JSON: ${e.message}`);
2337
+ }
2338
+ if (
2339
+ embedded == null ||
2340
+ typeof embedded !== "object" ||
2341
+ Array.isArray(embedded) ||
2342
+ embedded.kind !== AGENT_HEAD_KIND ||
2343
+ embedded.schemaVersion !== 1 ||
2344
+ embedded.note !== AGENT_TRUST_NOTE
2345
+ ) {
2346
+ throw new IOError(`${label} embedded payload is not a canonical ${JSON.stringify(AGENT_HEAD_KIND)} payload`);
2347
+ }
2348
+ validateAgentHeadShape(embedded.head, `${label} embedded payload`);
2349
+ // The embedded string must be the EXACT canonical serialization (the byte-unambiguous signed message);
2350
+ // an insignificant-whitespace/reordered variant is a foreign artifact.
2351
+ const canonicalText =
2352
+ JSON.stringify({
2353
+ kind: embedded.kind,
2354
+ schemaVersion: embedded.schemaVersion,
2355
+ note: embedded.note,
2356
+ head: { size: embedded.head.size, root: embedded.head.root },
2357
+ }) + "\n";
2358
+ if (container.attestation !== canonicalText) {
2359
+ throw new IOError(`${label} embedded attestation is not in canonical form (the signed-over bytes are ambiguous)`);
2360
+ }
2361
+ const sig = container.signature;
2362
+ if (sig == null || typeof sig !== "object" || Array.isArray(sig)) {
2363
+ throw new IOError(`${label} signature must be a { scheme, signer, signature } object`);
2364
+ }
2365
+ if (sig.scheme !== "eip191-personal-sign") {
2366
+ throw new IOError(
2367
+ `${label} has unsupported signature scheme: ${JSON.stringify(sig.scheme)} (this verifier understands eip191-personal-sign)`
2368
+ );
2369
+ }
2370
+ if (typeof sig.signer !== "string" || !AGENT_ADDRESS_LC_RE.test(sig.signer)) {
2371
+ throw new IOError(`${label} signer must be a LOWERCASE 0x-prefixed 20-byte hex address`);
2372
+ }
2373
+ if (typeof sig.signature !== "string" || !AGENT_SIG_LC_RE.test(sig.signature)) {
2374
+ throw new IOError(`${label} signature must be a 65-byte (r||s||v) LOWERCASE 0x-hex string`);
2375
+ }
2376
+ return { embeddedHead: { size: embedded.head.size, root: embedded.head.root } };
2377
+ }
2378
+
2379
+ // STRICT structural validation of a parsed packet (SHAPE only — the per-event/leaf/root RECOMPUTE is
2380
+ // verifyAgentSeal's job, so event-level tamper stays a NAMED verdict naming the seq, never a throw).
2381
+ // Mirrors the producer's validatePacketShape defect-for-defect. Throws IOError.
2382
+ function validateAgentPacketStructure(obj) {
2383
+ const label = "agent-session packet";
2384
+ const KNOWN = ["kind", "schemaVersion", "note", "head", "counts", "events", "leaves", "headAttestation"];
2385
+ for (const k of Object.keys(obj)) {
2386
+ if (!KNOWN.includes(k)) throw new IOError(`${label} has unknown field: ${JSON.stringify(k)}`);
2387
+ }
2388
+ if (!AGENT_PACKET_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
2389
+ throw new IOError(
2390
+ `unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
2391
+ `(this verifier understands ${JSON.stringify(AGENT_PACKET_SCHEMA_VERSIONS)})`
2392
+ );
2393
+ }
2394
+ if (obj.note !== AGENT_TRUST_NOTE) {
2395
+ throw new IOError(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
2396
+ }
2397
+ validateAgentHeadShape(obj.head, label);
2398
+ if (obj.counts == null || typeof obj.counts !== "object" || Array.isArray(obj.counts)) {
2399
+ throw new IOError(`${label} \`counts\` must be a { events, full, redacted } object`);
2400
+ }
2401
+ for (const k of Object.keys(obj.counts)) {
2402
+ if (!["events", "full", "redacted"].includes(k)) {
2403
+ throw new IOError(`${label} counts has unknown field: ${JSON.stringify(k)}`);
2404
+ }
2405
+ }
2406
+ for (const k of ["events", "full", "redacted"]) {
2407
+ if (!Number.isSafeInteger(obj.counts[k]) || obj.counts[k] < 0) {
2408
+ throw new IOError(`${label} counts.${k} must be a non-negative integer, got: ${String(obj.counts[k])}`);
2409
+ }
2410
+ }
2411
+ if (!Array.isArray(obj.events)) throw new IOError(`${label} \`events\` must be an array`);
2412
+ if (!Array.isArray(obj.leaves) || obj.leaves.length !== obj.events.length) {
2413
+ throw new IOError(`${label} \`leaves\` must be an array with EXACTLY one leaf expectation per event`);
2414
+ }
2415
+ obj.leaves.forEach((l, i) => {
2416
+ if (typeof l !== "string" || !AGENT_HEX32_LC_RE.test(l)) {
2417
+ throw new IOError(`${label} leaves[${i}] must be a LOWERCASE 0x-bytes32 hex string, got: ${String(l)}`);
2418
+ }
2419
+ });
2420
+ if (obj.head.size !== obj.events.length) {
2421
+ throw new IOError(
2422
+ `${label} head.size (${obj.head.size}) does not match the events length (${obj.events.length})`
2423
+ );
2424
+ }
2425
+ if (obj.counts.events !== obj.events.length || obj.counts.full + obj.counts.redacted !== obj.counts.events) {
2426
+ throw new IOError(
2427
+ `${label} \`counts\` is internally inconsistent (events must equal the events length; full + redacted must equal events)`
2428
+ );
2429
+ }
2430
+ let signedHead = null;
2431
+ if (obj.headAttestation !== undefined) signedHead = validateAgentSignedHead(obj.headAttestation);
2432
+ return { packet: obj, signedHead };
2433
+ }
2434
+
2435
+ // The AUTHORITATIVE per-event/leaf/root/counts RECOMPUTE over a shape-validated packet. Returns the
2436
+ // engine's standard fileResult shape (matched/changed/... + roots) PLUS an `agent` sub-verdict block
2437
+ // and a `reasonKind` in the verifier's reason vocabulary. Event faults are localized to the FIRST
2438
+ // offending seq, exactly as the producer's verify names it. Never throws.
2439
+ function verifyAgentSeal(packet) {
2440
+ const matched = [];
2441
+ const changed = [];
2442
+ const withheld = [];
2443
+ const agent = {
2444
+ head: { size: packet.head.size, root: packet.head.root },
2445
+ recomputedHead: null,
2446
+ counts: null,
2447
+ withheld: null,
2448
+ seq: null,
2449
+ reason: null,
2450
+ };
2451
+ const base = {
2452
+ matched,
2453
+ changed,
2454
+ missing: [],
2455
+ escaped: [],
2456
+ unexpected: [],
2457
+ sealedRoot: packet.head.root,
2458
+ recomputedRoot: null,
2459
+ rootMatches: null,
2460
+ filesOk: false,
2461
+ reasonKind: null,
2462
+ agent,
2463
+ };
2464
+ const events = packet.events;
2465
+ const leaves = [];
2466
+ for (let i = 0; i < events.length; i++) {
2467
+ const v = agentValidateEvent(events[i]);
2468
+ if (!v.ok) {
2469
+ agent.seq = i;
2470
+ agent.reason = v.reason;
2471
+ if (v.field !== undefined) agent.field = v.field;
2472
+ if (v.reason === "EVENT_PAYLOAD_HASH_MISMATCH") {
2473
+ // The payload no longer matches its carried commitment: a CONTENT change localized to its seq
2474
+ // (this is also how a REDACTED event's FORGED commitment surfaces once its leaf is checked).
2475
+ changed.push({ relPath: `events[${i}]`, expectedContentHash: v.carried, actualContentHash: v.recomputed });
2476
+ base.reasonKind = "CHANGED";
2477
+ } else {
2478
+ base.reasonKind = "event_invalid";
2479
+ }
2480
+ return base;
2481
+ }
2482
+ if (events[i].seq !== i) {
2483
+ agent.seq = i;
2484
+ agent.reason = "SESSION_SEQ_NOT_CONTIGUOUS";
2485
+ base.reasonKind = "event_invalid";
2486
+ return base;
2487
+ }
2488
+ const leaf = agentEventLeaf(events[i], v);
2489
+ if (leaf === null || leaf !== packet.leaves[i]) {
2490
+ // A bound-field edit (ts/actor/type/meta) or a forged redacted commitment: the re-derived leaf no
2491
+ // longer matches the packet's own expectation — named by seq, recompute authoritative.
2492
+ agent.seq = i;
2493
+ agent.reason = "EVENT_LEAF_MISMATCH";
2494
+ changed.push({ relPath: `events[${i}]`, expectedContentHash: packet.leaves[i], actualContentHash: leaf });
2495
+ base.reasonKind = "CHANGED";
2496
+ return base;
2497
+ }
2498
+ leaves.push(leaf);
2499
+ matched.push({ relPath: `events[${i}]`, contentHash: leaf });
2500
+ if (v.redacted) withheld.push(i);
2501
+ }
2502
+ const recomputedRoot = agentTreeRoot(leaves);
2503
+ base.recomputedRoot = recomputedRoot;
2504
+ agent.recomputedHead = { size: leaves.length, root: recomputedRoot };
2505
+ base.rootMatches = leaves.length === packet.head.size && recomputedRoot === packet.head.root;
2506
+ if (!base.rootMatches) {
2507
+ agent.reason = "HEAD_MISMATCH";
2508
+ base.reasonKind = "root_mismatch";
2509
+ return base;
2510
+ }
2511
+ const full = events.length - withheld.length;
2512
+ agent.counts = { events: events.length, full, redacted: withheld.length };
2513
+ agent.withheld = withheld;
2514
+ if (packet.counts.full !== full || packet.counts.redacted !== withheld.length) {
2515
+ agent.reason = "COUNTS_MISMATCH";
2516
+ base.reasonKind = "counts_mismatch";
2517
+ return base;
2518
+ }
2519
+ base.filesOk = true;
2520
+ return base;
2521
+ }
2522
+
2523
+ // The artifact-level orchestrator for KINDS.AGENT_PACKET — both entrypoints (disk + bytes) route here
2524
+ // through verifyParsedArtifact, so the two paths' verdicts are one code path (deep-equal by
2525
+ // construction). Precedence mirrors the producer's `vh agent verify`: event/leaf/head/counts faults
2526
+ // (naming the seq) dominate; then head binding, signature genuineness, and the vendor pin.
2527
+ function verifyAgentPacketArtifact({ artifact, obj, pinned }) {
2528
+ const { signedHead } = validateAgentPacketStructure(obj); // throws IOError on a malformed/foreign packet
2529
+ const fileResult = verifyAgentSeal(obj);
2530
+ const agent = fileResult.agent;
2531
+
2532
+ const signed = obj.headAttestation !== undefined;
2533
+ let recoveredSigner = null;
2534
+ let claimedSigner = null;
2535
+ let signatureOk = null;
2536
+ let signerMatchesVendor = null;
2537
+ let headBound = null;
2538
+ if (signed) {
2539
+ claimedSigner = obj.headAttestation.signature.signer; // lowercase, structurally enforced
2540
+ recoveredSigner = tryRecover(obj.headAttestation.attestation, obj.headAttestation.signature.signature);
2541
+ signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
2542
+ if (agent.recomputedHead != null) {
2543
+ // The signature must vouch for THIS session's RECOMPUTED head — a signature pasted from a
2544
+ // different session recovers fine but binds a different { size, root }.
2545
+ headBound =
2546
+ signedHead.embeddedHead.size === agent.recomputedHead.size &&
2547
+ signedHead.embeddedHead.root === agent.recomputedHead.root;
2548
+ }
2549
+ if (signatureOk && pinned != null) signerMatchesVendor = recoveredSigner === pinned;
2550
+ }
2551
+
2552
+ let accepted = true;
2553
+ let reason = "OK";
2554
+ if (!fileResult.filesOk) {
2555
+ accepted = false;
2556
+ reason = fileResult.reasonKind;
2557
+ } else if (signed && headBound === false) {
2558
+ accepted = false;
2559
+ reason = "head_not_bound";
2560
+ agent.reason = "HEAD_NOT_BOUND";
2561
+ } else if (signed && !signatureOk) {
2562
+ accepted = false;
2563
+ reason = "bad_signature";
2564
+ agent.reason = "SIGNATURE_FORGED";
2565
+ } else if (signed && pinned != null && signerMatchesVendor !== true) {
2566
+ accepted = false;
2567
+ reason = "wrong_issuer";
2568
+ agent.reason = "WRONG_VENDOR";
2569
+ } else if (!signed && pinned != null) {
2570
+ // Fail-closed pin: a stripped signature can never pass a pinned verify.
2571
+ accepted = false;
2572
+ reason = "unsigned_cannot_pin_vendor";
2573
+ agent.reason = "NOT_SIGNED";
2574
+ }
2575
+
2576
+ const result = {
2577
+ artifact,
2578
+ kind: KINDS.AGENT_PACKET,
2579
+ payloadKind: KINDS.AGENT_PACKET,
2580
+ signed,
2581
+ verdict: accepted ? "OK" : "REJECTED",
2582
+ reason,
2583
+ accepted,
2584
+ recoveredSigner,
2585
+ claimedSigner,
2586
+ pinnedVendor: pinned,
2587
+ signatureOk,
2588
+ signerMatchesVendor,
2589
+ sealedRoot: fileResult.sealedRoot,
2590
+ recomputedRoot: fileResult.recomputedRoot,
2591
+ rootMatches: fileResult.rootMatches,
2592
+ counts: {
2593
+ matched: fileResult.matched.length,
2594
+ changed: fileResult.changed.length,
2595
+ missing: 0,
2596
+ escaped: 0,
2597
+ unexpected: 0,
2598
+ },
2599
+ matched: fileResult.matched,
2600
+ changed: fileResult.changed,
2601
+ missing: [],
2602
+ escaped: [],
2603
+ unexpected: [],
2604
+ agent,
2605
+ note: TRUST_NOTE,
2606
+ };
2607
+ return { result, code: accepted ? EXIT.OK : EXIT.REJECTED };
2608
+ }
2609
+
2610
+ // ---------------------------------------------------------------------------
2611
+ // The core verify orchestration over an ALREADY-PARSED artifact object + an injected file source. This
2612
+ // is the ONE engine BOTH entrypoints drive — `verifyArtifact` (disk: the CLI contract, byte-identical to
2613
+ // before this seam existed) and `verifyArtifactFromBytes` (in-memory map). It auto-detects the artifact
2614
+ // kind, decodes a signed container (recovering + pinning the signer), re-derives the root from
2615
+ // referenced bytes, and assembles a deterministic verdict. PURE: every read goes through `readEntry`.
2616
+ // Returns { result, code } — code is the EXIT-contract integer.
2617
+ // ---------------------------------------------------------------------------
2618
+
2619
+ function verifyParsedArtifact({ artifact, obj, vendor, readEntry }) {
2620
+ const kind = obj.kind;
2621
+ const pinned = vendor != null ? normalizeAddress(vendor, "--vendor") : null;
2622
+
2623
+ // AGENT-SESSION packet (T-68.3): SELF-CONTAINED — no sibling bytes, its own leaf/root convention and
2624
+ // its own in-packet signed head. Routed to the dedicated orchestrator above (`readEntry` unused).
2625
+ if (kind === KINDS.AGENT_PACKET) {
2626
+ return verifyAgentPacketArtifact({ artifact, obj, pinned });
2627
+ }
2628
+
2629
+ // Detect signed vs bare and the underlying payload kind. A signed container wraps the embedded payload.
2630
+ let signed = false;
2631
+ let recoveredSigner = null;
2632
+ let claimedSigner = null;
2633
+ let signatureOk = null; // null = no signature on this artifact
2634
+ let payload = obj; // the (possibly embedded) thing whose root we re-derive
2635
+ let payloadKind = kind;
2636
+
2637
+ if (
2638
+ kind === KINDS.EVIDENCE_SEAL_SIGNED ||
2639
+ kind === KINDS.TRUST_SEAL_SIGNED ||
2640
+ kind === KINDS.DATASET_ATTESTATION_SIGNED ||
2641
+ kind === KINDS.DATASET_ATTESTATION_TIMESTAMPED
2642
+ ) {
2643
+ signed = true;
2644
+ const dec = decodeSigned(obj);
2645
+ payload = dec.embedded;
2646
+ payloadKind = dec.embedded.kind;
2647
+ claimedSigner = dec.claimedSigner;
2648
+ recoveredSigner = tryRecover(dec.message, dec.signature);
2649
+ // signatureOk: the signature recovers AND matches the CLAIMED signer recorded in the container.
2650
+ signatureOk = recoveredSigner != null && recoveredSigner === claimedSigner;
2651
+ } else if (!Object.values(KINDS).includes(kind)) {
2652
+ throw new UsageError(
2653
+ `unrecognized artifact kind: ${JSON.stringify(kind)} ` +
2654
+ "(verify-vh understands evidence seals, reconciliation seals, dataset attestations, and proof bundles)"
2655
+ );
2656
+ }
2657
+
2658
+ // Re-derive the root from the referenced bytes per the (underlying) kind.
2659
+ let fileResult;
2660
+ if (payloadKind === KINDS.EVIDENCE_SEAL) {
2661
+ fileResult = verifyEvidenceSealWith(payload, readEntry);
2662
+ } else if (payloadKind === KINDS.TRUST_SEAL) {
2663
+ fileResult = verifyTrustSealWith(payload, readEntry);
2664
+ } else if (payloadKind === KINDS.DATASET_ATTESTATION) {
2665
+ fileResult = verifyDatasetAttestation(payload);
2666
+ } else if (payloadKind === KINDS.PROOF) {
2667
+ fileResult = verifyProofBundle(payload);
2668
+ } else {
2669
+ throw new UsageError(
2670
+ `unrecognized embedded artifact kind: ${JSON.stringify(payloadKind)}`
2671
+ );
2672
+ }
2673
+
2674
+ // --- Decide the verdict + the deterministic reason. ---
2675
+ // Precedence: a structural file tamper (CHANGED/MISSING/root mismatch) is a clean REJECTED. For a
2676
+ // SIGNED artifact, a broken signature is `bad_signature`; a recovered signer that does not equal the
2677
+ // pinned --vendor is `wrong_issuer`. Both are clean REJECTED verdicts (exit 3), never a crash.
2678
+ let reason = "OK";
2679
+ let accepted = true;
2680
+
2681
+ const escaped = fileResult.escaped || [];
2682
+ if (!fileResult.filesOk) {
2683
+ accepted = false;
2684
+ // path_escape DOMINATES: an artifact that tries to read outside its source is malicious by
2685
+ // construction (the threat model is a hostile producer probing the counterparty's filesystem), so it
2686
+ // is reported FIRST — never as a benign CHANGED/MISSING, and never with a leaked out-of-tree content
2687
+ // hash.
2688
+ if (escaped.length > 0) reason = "path_escape";
2689
+ else if (fileResult.changed.length > 0) reason = "CHANGED";
2690
+ else if (fileResult.missing.length > 0) reason = "MISSING";
2691
+ else if (fileResult.unexpected.length > 0) reason = "UNEXPECTED";
2692
+ else reason = "root_mismatch";
2693
+ }
2694
+
2695
+ // Signature checks (only for signed artifacts). A bad signature dominates the "issuer" check (you
2696
+ // cannot trust an issuer you cannot recover).
2697
+ let signerMatchesVendor = null;
2698
+ if (signed) {
2699
+ if (!signatureOk) {
2700
+ accepted = false;
2701
+ // bad_signature is the dominant reason ONLY if files were otherwise OK; if a file also changed we
2702
+ // still surface bad_signature because the signature is the trust root of a signed artifact.
2703
+ reason = "bad_signature";
2704
+ } else if (pinned != null) {
2705
+ signerMatchesVendor = recoveredSigner === pinned;
2706
+ if (!signerMatchesVendor) {
2707
+ accepted = false;
2708
+ // wrong_issuer only when the signature itself is sound but the signer is not the pinned vendor.
2709
+ if (fileResult.filesOk) reason = "wrong_issuer";
2710
+ else if (reason === "OK") reason = "wrong_issuer";
2711
+ }
2712
+ }
2713
+ } else if (pinned != null) {
2714
+ // A --vendor pin on an UNSIGNED artifact cannot be satisfied (there is no signer to recover); this is
2715
+ // a clean REJECTED wrong_issuer-style verdict so a CI gate expecting a signed-by-vendor artifact fails.
2716
+ accepted = false;
2717
+ reason = "unsigned_cannot_pin_vendor";
2718
+ }
2719
+
2720
+ const verdict = accepted ? "OK" : "REJECTED";
2721
+ const code = accepted ? EXIT.OK : EXIT.REJECTED;
2722
+
2723
+ const result = {
2724
+ artifact,
2725
+ kind,
2726
+ payloadKind,
2727
+ signed,
2728
+ verdict,
2729
+ reason,
2730
+ accepted,
2731
+ recoveredSigner,
2732
+ claimedSigner,
2733
+ pinnedVendor: pinned,
2734
+ signatureOk,
2735
+ signerMatchesVendor,
2736
+ sealedRoot: fileResult.sealedRoot,
2737
+ recomputedRoot: fileResult.recomputedRoot,
2738
+ rootMatches: fileResult.rootMatches,
2739
+ counts: {
2740
+ matched: fileResult.matched.length,
2741
+ changed: fileResult.changed.length,
2742
+ missing: fileResult.missing.length,
2743
+ escaped: escaped.length,
2744
+ unexpected: fileResult.unexpected.length,
2745
+ },
2746
+ matched: fileResult.matched,
2747
+ changed: fileResult.changed,
2748
+ missing: fileResult.missing,
2749
+ escaped,
2750
+ unexpected: fileResult.unexpected,
2751
+ note: TRUST_NOTE,
2752
+ };
2753
+ if (fileResult.identityOnly) result.identityOnly = true;
2754
+ if (fileResult.proof) result.proof = fileResult.proof;
2755
+
2756
+ return { result, code };
2757
+ }
2758
+
2759
+ // ---------------------------------------------------------------------------
2760
+ // The PURE revocation fold for the bytes path. Semantically identical to revocation.loadAndApply (the
2761
+ // disk integration) once the entries are in hand: resolve the as-of instant (defaulting to nowISO),
2762
+ // normalize the caller-supplied revocations input (a JSON string, a container object, or an array of
2763
+ // either), fold the decision onto the result, and recompute the exit code. Uses ONLY the pure decision
2764
+ // functions (./lib/revocation-core.js via the revocation re-exports) — never the fs-backed reader.
2765
+ // ---------------------------------------------------------------------------
2766
+
2767
+ function applyRevocationsDecision(result, revocationsInput, asOf, nowISO) {
2768
+ const resolved = revocation.resolveAsOf(asOf, nowISO);
2769
+ const entries = revocation.normalizeRevocationsInput(revocationsInput);
2770
+ const downgraded = revocation.applyToVerifyResult({ result, revocations: entries, asOf: resolved.asOf });
2771
+ downgraded.trustAsOfDefaulted = resolved.defaulted;
2772
+ return { result: downgraded, code: downgraded.accepted ? EXIT.OK : EXIT.REJECTED };
2773
+ }
2774
+
2775
+ // ---------------------------------------------------------------------------
2776
+ // THE IN-MEMORY FILE SOURCE + BYTES ENTRYPOINT (T-66.1).
2777
+ //
2778
+ // `verifyArtifactFromBytes({ artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName })`
2779
+ // drives the EXACT engine above over caller-supplied bytes:
2780
+ // * `artifactText` — the artifact JSON as a STRING (what a browser read out of a dropped file);
2781
+ // * `files` — a plain `{ relPath: Uint8Array|Buffer }` map of the packet's referenced bytes;
2782
+ // * `vendor` — optional 0x-address pin (same semantics as `--vendor`);
2783
+ // * `revocationsText` — optional revocations input (JSON text / container / array; same semantics as
2784
+ // the CONTENT of a `--revocations` file), with optional `asOf` (canonical ISO instant) + `nowISO`;
2785
+ // * `artifactName` — optional label used verbatim as `result.artifact` (defaults below).
2786
+ //
2787
+ // CONTRACT — NEVER THROWS. Hostile input (non-JSON artifact text, an oversized / absolute / `..` map
2788
+ // key, a non-bytes map value, a malformed vendor or asOf) is NAMED-rejected: the return value is
2789
+ // { ok, code, result, error }
2790
+ // where a computed verdict carries `result` (the SAME structured shape `verifyArtifact` returns — the
2791
+ // two are DEEP-EQUAL on identical inputs) + `error: null`, and an input problem carries `result: null` +
2792
+ // `error: { name: "UsageError"|"IOError", code, message }` with the exact defect named. The verdict
2793
+ // classes (missing / extra / content-mismatch / wrong-vendor / tampered-signature / path_escape /
2794
+ // revoked) derive from the MAP exactly as the disk path derives them from the directory.
2795
+ // ---------------------------------------------------------------------------
2796
+
2797
+ // The largest relPath key the in-memory map accepts. Sealed relPaths are short; a multi-kilobyte "key"
2798
+ // is hostile input (an attempted resource-exhaustion / log-flooding vector), rejected by NAME up front.
2799
+ const MAX_RELPATH_CHARS = 4096;
2800
+
2801
+ // PURE string-level confinement for an in-memory relPath — the map-source mirror of the disk source's
2802
+ // string checks (absolute anywhere, or any `..` traversal component, is hostile). Windows-style drive
2803
+ // and UNC prefixes are treated as absolute here too: an in-memory map NEVER has a legitimate absolute
2804
+ // key, whatever platform authored the artifact.
2805
+ function isTraversalOrAbsoluteRelPath(relPath) {
2806
+ if (typeof relPath !== "string" || relPath.length === 0) return true;
2807
+ if (relPath.charAt(0) === "/" || relPath.charAt(0) === "\\") return true;
2808
+ if (/^[A-Za-z]:[\\/]/.test(relPath)) return true;
2809
+ if (relPath.split(/[\\/]/).includes("..")) return true;
2810
+ return false;
2811
+ }
2812
+
2813
+ // Validate the caller's `{ relPath: bytes }` map SHAPE up front so a hostile map is NAMED-rejected
2814
+ // before any verification work (and before any key is dereferenced). Throws UsageError; the entrypoint
2815
+ // converts that into the structured `{ error }` return — never an uncaught throw.
2816
+ function validateFilesMap(files) {
2817
+ if (files == null || typeof files !== "object" || Array.isArray(files)) {
2818
+ throw new UsageError(
2819
+ "verifyArtifactFromBytes requires `files` as a plain { relPath: Uint8Array|Buffer } object map"
2820
+ );
2821
+ }
2822
+ for (const key of Object.keys(files)) {
2823
+ if (key.length === 0) {
2824
+ throw new UsageError("files map contains an empty relPath key");
2825
+ }
2826
+ if (key.length > MAX_RELPATH_CHARS) {
2827
+ throw new UsageError(
2828
+ `files map key exceeds ${MAX_RELPATH_CHARS} characters (oversized relPath, starts: ` +
2829
+ `${JSON.stringify(key.slice(0, 64))})`
2830
+ );
2831
+ }
2832
+ if (isTraversalOrAbsoluteRelPath(key)) {
2833
+ throw new UsageError(
2834
+ `files map key is not a confined relative path: ${JSON.stringify(key.slice(0, 256))}`
2835
+ );
2836
+ }
2837
+ const v = files[key];
2838
+ if (!(v instanceof Uint8Array)) {
2839
+ throw new UsageError(
2840
+ `files map value for ${JSON.stringify(key.slice(0, 256))} must be a Uint8Array/Buffer of the file's bytes`
2841
+ );
2842
+ }
2843
+ }
2844
+ }
2845
+
2846
+ // The in-memory `readEntry` source over an (already-validated) map: a hostile relPath from the ARTIFACT
2847
+ // is `escaped` (the same string-level rules as the disk source — so absolute/`..` seal entries produce
2848
+ // the identical path_escape verdict), an absent key is `missing`, and a present key answers its bytes.
2849
+ // Lookups use an own-property check so `__proto__`/`constructor` style keys can never smuggle
2850
+ // prototype-chain values in as file bytes.
2851
+ function makeMapReadEntry(files) {
2852
+ return function readEntry(relPath) {
2853
+ if (isTraversalOrAbsoluteRelPath(relPath)) return { status: "escaped" };
2854
+ if (!Object.prototype.hasOwnProperty.call(files, relPath)) return { status: "missing" };
2855
+ return { status: "ok", bytes: files[relPath] };
2856
+ };
2857
+ }
2858
+
2859
+ function verifyArtifactFromBytes(params) {
2860
+ try {
2861
+ if (params == null || typeof params !== "object" || Array.isArray(params)) {
2862
+ throw new UsageError(
2863
+ "verifyArtifactFromBytes requires a params object: " +
2864
+ "{ artifactText, files, vendor?, revocationsText?, asOf?, nowISO?, artifactName? }"
2865
+ );
2866
+ }
2867
+ const { artifactText, files, vendor, revocationsText, asOf, nowISO, artifactName } = params;
2868
+ if (typeof artifactText !== "string") {
2869
+ throw new UsageError("verifyArtifactFromBytes requires `artifactText` (the artifact JSON as a string)");
2870
+ }
2871
+ validateFilesMap(files);
2872
+
2873
+ // Mirror the CLI's flag-shape gate (parseArgs): asOf only means something alongside revocations, and
2874
+ // must be a canonical ISO-8601 UTC instant — a malformed one is a NAMED usage rejection up front,
2875
+ // never a mid-verify throw.
2876
+ if (asOf !== undefined && asOf !== null && (revocationsText === undefined || revocationsText === null)) {
2877
+ throw new UsageError(
2878
+ "asOf requires revocationsText (it pins the instant the revocation decision is made AS OF)"
2879
+ );
2880
+ }
2881
+ if (asOf !== undefined && asOf !== null) {
2882
+ const ms = Date.parse(asOf);
2883
+ if (
2884
+ typeof asOf !== "string" ||
2885
+ !revocation.ISO_INSTANT_RE.test(asOf) ||
2886
+ Number.isNaN(ms) ||
2887
+ new Date(ms).toISOString() !== asOf
2888
+ ) {
2889
+ throw new UsageError(
2890
+ `invalid asOf: ${String(asOf)} (expected a canonical ISO-8601 UTC instant, e.g. 2026-06-01T00:00:00.000Z)`
2891
+ );
2892
+ }
2893
+ }
2894
+
2895
+ const label = artifactName != null ? String(artifactName) : "(in-memory artifact)";
2896
+ let obj;
2897
+ try {
2898
+ obj = JSON.parse(artifactText);
2899
+ } catch (e) {
2900
+ throw new IOError(`artifact ${label} is not valid JSON: ${e.message}`);
2901
+ }
2902
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
2903
+ throw new IOError(`artifact ${label} must be a JSON object`);
2904
+ }
2905
+
2906
+ const { result, code } = verifyParsedArtifact({
2907
+ artifact: label,
2908
+ obj,
2909
+ vendor,
2910
+ readEntry: makeMapReadEntry(files),
2911
+ });
2912
+
2913
+ // OPTIONAL recipient-side TRUST-DECISION-AS-OF, from caller-supplied revocations INPUT (never a
2914
+ // filesystem read). Same downgrade math as the disk path's revocation.loadAndApply, so the two
2915
+ // paths' results stay deep-equal on identical inputs.
2916
+ if (revocationsText !== undefined && revocationsText !== null) {
2917
+ let applied;
2918
+ try {
2919
+ applied = applyRevocationsDecision(result, revocationsText, asOf, nowISO || new Date().toISOString());
2920
+ } catch (e) {
2921
+ // A non-JSON / wrong-shape revocations input is the bytes-path analogue of an unreadable
2922
+ // --revocations file: a NAMED IO-class rejection, never a silently-skipped downgrade.
2923
+ throw new IOError(`cannot evaluate revocations: ${e.message}`);
2924
+ }
2925
+ return { ok: applied.result.accepted, code: applied.code, result: applied.result, error: null };
2926
+ }
2927
+
2928
+ return { ok: result.accepted, code, result, error: null };
2929
+ } catch (e) {
2930
+ const isUsage = e instanceof UsageError;
2931
+ const code = isUsage ? EXIT.USAGE : EXIT.IO;
2932
+ return {
2933
+ ok: false,
2934
+ code,
2935
+ result: null,
2936
+ error: {
2937
+ name: isUsage ? "UsageError" : "IOError",
2938
+ code,
2939
+ message: String(e && e.message ? e.message : e),
2940
+ },
2941
+ };
2942
+ }
2943
+ }
2944
+
2945
+ // BUILD-GENERATED EXPORTS (verifier/build-standalone-html.js): the engine surface the page drives.
2946
+ module.exports = {
2947
+ EXIT: EXIT,
2948
+ KINDS: KINDS,
2949
+ TRUST_NOTE: TRUST_NOTE,
2950
+ UsageError: UsageError,
2951
+ IOError: IOError,
2952
+ MAX_RELPATH_CHARS: MAX_RELPATH_CHARS,
2953
+ verifyArtifactFromBytes: verifyArtifactFromBytes,
2954
+ };
2955
+ };
2956
+
2957
+ // ===== module: challenge-fixture (build-generated) =====
2958
+ __modules["challenge-fixture"] = function (module, exports, __require) {
2959
+ "use strict";
2960
+ // The verifier's SHIPPED demo packet (verify-vh.js DEMO_SIGNER/DEMO_FILES/DEMO_CONTAINER), inlined
2961
+ // VERBATIM at build time — a REAL vh.evidence-seal-signed container signed by the fixed TEST-ONLY
2962
+ // key (hardhat account #1; never a real key / real funds). CONTAINER_TEXT is the exact JSON the
2963
+ // in-tree demo verifies, so the page's sample verdict is byte-identical to `verify-vh demo`'s.
2964
+ var SIGNER = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8";
2965
+ var PACKET_NAME = "demo-packet.vhevidence.json";
2966
+ var CONTAINER_TEXT = "{\"kind\":\"vh.evidence-seal-signed\",\"attestation\":\"{\\\"kind\\\":\\\"vh.evidence-seal\\\",\\\"files\\\":[{\\\"relPath\\\":\\\"model-card.md\\\",\\\"contentHash\\\":\\\"0x1aeca0ad922f53e9c30186234c5d1a62ffda62a828988bdd266fa93240675db0\\\",\\\"leaf\\\":\\\"0xbbb3052a7359188aed3f114e15b721cf5d707a8bdf09109d1d51ec5765b3c58c\\\"},{\\\"relPath\\\":\\\"weights.txt\\\",\\\"contentHash\\\":\\\"0x7716d380e062d1daf7ca58897b55f6b58900ed4fd1eda79445956c5c3d336cdf\\\",\\\"leaf\\\":\\\"0x34ce488c6fb49a32d356a2553196dc817a439c13a03ce9a2a2ff2710fcf9eea2\\\"}],\\\"root\\\":\\\"0x621a5eb924a9887f88d4b05ccdf19834cdae2f4ed2399921acc7b8a45d48da9b\\\"}\",\"signature\":{\"scheme\":\"eip191-personal-sign\",\"signer\":\"0x70997970c51812dc3a010c7d01b50e0d17dc79c8\",\"signature\":\"0x1aabba1530df192e87498bbf1a26f63a7e30d84d72c14bf5d08b2d872df9810b672efcf26f30ec6a38a00ffc158be53633daeff9e99f344b6c1a2e99522d61a01b\"}}";
2967
+ var FILES = {"model-card.md":"# Demo model card\nThis file is sealed by the verify-vh demo.\n","weights.txt":"0.10 0.20 0.30\n"};
2968
+ var TAMPER_FILE = "model-card.md";
2969
+ // The AGENT-SESSION demo packet (T-68.3): the verifier's shipped DEMO_AGENT_* constants, inlined
2970
+ // VERBATIM — a genuine `vh.agent-session-packet` with one tool_call payload REDACTED behind its
2971
+ // hash commitment (it STILL verifies). The TAMPER_FROM/TO pair is a one-byte payload edit that
2972
+ // occurs exactly once in the packet text, so the page's agent challenge is deterministic.
2973
+ var AGENT_PACKET_NAME = "demo-session.vhagent.json";
2974
+ var 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";
2975
+ var AGENT_TAMPER_SEQ = 0;
2976
+ var AGENT_TAMPER_FROM = "\"payload\":\"Summarize the vendor contract";
2977
+ var AGENT_TAMPER_TO = "\"payload\":\"SUMMARIZE the vendor contract";
2978
+ module.exports = {
2979
+ SIGNER: SIGNER,
2980
+ PACKET_NAME: PACKET_NAME,
2981
+ CONTAINER_TEXT: CONTAINER_TEXT,
2982
+ FILES: FILES,
2983
+ TAMPER_FILE: TAMPER_FILE,
2984
+ AGENT_PACKET_NAME: AGENT_PACKET_NAME,
2985
+ AGENT_PACKET_TEXT: AGENT_PACKET_TEXT,
2986
+ AGENT_TAMPER_SEQ: AGENT_TAMPER_SEQ,
2987
+ AGENT_TAMPER_FROM: AGENT_TAMPER_FROM,
2988
+ AGENT_TAMPER_TO: AGENT_TAMPER_TO,
2989
+ };
2990
+ };
2991
+
2992
+ // ===== module: challenge (build-generated) =====
2993
+ __modules["challenge"] = function (module, exports, __require) {
2994
+ "use strict";
2995
+ // The built-in 60-SECOND CHALLENGE over the embedded demo fixture: genuine -> ACCEPT (signer pinned),
2996
+ // one tampered byte -> REJECT naming the file. Drives the REAL engine — no special-case verify path.
2997
+ var engine = __require("verify-vh-engine");
2998
+ var fixture = __require("challenge-fixture");
2999
+ function toFilesMap(contents) {
3000
+ var m = {};
3001
+ var keys = Object.keys(contents);
3002
+ for (var i = 0; i < keys.length; i++) {
3003
+ m[keys[i]] = Buffer.from(contents[keys[i]], "utf8");
3004
+ }
3005
+ return m;
3006
+ }
3007
+ function verifyContents(contents) {
3008
+ return engine.verifyArtifactFromBytes({
3009
+ artifactText: fixture.CONTAINER_TEXT,
3010
+ files: toFilesMap(contents),
3011
+ vendor: fixture.SIGNER,
3012
+ artifactName: fixture.PACKET_NAME,
3013
+ });
3014
+ }
3015
+ function runChallenge() {
3016
+ var genuine = verifyContents(fixture.FILES);
3017
+ var tamperedFiles = {};
3018
+ var keys = Object.keys(fixture.FILES);
3019
+ for (var i = 0; i < keys.length; i++) tamperedFiles[keys[i]] = fixture.FILES[keys[i]];
3020
+ tamperedFiles[fixture.TAMPER_FILE] = fixture.FILES[fixture.TAMPER_FILE] + "X";
3021
+ var tampered = verifyContents(tamperedFiles);
3022
+ return {
3023
+ genuine: genuine,
3024
+ tampered: tampered,
3025
+ signer: fixture.SIGNER,
3026
+ tamperedFile: fixture.TAMPER_FILE,
3027
+ packetName: fixture.PACKET_NAME,
3028
+ };
3029
+ }
3030
+ // The AGENT-SESSION challenge (T-68.3): the SAME engine verifies the embedded *.vhagent.json
3031
+ // packet — SELF-CONTAINED, so the files map is empty. genuine -> ACCEPT (one payload redacted,
3032
+ // still verifies); a one-byte payload tamper -> REJECT naming the offending event seq.
3033
+ function verifyAgentText(packetText) {
3034
+ return engine.verifyArtifactFromBytes({
3035
+ artifactText: packetText,
3036
+ files: {},
3037
+ artifactName: fixture.AGENT_PACKET_NAME,
3038
+ });
3039
+ }
3040
+ function runAgentChallenge() {
3041
+ var genuine = verifyAgentText(fixture.AGENT_PACKET_TEXT);
3042
+ var tamperedText = fixture.AGENT_PACKET_TEXT.replace(fixture.AGENT_TAMPER_FROM, fixture.AGENT_TAMPER_TO);
3043
+ var tampered = verifyAgentText(tamperedText);
3044
+ return {
3045
+ genuine: genuine,
3046
+ tampered: tampered,
3047
+ tamperSeq: fixture.AGENT_TAMPER_SEQ,
3048
+ packetName: fixture.AGENT_PACKET_NAME,
3049
+ };
3050
+ }
3051
+ module.exports = {
3052
+ runChallenge: runChallenge,
3053
+ verifyContents: verifyContents,
3054
+ runAgentChallenge: runAgentChallenge,
3055
+ verifyAgentText: verifyAgentText,
3056
+ fixture: fixture,
3057
+ };
3058
+ };
3059
+
3060
+ return {
3061
+ engine: __require("verify-vh-engine"),
3062
+ challenge: __require("challenge"),
3063
+ };
3064
+ })();
3065
+ // __VERIFY_VH_ENGINE_END__
3066
+ </script>
3067
+ <script>
3068
+ "use strict";
3069
+ (function () {
3070
+ var E = VerifyVhStandalone.engine;
3071
+ var C = VerifyVhStandalone.challenge;
3072
+ var enc = new TextEncoder();
3073
+ var dec = new TextDecoder();
3074
+
3075
+ function $(id) { return document.getElementById(id); }
3076
+ function el(tag, cls, text) {
3077
+ var n = document.createElement(tag);
3078
+ if (cls) n.className = cls;
3079
+ if (text !== undefined) n.textContent = text;
3080
+ return n;
3081
+ }
3082
+ function kv(label, value) {
3083
+ var p = el("div", "kv");
3084
+ p.appendChild(el("b", null, label));
3085
+ var v = el("span", "mono", value);
3086
+ p.appendChild(v);
3087
+ return p;
3088
+ }
3089
+
3090
+ $("trust-note").textContent = E.TRUST_NOTE;
3091
+
3092
+ // ---------- shared verdict rendering (textContent only — hostile relPaths cannot inject) ----------
3093
+ function renderVerdict(container, out) {
3094
+ container.textContent = "";
3095
+ if (out.error) {
3096
+ var eb = el("div", "verdict reject");
3097
+ eb.appendChild(el("div", "verdict-title", "CANNOT VERIFY (" + out.error.name + ", exit " + out.error.code + ")"));
3098
+ eb.appendChild(el("div", "mono", out.error.message));
3099
+ container.appendChild(eb);
3100
+ return;
3101
+ }
3102
+ var r = out.result;
3103
+ var box = el("div", "verdict " + (r.accepted ? "accept" : "reject"));
3104
+ var title = r.accepted
3105
+ ? "ACCEPT — the artifact verifies."
3106
+ : (r.verdict === "REVOKED" ? "REVOKED" : "REJECTED") + " (" + r.reason + ")";
3107
+ box.appendChild(el("div", "verdict-title", title));
3108
+ box.appendChild(kv("artifact", String(r.artifact)));
3109
+ box.appendChild(kv("kind", r.kind + (r.payloadKind !== r.kind ? " (embeds " + r.payloadKind + ")" : "")));
3110
+ box.appendChild(kv("signed", r.signed ? "yes" : "no"));
3111
+ if (r.signed) {
3112
+ box.appendChild(kv("recovered signer", r.recoveredSigner || "(unrecoverable)"));
3113
+ if (r.pinnedVendor != null) {
3114
+ box.appendChild(kv("pinned vendor", r.pinnedVendor));
3115
+ box.appendChild(kv("signer matches vendor", r.signerMatchesVendor ? "yes" : "NO"));
3116
+ }
3117
+ }
3118
+ if (r.sealedRoot != null) box.appendChild(kv("sealed root", r.sealedRoot));
3119
+ if (r.recomputedRoot != null) box.appendChild(kv("recomputed root", r.recomputedRoot));
3120
+ if (r.rootMatches != null) box.appendChild(kv("root matches", r.rootMatches ? "yes" : "NO"));
3121
+ if (r.agent) {
3122
+ box.appendChild(kv("declared head", "{ size: " + r.agent.head.size + ", root: " + r.agent.head.root + " }"));
3123
+ if (r.agent.counts) {
3124
+ box.appendChild(kv("events", r.agent.counts.events + " (" + r.agent.counts.full + " full, " + r.agent.counts.redacted + " redacted)"));
3125
+ box.appendChild(kv("withheld seqs", r.agent.withheld.length === 0 ? "(none — every payload disclosed)" : r.agent.withheld.join(", ")));
3126
+ }
3127
+ if (!r.accepted && r.agent.seq != null) {
3128
+ box.appendChild(kv("offending event seq", String(r.agent.seq) + (r.agent.reason ? " (" + r.agent.reason + ")" : "")));
3129
+ }
3130
+ }
3131
+ box.appendChild(kv("files", r.counts.matched + " matched, " + r.counts.changed + " changed, " +
3132
+ r.counts.missing + " missing, " + (r.counts.escaped || 0) + " rejected"));
3133
+ if (!r.accepted) {
3134
+ var ul = el("ul", "files");
3135
+ r.changed.forEach(function (c) {
3136
+ ul.appendChild(el("li", "mono", "CHANGED " + c.relPath + ": sealed " + c.expectedContentHash + " != held " + c.actualContentHash));
3137
+ });
3138
+ r.missing.forEach(function (m) {
3139
+ ul.appendChild(el("li", "mono", "MISSING " + m.relPath + ": referenced but not among the dropped files"));
3140
+ });
3141
+ (r.escaped || []).forEach(function (x) {
3142
+ ul.appendChild(el("li", "mono", "REJECTED " + x.relPath + ": path escapes the packet (refused to read; no hash computed)"));
3143
+ });
3144
+ if (ul.childNodes.length) box.appendChild(ul);
3145
+ if (r.trustAsOf && r.trustAsOf.governing) {
3146
+ box.appendChild(el("div", "mono", "key_revoked_as_of: signing key " + r.trustAsOf.governing.vendorAddress +
3147
+ " was REVOKED as of " + r.trustAsOf.governing.revokedAt + " (reason: " + r.trustAsOf.governing.reason + ")"));
3148
+ }
3149
+ }
3150
+ container.appendChild(box);
3151
+ }
3152
+
3153
+ // ---------- section 1: the built-in 60-second challenge ----------
3154
+ function runSampleVerify() {
3155
+ var contents = {};
3156
+ Object.keys(C.fixture.FILES).forEach(function (k) { contents[k] = C.fixture.FILES[k]; });
3157
+ contents[C.fixture.TAMPER_FILE] = $("sample-editor").value;
3158
+ var out = C.verifyContents(contents);
3159
+ renderVerdict($("sample-verdict"), out);
3160
+ }
3161
+ $("load-sample").onclick = function () {
3162
+ $("sample-area").style.display = "";
3163
+ $("sample-name").textContent = C.fixture.PACKET_NAME;
3164
+ $("sample-signer").textContent = C.fixture.SIGNER + " (fixed TEST-ONLY key — hardhat account #1)";
3165
+ $("sample-editor").value = C.fixture.FILES[C.fixture.TAMPER_FILE];
3166
+ runSampleVerify();
3167
+ };
3168
+ $("sample-verify").onclick = runSampleVerify;
3169
+ $("sample-tamper").onclick = function () {
3170
+ $("sample-editor").value = $("sample-editor").value + "X";
3171
+ runSampleVerify();
3172
+ };
3173
+ $("sample-restore").onclick = function () {
3174
+ $("sample-editor").value = C.fixture.FILES[C.fixture.TAMPER_FILE];
3175
+ runSampleVerify();
3176
+ };
3177
+
3178
+ // ---------- section 1b: the built-in agent-session demo (T-68.3) ----------
3179
+ function runAgentSampleVerify() {
3180
+ renderVerdict($("agent-verdict"), C.verifyAgentText($("agent-editor").value));
3181
+ }
3182
+ $("load-agent-sample").onclick = function () {
3183
+ $("agent-sample-area").style.display = "";
3184
+ $("agent-sample-name").textContent = C.fixture.AGENT_PACKET_NAME;
3185
+ $("agent-editor").value = C.fixture.AGENT_PACKET_TEXT;
3186
+ runAgentSampleVerify();
3187
+ };
3188
+ $("agent-verify").onclick = runAgentSampleVerify;
3189
+ $("agent-tamper").onclick = function () {
3190
+ // One deterministic byte flip inside a payload (a substring that occurs exactly once).
3191
+ $("agent-editor").value = $("agent-editor").value.replace(C.fixture.AGENT_TAMPER_FROM, C.fixture.AGENT_TAMPER_TO);
3192
+ runAgentSampleVerify();
3193
+ };
3194
+ $("agent-restore").onclick = function () {
3195
+ $("agent-editor").value = C.fixture.AGENT_PACKET_TEXT;
3196
+ runAgentSampleVerify();
3197
+ };
3198
+
3199
+ // ---------- section 2: verify a real packet ----------
3200
+ var held = {}; // relPath -> Uint8Array
3201
+ var revocationsText = null;
3202
+
3203
+ function refreshHeld() {
3204
+ var list = $("held-files");
3205
+ list.textContent = "";
3206
+ var keys = Object.keys(held).sort();
3207
+ if (keys.length === 0) { list.textContent = "(no files held yet)"; return refreshArtifactSelect(); }
3208
+ keys.forEach(function (k) {
3209
+ list.appendChild(el("div", null, k + " (" + held[k].length + " bytes)"));
3210
+ });
3211
+ refreshArtifactSelect();
3212
+ }
3213
+ function artifactCandidates() {
3214
+ var kinds = Object.keys(E.KINDS).map(function (k) { return E.KINDS[k]; });
3215
+ return Object.keys(held).sort().filter(function (k) {
3216
+ if (held[k].length > 8 * 1024 * 1024) return false;
3217
+ try {
3218
+ var obj = JSON.parse(dec.decode(held[k]));
3219
+ return !!obj && typeof obj === "object" && kinds.indexOf(obj.kind) !== -1;
3220
+ } catch (e) { return false; }
3221
+ });
3222
+ }
3223
+ function refreshArtifactSelect() {
3224
+ var sel = $("artifact-select");
3225
+ sel.textContent = "";
3226
+ var cands = artifactCandidates();
3227
+ if (cands.length === 0) {
3228
+ var opt = el("option", null, "(drop a *.vhevidence.json / *.vhseal / attestation / proof / *.vhagent.json first)");
3229
+ opt.value = "";
3230
+ sel.appendChild(opt);
3231
+ return;
3232
+ }
3233
+ cands.forEach(function (k) {
3234
+ var opt = el("option", null, k);
3235
+ opt.value = k;
3236
+ sel.appendChild(opt);
3237
+ });
3238
+ }
3239
+ function addHeld(relPath, bytes) {
3240
+ var key = String(relPath).replace(/\\/g, "/").replace(/^\.\//, "");
3241
+ held[key] = bytes;
3242
+ }
3243
+ function readFileInto(relPath, file, done) {
3244
+ var r = new FileReader();
3245
+ r.onload = function () { addHeld(relPath, new Uint8Array(r.result)); done(); };
3246
+ r.onerror = function () { done("cannot read " + relPath); };
3247
+ r.readAsArrayBuffer(file);
3248
+ }
3249
+ // Strip the top-level picked/dropped folder name so keys are relative to the packet's own dir,
3250
+ // exactly like the CLI's `--dir <folder>` resolution.
3251
+ function innerPath(p) {
3252
+ var parts = String(p).split("/").filter(function (s) { return s.length > 0; });
3253
+ return parts.length > 1 ? parts.slice(1).join("/") : parts.join("/");
3254
+ }
3255
+ function afterBatch(pending) {
3256
+ if (pending.n === 0) refreshHeld();
3257
+ }
3258
+ function ingestFileList(fileList, useRelative) {
3259
+ var files = Array.prototype.slice.call(fileList);
3260
+ var pending = { n: files.length };
3261
+ if (files.length === 0) return;
3262
+ files.forEach(function (f) {
3263
+ var rel = useRelative && f.webkitRelativePath ? innerPath(f.webkitRelativePath) : f.name;
3264
+ readFileInto(rel, f, function () { pending.n--; afterBatch(pending); });
3265
+ });
3266
+ }
3267
+ function walkEntry(entry, prefix, pending) {
3268
+ if (entry.isFile) {
3269
+ pending.n++;
3270
+ entry.file(function (f) {
3271
+ readFileInto(prefix + entry.name, f, function () { pending.n--; afterBatch(pending); });
3272
+ }, function () { pending.n--; afterBatch(pending); });
3273
+ } else if (entry.isDirectory) {
3274
+ var reader = entry.createReader();
3275
+ var readMore = function () {
3276
+ pending.n++;
3277
+ reader.readEntries(function (entries) {
3278
+ entries.forEach(function (e) { walkEntry(e, prefix + entry.name + "/", pending); });
3279
+ pending.n--;
3280
+ if (entries.length > 0) readMore();
3281
+ else afterBatch(pending);
3282
+ }, function () { pending.n--; afterBatch(pending); });
3283
+ };
3284
+ readMore();
3285
+ }
3286
+ }
3287
+ var drop = $("drop-zone");
3288
+ drop.addEventListener("dragover", function (ev) { ev.preventDefault(); drop.classList.add("armed"); });
3289
+ drop.addEventListener("dragleave", function () { drop.classList.remove("armed"); });
3290
+ drop.addEventListener("drop", function (ev) {
3291
+ ev.preventDefault();
3292
+ drop.classList.remove("armed");
3293
+ var items = ev.dataTransfer && ev.dataTransfer.items;
3294
+ var usedEntries = false;
3295
+ if (items) {
3296
+ var pending = { n: 0 };
3297
+ for (var i = 0; i < items.length; i++) {
3298
+ var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
3299
+ if (entry) {
3300
+ usedEntries = true;
3301
+ if (entry.isDirectory) {
3302
+ // Paths inside a dropped folder are taken relative to THAT folder (like --dir).
3303
+ var reader = entry.createReader();
3304
+ (function (rd) {
3305
+ var readMore = function () {
3306
+ pending.n++;
3307
+ rd.readEntries(function (entries) {
3308
+ entries.forEach(function (e) { walkEntry(e, "", pending); });
3309
+ pending.n--;
3310
+ if (entries.length > 0) readMore();
3311
+ else afterBatch(pending);
3312
+ }, function () { pending.n--; afterBatch(pending); });
3313
+ };
3314
+ readMore();
3315
+ })(reader);
3316
+ } else {
3317
+ walkEntry(entry, "", pending);
3318
+ }
3319
+ }
3320
+ }
3321
+ }
3322
+ if (!usedEntries && ev.dataTransfer && ev.dataTransfer.files) {
3323
+ ingestFileList(ev.dataTransfer.files, false);
3324
+ }
3325
+ });
3326
+ $("file-input").addEventListener("change", function () { ingestFileList(this.files, false); });
3327
+ $("dir-input").addEventListener("change", function () { ingestFileList(this.files, true); });
3328
+ $("clear-files").onclick = function () {
3329
+ held = {};
3330
+ revocationsText = null;
3331
+ $("revocations-name").textContent = "";
3332
+ $("revocations-input").value = "";
3333
+ $("verify-verdict").textContent = "";
3334
+ refreshHeld();
3335
+ };
3336
+ $("revocations-input").addEventListener("change", function () {
3337
+ var f = this.files && this.files[0];
3338
+ if (!f) { revocationsText = null; return; }
3339
+ var r = new FileReader();
3340
+ r.onload = function () {
3341
+ revocationsText = String(r.result);
3342
+ $("revocations-name").textContent = f.name + " (applies the same revoked-key downgrade the CLI does)";
3343
+ };
3344
+ r.readAsText(f);
3345
+ });
3346
+ $("run-verify").onclick = function () {
3347
+ var out = $("verify-verdict");
3348
+ var artifactKey = $("artifact-select").value;
3349
+ if (!artifactKey || !held[artifactKey]) {
3350
+ out.textContent = "";
3351
+ var eb = el("div", "verdict reject");
3352
+ eb.appendChild(el("div", "verdict-title", "No artifact selected"));
3353
+ eb.appendChild(el("div", null, "Drop the sealed artifact JSON together with the files it references, then pick it above."));
3354
+ out.appendChild(eb);
3355
+ return;
3356
+ }
3357
+ var files = {};
3358
+ Object.keys(held).forEach(function (k) { if (k !== artifactKey) files[k] = held[k]; });
3359
+ var vendor = $("vendor-input").value.trim();
3360
+ var params = {
3361
+ artifactText: dec.decode(held[artifactKey]),
3362
+ files: files,
3363
+ artifactName: artifactKey,
3364
+ };
3365
+ if (vendor) params.vendor = vendor;
3366
+ if (revocationsText != null) params.revocationsText = revocationsText;
3367
+ renderVerdict(out, E.verifyArtifactFromBytes(params));
3368
+ };
3369
+ refreshHeld();
3370
+ })();
3371
+ </script>
3372
+ </body>
3373
+ </html>