verifyhash 0.1.0 → 0.1.2

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 (64) 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/ANCHORING.md +43 -22
  6. package/docs/PUBLISH-VERIFY-VH.md +45 -0
  7. package/examples/README.md +185 -0
  8. package/examples/policy.lenient.json +5 -0
  9. package/examples/policy.strict.json +6 -0
  10. package/examples/run.js +366 -0
  11. package/examples/sample-dataset/README.txt +10 -0
  12. package/examples/sample-dataset/corpus/cc-by-poem.txt +8 -0
  13. package/examples/sample-dataset/corpus/mit-notes.txt +4 -0
  14. package/examples/sample-dataset/data/unlabeled.txt +5 -0
  15. package/examples/sample-dataset/vendored/gpl-snippet.txt +5 -0
  16. package/examples/sample-dataset.hints.json +7 -0
  17. package/examples/sample-parcel/data/manifest-of-contents.txt +7 -0
  18. package/examples/sample-parcel/data/records.csv +4 -0
  19. package/examples/sample-parcel/delivery-note.txt +9 -0
  20. package/package.json +26 -3
  21. package/verifier/README.md +584 -0
  22. package/verifier/action/README.md +87 -0
  23. package/verifier/action/action.yml +146 -0
  24. package/verifier/build-standalone-html.js +1287 -0
  25. package/verifier/build-standalone.js +989 -0
  26. package/verifier/ci/journal.generic.sh +96 -0
  27. package/verifier/ci/journal.github-actions.yml +99 -0
  28. package/verifier/ci/reproduce-vh.generic.sh +59 -0
  29. package/verifier/ci/reproduce-vh.github-actions.yml +49 -0
  30. package/verifier/ci/verify-service.generic.sh +96 -0
  31. package/verifier/ci/verify-service.github-actions.yml +88 -0
  32. package/verifier/ci/verify-vh.generic.sh +75 -0
  33. package/verifier/ci/verify-vh.github-actions.yml +56 -0
  34. package/verifier/dist/BUILD-PROVENANCE.json +210 -0
  35. package/verifier/dist/seal-vh-standalone.js +876 -0
  36. package/verifier/dist/seal-vh-standalone.js.sha256 +1 -0
  37. package/verifier/dist/verify-vh-standalone.html +3373 -0
  38. package/verifier/dist/verify-vh-standalone.html.sha256 +1 -0
  39. package/verifier/dist/verify-vh-standalone.js +5123 -0
  40. package/verifier/dist/verify-vh-standalone.js.sha256 +1 -0
  41. package/verifier/lib/canonical.js +141 -0
  42. package/verifier/lib/keccak.js +30 -0
  43. package/verifier/lib/keccak256-vendored.js +206 -0
  44. package/verifier/lib/merkle.js +145 -0
  45. package/verifier/lib/revocation-core.js +606 -0
  46. package/verifier/lib/revocation.js +200 -0
  47. package/verifier/lib/seal-cli.js +374 -0
  48. package/verifier/lib/seal-evidence.js +237 -0
  49. package/verifier/lib/secp256k1-recover.js +249 -0
  50. package/verifier/package.json +39 -0
  51. package/verifier/verify-vh.js +3376 -0
  52. package/docs/ADOPTION.json +0 -11
  53. package/docs/AUDIT.md +0 -55
  54. package/docs/DECIDE.md +0 -47
  55. package/docs/DECISIONS-PENDING.md +0 -27
  56. package/docs/DEPLOY-PUBLIC-SITE.md +0 -301
  57. package/docs/ENGINE-LEDGER.json +0 -12
  58. package/docs/LOOP-AUDIT-2026-07-03.json +0 -580
  59. package/docs/LOOP-HARDENING-PLAN.md +0 -44
  60. package/docs/METRICS.jsonl +0 -31
  61. package/docs/MORNING.md +0 -204
  62. package/docs/STRATEGY-ARCHIVE.md +0 -5055
  63. package/docs/SUPERVISOR-RUNBOOK.md +0 -52
  64. package/docs/USAGE-BUDGET.json +0 -121
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+
3
+ // verifier/lib/revocation.js — the STACK-FREE recipient-side KEY-REVOCATION reader + as-of decision for the
4
+ // INDEPENDENT verifier (EPIC-51 / T-51.4).
5
+ //
6
+ // WHY THIS EXISTS
7
+ // The producer stack already lets a recipient downgrade an otherwise-ACCEPTED signed artifact to REVOKED
8
+ // when the signing key was revoked-before-the-as-of-instant (`vh ... verify-signed --revocations <f>
9
+ // --as-of <T>`, cli/core/trust-asof.js + cli/core/revocation.js). The OFFLINE, no-producer-stack verifier
10
+ // (`verify-vh`) did NOT — so a counterparty who only holds the single-file verifier reached a DIFFERENT
11
+ // verdict than the producer on the SAME inputs (a clean ACCEPTED where the producer returned REVOKED).
12
+ // This module closes that gap WITHOUT pulling in ethers/hardhat or back-edging into cli/: it RE-IMPLEMENTS
13
+ // the revocation soundness check (EIP-191 signer recovery + the load-bearing SELF-CONTROL invariant) and
14
+ // the as-of trust decision using ONLY the verifier's own pure-JS crypto (./secp256k1-recover, ./keccak).
15
+ //
16
+ // MODULE LAYOUT (T-66.1 split — surface + behavior UNCHANGED)
17
+ // Every PURE piece of this module — the discriminators/grammars/trust notes, the structural validation, the
18
+ // signer recovery, the as-of classification/decision, the verify-result fold, the renderer — lives in
19
+ // ./revocation-core.js (which requires NO fs/path/os, so the verifier's IN-MEMORY bytes path can reach it
20
+ // without ANY impure builtin on its require graph). This file re-exports ALL of it VERBATIM (the very same
21
+ // function objects) and adds the two — and only two — fs-backed conveniences: `readRevocationsFromPath`
22
+ // (the --revocations <file-or-dir> reader) and `loadAndApply` (the one-call CLI integration). Every
23
+ // existing caller keeps its exact import surface; on identical inputs every function is byte-identical.
24
+ //
25
+ // THE LOAD-BEARING SAFETY INVARIANT — A REVOCATION CAN ONLY EVER REMOVE TRUST, NEVER ADD IT.
26
+ // Every revocation statement is verified the SAME way the producer core does: it must (1) recover to its
27
+ // own claimed `signer` AND (2) recover to its own embedded `vendorAddress` (a key revokes ITSELF). A
28
+ // revocation that fails EITHER check — forged, tampered, third-party, structurally malformed, or simply
29
+ // not parseable — is IGNORED with a WARNING and can NEVER downgrade the verdict. So a planted "revocation"
30
+ // for a victim's key cannot grief a recipient into rejecting a perfectly good artifact.
31
+ //
32
+ // SUBJECT-SCOPING — A REVOCATION ONLY BITES THE KEY IT NAMES.
33
+ // The `subject` is the artifact's RECOVERED signer (the address verify-vh derived from the bytes). A
34
+ // revocation only affects the verdict when its `vendorAddress` EQUALS that subject; a revocation for some
35
+ // OTHER key is `irrelevant`, never a downgrade.
36
+ //
37
+ // PURE + I/O-FREE on the decision; the FILE/DIR READ is the only I/O (readRevocationsFromPath), kept here so
38
+ // verify-vh stays a thin wiring layer. No network, no key, no clock (the `asOf` instant is caller-supplied).
39
+ //
40
+ // PARITY WITH THE PRODUCER STACK
41
+ // The decision semantics (applies / later / irrelevant / ignored; the inclusive `revokedAt <= asOf`
42
+ // boundary; the EARLIEST-applicable governing record; the later-revoked informational note) mirror
43
+ // cli/core/trust-asof.js, and the STRUCTURAL validation (validateSignedRevocation in ./revocation-core.js)
44
+ // mirrors the producer's cli/core/attestation.js validateSignedAttestation + cli/core/revocation.js
45
+ // validateRevocation gate-for-gate: the closed embedded field set, the supported schemaVersion (container
46
+ // AND payload), the standing trust NOTES, the lowercase-only signature/address grammar, and the
47
+ // WRAP-DON'T-EDIT canonical re-serialization binding. A revocation the producer IGNORES (a non-canonical /
48
+ // extra-field / wrong-note / unsupported-schemaVersion / mixed-case-hex but genuinely self-signed one) is
49
+ // therefore IGNORED here too — so on identical inputs verify-vh's verdict + exit code match
50
+ // `vh ... verify-signed --revocations` byte-for-byte, for the SOUND inputs AND the malformed ones.
51
+ // test/verifier.revocation.test.js pins that parity against the REAL producer core, including the
52
+ // malformed-but-self-signed (NEGATIVE-parity) classes.
53
+
54
+ const fs = require("fs");
55
+ const path = require("path");
56
+
57
+ const core = require("./revocation-core");
58
+
59
+ const { RevocationReadError, isPlainObject, resolveAsOf, normalizeRevocationsInput, applyToVerifyResult } = core;
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // FILE-OR-DIR reader. The ONLY I/O in this module. A counterparty may hand the verifier a SINGLE revocation
63
+ // file (one container, or a JSON array of them) OR a DIRECTORY of revocation files — a vendor commonly
64
+ // publishes one file per revoked key. Reading a directory aggregates every entry into ONE array of parsed
65
+ // containers, so the as-of decision sees them all under one --revocations flag.
66
+ //
67
+ // DIRECTORY MODE: read every *.json / *.vhrevocation.json file in the dir (NON-recursive — a flat folder of
68
+ // revocations), parse each, and flatten (a file that is itself a JSON array contributes all its entries). A
69
+ // file that is not valid JSON, or whose JSON is not an object/array, becomes a `_parseError` marker so it is
70
+ // IGNORED with a warning downstream — a single junk file in the folder never aborts the decision.
71
+ //
72
+ // FILE MODE: read the one file's text and hand it to normalizeRevocationsInput (so a single file may be one
73
+ // container OR a JSON array). A non-JSON single file HARD-errors (the recipient pointed --revocations at
74
+ // bytes that aren't a revocations input at all) — same contract as the producer's single-file read.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ // File extensions a directory scan treats as candidate revocation files.
78
+ const REVOCATION_FILE_RE = /\.(json|vhrevocation\.json)$/i;
79
+
80
+ /**
81
+ * Read the --revocations <file-or-dir> path into a normalized array of parsed entries (each a container
82
+ * object or a `_parseError` marker). The caller passes the already-resolved array straight to
83
+ * evaluateTrustAsOf/applyToVerifyResult.
84
+ *
85
+ * @param {string} p the --revocations path (a file or a directory)
86
+ * @param {{ readFile?: Function, statSync?: Function, readdirSync?: Function }} [io] injectable fs for tests
87
+ * @returns {Array<object>} a flat array of parsed entries (containers or `_parseError` markers)
88
+ * @throws {RevocationReadError} on an unreadable path or a non-JSON SINGLE file
89
+ */
90
+ function readRevocationsFromPath(p, io = {}) {
91
+ const readFile = io.readFile || ((f) => fs.readFileSync(f, "utf8"));
92
+ const statSync = io.statSync || ((f) => fs.statSync(f));
93
+ const readdirSync = io.readdirSync || ((d) => fs.readdirSync(d));
94
+
95
+ let st;
96
+ try {
97
+ st = statSync(p);
98
+ } catch (e) {
99
+ throw new RevocationReadError(`cannot read --revocations ${p}: ${e.message}`);
100
+ }
101
+
102
+ if (st.isDirectory()) {
103
+ let names;
104
+ try {
105
+ names = readdirSync(p);
106
+ } catch (e) {
107
+ throw new RevocationReadError(`cannot read --revocations directory ${p}: ${e.message}`);
108
+ }
109
+ // Deterministic order (sorted) so the governing tie-break + ignored-warning order are stable.
110
+ const files = names
111
+ .filter((n) => REVOCATION_FILE_RE.test(n))
112
+ .sort()
113
+ .map((n) => path.join(p, n));
114
+ const entries = [];
115
+ for (const f of files) {
116
+ let text;
117
+ try {
118
+ text = readFile(f);
119
+ } catch (e) {
120
+ entries.push({ _parseError: `cannot read ${path.basename(f)}: ${e.message}`, _raw: f });
121
+ continue;
122
+ }
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(text);
126
+ } catch (e) {
127
+ entries.push({ _parseError: `${path.basename(f)} is not valid JSON: ${e.message}`, _raw: f });
128
+ continue;
129
+ }
130
+ // A file may itself be a single container OR a JSON array of them — flatten either into the pool.
131
+ if (Array.isArray(parsed)) {
132
+ for (const el of parsed) entries.push(el);
133
+ } else if (isPlainObject(parsed)) {
134
+ entries.push(parsed);
135
+ } else {
136
+ entries.push({ _parseError: `${path.basename(f)} is not a revocation object/array`, _raw: f });
137
+ }
138
+ }
139
+ return entries;
140
+ }
141
+
142
+ // Single file: read its text and normalize (a non-JSON single file HARD-errors via normalizeRevocationsInput).
143
+ let text;
144
+ try {
145
+ text = readFile(p);
146
+ } catch (e) {
147
+ throw new RevocationReadError(`cannot read --revocations ${p}: ${e.message}`);
148
+ }
149
+ return normalizeRevocationsInput(text);
150
+ }
151
+
152
+ /**
153
+ * The ONE shared integration verify-vh calls: read the --revocations file-or-dir, resolve the --as-of
154
+ * (defaulting to nowISO), evaluate the decision, and fold it onto the verify result. Runs ONLY when
155
+ * `revocationsPath` is truthy; with no path it returns the result UNCHANGED + a null decision (the
156
+ * regression-safety contract — with no --revocations the verifier is byte-identical to today).
157
+ *
158
+ * @param {object} params { result, revocationsPath, asOf, nowISO, io? }
159
+ * @returns {{ result, decision, defaulted }}
160
+ */
161
+ function loadAndApply(params) {
162
+ if (!isPlainObject(params) || !isPlainObject(params.result)) {
163
+ throw new RevocationReadError("loadAndApply requires { result, revocationsPath, asOf, nowISO }");
164
+ }
165
+ const { result, revocationsPath, asOf, nowISO, io } = params;
166
+ if (!revocationsPath) {
167
+ return { result, decision: null, defaulted: false };
168
+ }
169
+ const { asOf: effectiveAsOf, defaulted } = resolveAsOf(asOf, nowISO);
170
+ const entries = readRevocationsFromPath(revocationsPath, io || {});
171
+ const out = applyToVerifyResult({ result, revocations: entries, asOf: effectiveAsOf });
172
+ return { result: out, decision: out.trustAsOf, defaulted };
173
+ }
174
+
175
+ module.exports = {
176
+ RevocationReadError: core.RevocationReadError,
177
+ SIGNED_REVOCATION_KIND: core.SIGNED_REVOCATION_KIND,
178
+ REVOCATION_KIND: core.REVOCATION_KIND,
179
+ REVOCATION_SCHEMA_VERSION: core.REVOCATION_SCHEMA_VERSION,
180
+ SUPPORTED_REVOCATION_SCHEMA_VERSIONS: core.SUPPORTED_REVOCATION_SCHEMA_VERSIONS,
181
+ SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS: core.SUPPORTED_SIGNED_REVOCATION_SCHEMA_VERSIONS,
182
+ REVOCATION_REASON_SET: core.REVOCATION_REASON_SET,
183
+ REVOCATION_FIELDS: core.REVOCATION_FIELDS,
184
+ REVOCATION_TRUST_NOTE: core.REVOCATION_TRUST_NOTE,
185
+ SIGNED_REVOCATION_TRUST_NOTE: core.SIGNED_REVOCATION_TRUST_NOTE,
186
+ ISO_INSTANT_RE: core.ISO_INSTANT_RE,
187
+ UNRECOVERABLE: core.UNRECOVERABLE,
188
+ parseCanonicalInstant: core.parseCanonicalInstant,
189
+ resolveAsOf: core.resolveAsOf,
190
+ serializeRevocation: core.serializeRevocation,
191
+ validateSignedRevocation: core.validateSignedRevocation,
192
+ verifyRevocation: core.verifyRevocation,
193
+ normalizeRevocationsInput: core.normalizeRevocationsInput,
194
+ classifyRevocation: core.classifyRevocation,
195
+ evaluateTrustAsOf: core.evaluateTrustAsOf,
196
+ applyToVerifyResult: core.applyToVerifyResult,
197
+ renderTrustAsOf: core.renderTrustAsOf,
198
+ readRevocationsFromPath,
199
+ loadAndApply,
200
+ };
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+
3
+ // verifier/lib/seal-cli.js — the FREE, ZERO-INSTALL "seal your own folder" PRODUCER, inlined verbatim into
4
+ // verifier/dist/seal-vh-standalone.js by verifier/build-standalone.js (T-36.2).
5
+ //
6
+ // WHY THIS EXISTS
7
+ // EPIC-35 made the FREE VERIFY side zero-install: a counterparty handed ONE sealed packet saves a single
8
+ // file (verify-vh-standalone.js) and runs it — no clone, no `npm install`. The symmetric gap was the FREE
9
+ // PRODUCE side: a stranger who wants to SEAL up to 25 of their OWN files (the free tier) still had to clone
10
+ // the repo and `npm install` the heavy ethers/hardhat stack, because `vh evidence seal` routes through
11
+ // cli/evidence.js -> cli/core/packetseal.js -> cli/hash.js, and cli/hash.js pulls keccak256 from `ethers`.
12
+ // This module is the LAST piece that closes the loop: a from-scratch sealer that `require`s NOTHING but
13
+ // Node core (fs/path) and the verifier's OWN merkle lib (which itself is zero-third-party in the bundle —
14
+ // keccak256 comes from the inlined pure-JS vendored implementation). The emitted bundle lets a prospect
15
+ // PRODUCE a free `vh.evidence-seal` of up to 25 files with NO install, hand it to a counterparty, and have
16
+ // THEM verify it with NO install — the whole organic adoption loop, self-service, before any sales call.
17
+ //
18
+ // FREE-TIER BOUNDARY (enforced here, not advisory)
19
+ // The free tier is an UNSIGNED seal of up to SAMPLE_LIMIT (25) files. This sealer:
20
+ // * HARD-ERRORS (exit 2) on a folder of MORE than 25 files, naming the paid `evidence_unlimited`
21
+ // entitlement + the full `vh evidence seal` command that unlocks it. It never silently truncates.
22
+ // * has NO `--sign` / `--license` / `--key` flag AT ALL — signing (`evidence_signed`) is the PAID
23
+ // surface and lives only in the full CLI. There is no way to produce a signed packet from this file.
24
+ // So the standalone is strictly the FREE half of the product: a try-before-you-buy producer whose output
25
+ // the paid signed wrap is layered on top of (the bytes this emits are the exact canonical bytes the paid
26
+ // `vh evidence seal --sign` would wrap, so an upgrade re-uses, never re-does, the free seal).
27
+ //
28
+ // BYTE-FOR-BYTE COMPATIBLE with the producer
29
+ // The seal this emits is BYTE-IDENTICAL to cli/evidence.js#serializeSeal over the same directory: the same
30
+ // `kind`, `schemaVersion`, `note`, `root`, `fileCount`, and per-file { relPath, contentHash, leaf } in the
31
+ // same canonical key order, terminated with one "\n". That is WHY the standalone-produced seal is accepted
32
+ // verbatim by verify-vh-standalone.js (and the in-tree verifier) — the free PRODUCE and free VERIFY halves
33
+ // interoperate with zero install on either side. The merkle convention (pathLeaf / leafHash / nodeHash /
34
+ // sorted-leaf tree) is the verifier's own ./merkle lib, the SAME math the verifier re-derives on the other
35
+ // side, so a seal this builds always re-derives to the same root the verifier recomputes from the bytes.
36
+ //
37
+ // HONEST POSTURE + I/O DISCIPLINE
38
+ // The seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp (the load-bearing `note` is
39
+ // stated once, below, byte-identical to the producer's). This file reads the named folder and writes ONLY
40
+ // the single output file the user names with `-o`/`--out` (or prints to stdout) — it NEVER writes cwd
41
+ // otherwise, opens NO socket, and uses NO key. Same inputs -> byte-identical bytes.
42
+
43
+ const fs = require("fs");
44
+ const path = require("path");
45
+
46
+ // The verifier's INDEPENDENT merkle convention (pathLeaf / hashBytes / rootFromFlat). In the bundle this is
47
+ // the inlined verifier/lib/merkle.js, whose keccak256 is the inlined pure-JS vendored implementation — so the
48
+ // whole sealer is zero-third-party. Out of the bundle (direct `node verifier/lib/seal-cli.js`) it resolves to
49
+ // the same in-tree merkle lib, which uses js-sha3; either way the math is byte-identical.
50
+ const merkle = require("./merkle");
51
+
52
+ // Exit contract — the SAME as cli/evidence.js's EXIT: 0 ok / 1 IO / 2 usage / 3 gate-fail. The free-tier
53
+ // >25-files boundary is a USAGE error (2): the invocation asked for a paid surface the free sealer cannot do.
54
+ const EXIT = Object.freeze({ OK: 0, IO: 1, USAGE: 2, FAIL: 3 });
55
+
56
+ // The free SAMPLE size, byte-identical to cli/evidence.js SAMPLE_LIMIT (25). Sealing MORE requires the paid
57
+ // `evidence_unlimited` entitlement via the full `vh evidence seal` command — this free sealer hard-errors.
58
+ const SAMPLE_LIMIT = 25;
59
+
60
+ const SEAL_KIND = "vh.evidence-seal";
61
+ const SEAL_SCHEMA_VERSION = 1;
62
+
63
+ // The TRUST-BOUNDARIES one-liner — BYTE-IDENTICAL to cli/evidence.js EVIDENCE_TRUST_NOTE. The seal's `note`
64
+ // field MUST equal this verbatim or the verifier's strict structural check (note must not drift) rejects the
65
+ // packet. Stated once here so the standalone can never silently soften the caveat.
66
+ const EVIDENCE_TRUST_NOTE =
67
+ "This evidence seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp. Its Merkle " +
68
+ "`root` commits to the full set of (relPath, content) pairs in the directory: any edit, rename, add, " +
69
+ "or remove changes the root, and verify RE-DERIVES the root from the bytes you hold and LOCALIZES the " +
70
+ "change to the exact file (MATCH / CHANGED / MISSING / UNEXPECTED). It does NOT prove WHEN the sealing " +
71
+ 'happened ("sealed at T" rides the human-owned signing/timestamp trust-root, STRATEGY.md P-3) and it ' +
72
+ "is NOT a legal opinion. The packet is an UNTRUSTED transport container: verify never trusts the " +
73
+ "packet's own stored hashes.";
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // FILESYSTEM WALK — recursively collect every regular file under dirAbs (skipping sockets/fifos/symlinks,
77
+ // exactly as cli/hash.js#listFiles does — they have no stable content hash). Returns absolute paths.
78
+ // ---------------------------------------------------------------------------
79
+ function listFiles(dirAbs) {
80
+ const out = [];
81
+ const entries = fs.readdirSync(dirAbs, { withFileTypes: true });
82
+ for (const entry of entries) {
83
+ const full = path.join(dirAbs, entry.name);
84
+ if (entry.isDirectory()) {
85
+ out.push(...listFiles(full));
86
+ } else if (entry.isFile()) {
87
+ out.push(full);
88
+ }
89
+ // sockets/fifos/symlinks are intentionally skipped (no stable content hash) — same as cli/hash.js.
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // Load a directory into a sorted [{ relPath, bytes }] list. relPath is POSIX-normalized + relative to dirAbs,
95
+ // matching cli/evidence.js#loadDirEntries EXACTLY (split on path.sep, join "/"), so the standalone seal
96
+ // travels with the directory identically to a producer-built one. Sorted by relPath for determinism.
97
+ function loadDirEntries(dirAbs) {
98
+ const files = listFiles(dirAbs);
99
+ const entries = files.map((abs) => {
100
+ const rel = path.relative(dirAbs, abs).split(path.sep).join("/");
101
+ return { relPath: rel, bytes: fs.readFileSync(abs) };
102
+ });
103
+ entries.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
104
+ return entries;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // PURE SEAL BUILD — over the verifier's own merkle convention. Mirrors cli/core/packetseal.js#buildSeal +
109
+ // cli/evidence.js#serializeSeal so the emitted bytes are byte-identical to the producer's. Throws a plain
110
+ // Error (named in the message) on a structural problem (e.g. a duplicate relPath) — the CLI maps it to exit 3.
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function buildSeal(entries) {
114
+ if (!Array.isArray(entries) || entries.length === 0) {
115
+ throw new Error("cannot build an evidence seal from zero files");
116
+ }
117
+ // Per-file (relPath, contentHash, leaf), de-duplicated on relPath (a duplicate is a hard error — every
118
+ // entry must occupy a distinct path, matching the producer core's invariant).
119
+ const seen = new Set();
120
+ const files = entries.map((e) => {
121
+ if (typeof e.relPath !== "string" || e.relPath.length === 0) {
122
+ throw new Error("evidence seal entry relPath must be a non-empty string");
123
+ }
124
+ if (seen.has(e.relPath)) {
125
+ throw new Error(`evidence seal has a duplicate relPath across the file set: ${JSON.stringify(e.relPath)}`);
126
+ }
127
+ seen.add(e.relPath);
128
+ const contentHash = merkle.hashBytes(e.bytes);
129
+ const leaf = merkle.pathLeaf(e.relPath, contentHash);
130
+ return { relPath: e.relPath, contentHash, leaf };
131
+ });
132
+ // Emit per-file leaves sorted by relPath so the seal bytes are deterministic regardless of input order
133
+ // (the producer core does the same), then re-derive the root over the SAME convention the verifier uses.
134
+ files.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
135
+ const root = merkle.rootFromFlat(files.map((f) => ({ relPath: f.relPath, contentHash: f.contentHash })));
136
+ return {
137
+ kind: SEAL_KIND,
138
+ schemaVersion: SEAL_SCHEMA_VERSION,
139
+ note: EVIDENCE_TRUST_NOTE,
140
+ root,
141
+ fileCount: files.length,
142
+ files,
143
+ };
144
+ }
145
+
146
+ // Serialize a built seal to canonical, byte-deterministic bytes — BYTE-IDENTICAL to cli/evidence.js#
147
+ // serializeSeal: an EXPLICIT key order, no insignificant whitespace, one trailing "\n". The producer builds
148
+ // the same ordered object literal and JSON.stringify(...)+"\n"; reproducing that literal here yields the
149
+ // identical bytes the verifier (and `sha256sum`) expect.
150
+ function serializeSeal(seal) {
151
+ const canonical = {
152
+ kind: seal.kind,
153
+ schemaVersion: seal.schemaVersion,
154
+ note: seal.note,
155
+ root: seal.root,
156
+ fileCount: seal.fileCount,
157
+ files: seal.files.map((e) => ({
158
+ relPath: e.relPath,
159
+ contentHash: e.contentHash,
160
+ leaf: e.leaf,
161
+ })),
162
+ };
163
+ return JSON.stringify(canonical) + "\n";
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // CLI — `seal-vh-standalone.js <folder> [-o <out>] [--json]`.
168
+ // Walks <folder>, enforces the free-tier boundary, builds the UNSIGNED seal, and writes it to -o/--out
169
+ // (caller-named; NEVER cwd) or prints it. There is DELIBERATELY no --sign/--license/--key flag: signing is
170
+ // the paid surface. Exit: 0 ok / 1 IO / 2 usage (incl. the >25-files paid boundary) / 3 seal-build error.
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function usage() {
174
+ return [
175
+ "seal-vh-standalone.js — FREE, zero-install evidence sealer (seal your own folder, hand it to anyone)",
176
+ "",
177
+ "Usage:",
178
+ " node seal-vh-standalone.js <folder> [-o <out.vhevidence.json>] [--json]",
179
+ "",
180
+ "Walks <folder> and binds every file into ONE tamper-evident `vh.evidence-seal` you can hand to a",
181
+ "counterparty; they verify it with verify-vh-standalone.js — no clone, no `npm install`, on either side.",
182
+ "",
183
+ "FREE tier: an UNSIGNED seal of up to " + SAMPLE_LIMIT + " files. Sealing MORE files, or a SIGNED",
184
+ "attestation wrap, is the PAID surface (`evidence_unlimited` / `evidence_signed`) via `vh evidence seal`.",
185
+ "There is no --sign/--license/--key flag here: this file produces only the free, unsigned seal.",
186
+ "",
187
+ "Exit codes: 0 sealed / 1 IO error / 2 usage (incl. >" + SAMPLE_LIMIT + " files) / 3 seal-build error.",
188
+ "It is READ-ONLY apart from the -o file you name, opens NO network, and uses NO key.",
189
+ "",
190
+ ].join("\n");
191
+ }
192
+
193
+ function parseArgs(argv) {
194
+ const opts = { folder: undefined, out: undefined, json: false, _positionals: [] };
195
+ for (let i = 0; i < argv.length; i++) {
196
+ const a = argv[i];
197
+ const need = (flag) => {
198
+ const v = argv[++i];
199
+ if (v === undefined) {
200
+ const e = new Error(`${flag} requires a value`);
201
+ e.usage = true;
202
+ throw e;
203
+ }
204
+ return v;
205
+ };
206
+ switch (a) {
207
+ case "-o":
208
+ case "--out":
209
+ opts.out = need(a);
210
+ break;
211
+ case "--json":
212
+ opts.json = true;
213
+ break;
214
+ case "-h":
215
+ case "--help":
216
+ opts.help = true;
217
+ break;
218
+ default:
219
+ if (a && a.startsWith("-")) {
220
+ const e = new Error(`unknown flag: ${a}`);
221
+ e.usage = true;
222
+ throw e;
223
+ }
224
+ opts._positionals.push(a);
225
+ }
226
+ }
227
+ if (opts._positionals.length > 1) {
228
+ const e = new Error(
229
+ `unexpected extra argument: ${opts._positionals[1]} (seal takes exactly one <folder>)`
230
+ );
231
+ e.usage = true;
232
+ throw e;
233
+ }
234
+ opts.folder = opts._positionals[0];
235
+ return opts;
236
+ }
237
+
238
+ // Run the sealer with an injectable io ({ write, writeErr }) so it is unit-testable without spawning a
239
+ // process. Returns the exit code. PURE except for the directory read + the single -o write.
240
+ function run(argv, io = {}) {
241
+ const write = io.write || ((s) => process.stdout.write(s));
242
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
243
+
244
+ let opts;
245
+ try {
246
+ opts = parseArgs(argv);
247
+ } catch (e) {
248
+ writeErr(`error: ${e.message}\n`);
249
+ return EXIT.USAGE;
250
+ }
251
+ if (opts.help) {
252
+ write(usage());
253
+ return EXIT.OK;
254
+ }
255
+ if (!opts.folder) {
256
+ writeErr("error: seal-vh-standalone requires a <folder> to seal\n\n");
257
+ writeErr(usage());
258
+ return EXIT.USAGE;
259
+ }
260
+
261
+ // Walk the folder (the only read I/O). A missing/unreadable folder or a non-directory is an IO error.
262
+ const dirAbs = path.resolve(opts.folder);
263
+ let stat;
264
+ try {
265
+ stat = fs.statSync(dirAbs);
266
+ } catch (e) {
267
+ writeErr(`error: cannot read folder ${opts.folder}: ${e.message}\n`);
268
+ return EXIT.IO;
269
+ }
270
+ if (!stat.isDirectory()) {
271
+ writeErr(`error: ${opts.folder} is not a directory\n`);
272
+ return EXIT.IO;
273
+ }
274
+ let entries;
275
+ try {
276
+ entries = loadDirEntries(dirAbs);
277
+ } catch (e) {
278
+ writeErr(`error: cannot read folder ${opts.folder}: ${e.message}\n`);
279
+ return EXIT.IO;
280
+ }
281
+ if (entries.length === 0) {
282
+ writeErr(`error: ${opts.folder} contains no files to seal\n`);
283
+ return EXIT.FAIL;
284
+ }
285
+
286
+ // FREE-TIER BOUNDARY — hard-error (exit 2) on more than SAMPLE_LIMIT files, naming the paid entitlement +
287
+ // the full command that unlocks it. The free sealer NEVER silently truncates or downgrades.
288
+ if (entries.length > SAMPLE_LIMIT) {
289
+ writeErr(
290
+ `error: this folder has ${entries.length} files, but the FREE sealer seals at most ${SAMPLE_LIMIT}.\n` +
291
+ `Sealing more than ${SAMPLE_LIMIT} files is the PAID "evidence_unlimited" entitlement — use the full ` +
292
+ "command:\n" +
293
+ " vh evidence seal <folder> --license <file> --vendor <0xaddr>\n" +
294
+ "(The free, zero-install sealer is strictly try-before-you-buy: up to " +
295
+ SAMPLE_LIMIT +
296
+ " files, unsigned.)\n"
297
+ );
298
+ return EXIT.USAGE;
299
+ }
300
+
301
+ // Build the UNSIGNED seal. A structural problem (e.g. a duplicate relPath) is a seal-build error (3).
302
+ let seal;
303
+ try {
304
+ seal = buildSeal(entries);
305
+ } catch (e) {
306
+ writeErr(`error: cannot build evidence seal: ${e.message}\n`);
307
+ return EXIT.FAIL;
308
+ }
309
+ const artifactStr = serializeSeal(seal);
310
+
311
+ // Write to -o/--out (caller-chosen path; NEVER cwd) or print to stdout (writes nothing to disk).
312
+ let outAbs = null;
313
+ if (opts.out) {
314
+ outAbs = path.resolve(opts.out);
315
+ try {
316
+ fs.writeFileSync(outAbs, artifactStr);
317
+ } catch (e) {
318
+ writeErr(`error: cannot write -o file ${opts.out}: ${e.message}\n`);
319
+ return EXIT.IO;
320
+ }
321
+ }
322
+
323
+ if (opts.json) {
324
+ write(
325
+ JSON.stringify(
326
+ {
327
+ ok: true,
328
+ note: EVIDENCE_TRUST_NOTE,
329
+ kind: SEAL_KIND,
330
+ root: seal.root,
331
+ fileCount: seal.fileCount,
332
+ signed: false,
333
+ out: outAbs,
334
+ // With no -o the artifact rides in `artifact` so --json never drops it (parity with the producer).
335
+ artifact: outAbs ? null : artifactStr,
336
+ },
337
+ null,
338
+ 2
339
+ ) + "\n"
340
+ );
341
+ } else {
342
+ write(EVIDENCE_TRUST_NOTE + "\n\n");
343
+ write(
344
+ `sealed ${seal.fileCount} file${seal.fileCount === 1 ? "" : "s"} into an evidence packet — root ${seal.root}\n`
345
+ );
346
+ if (outAbs) {
347
+ write(` written: ${outAbs}\n`);
348
+ write(` verify it: node verify-vh-standalone.js ${path.basename(outAbs)} --dir <folder>\n`);
349
+ } else {
350
+ write(artifactStr);
351
+ }
352
+ }
353
+ return EXIT.OK;
354
+ }
355
+
356
+ module.exports = {
357
+ EXIT,
358
+ SAMPLE_LIMIT,
359
+ SEAL_KIND,
360
+ SEAL_SCHEMA_VERSION,
361
+ EVIDENCE_TRUST_NOTE,
362
+ listFiles,
363
+ loadDirEntries,
364
+ buildSeal,
365
+ serializeSeal,
366
+ parseArgs,
367
+ usage,
368
+ run,
369
+ };
370
+
371
+ // CLI shim when this file is run directly (out of the bundle). Inside the bundle the boot wrapper drives run().
372
+ if (require.main === module) {
373
+ process.exit(run(process.argv.slice(2)));
374
+ }