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.
- package/README.md +5 -3
- package/cli/agent-hook.js +431 -0
- package/docs/ADOPT.md +15 -5
- package/docs/AGENT-HOOK.md +111 -0
- package/docs/PUBLISH-VERIFY-VH.md +45 -0
- package/examples/README.md +185 -0
- package/examples/policy.lenient.json +5 -0
- package/examples/policy.strict.json +6 -0
- package/examples/run.js +366 -0
- package/examples/sample-dataset/README.txt +10 -0
- package/examples/sample-dataset/corpus/cc-by-poem.txt +8 -0
- package/examples/sample-dataset/corpus/mit-notes.txt +4 -0
- package/examples/sample-dataset/data/unlabeled.txt +5 -0
- package/examples/sample-dataset/vendored/gpl-snippet.txt +5 -0
- package/examples/sample-dataset.hints.json +7 -0
- package/examples/sample-parcel/data/manifest-of-contents.txt +7 -0
- package/examples/sample-parcel/data/records.csv +4 -0
- package/examples/sample-parcel/delivery-note.txt +9 -0
- package/package.json +25 -3
- package/verifier/README.md +555 -0
- package/verifier/action/README.md +87 -0
- package/verifier/action/action.yml +146 -0
- package/verifier/build-standalone-html.js +1287 -0
- package/verifier/build-standalone.js +989 -0
- package/verifier/ci/journal.generic.sh +96 -0
- package/verifier/ci/journal.github-actions.yml +99 -0
- package/verifier/ci/reproduce-vh.generic.sh +59 -0
- package/verifier/ci/reproduce-vh.github-actions.yml +49 -0
- package/verifier/ci/verify-service.generic.sh +96 -0
- package/verifier/ci/verify-service.github-actions.yml +88 -0
- package/verifier/ci/verify-vh.generic.sh +75 -0
- package/verifier/ci/verify-vh.github-actions.yml +56 -0
- package/verifier/dist/BUILD-PROVENANCE.json +210 -0
- package/verifier/dist/seal-vh-standalone.js +876 -0
- package/verifier/dist/seal-vh-standalone.js.sha256 +1 -0
- package/verifier/dist/verify-vh-standalone.html +3373 -0
- package/verifier/dist/verify-vh-standalone.html.sha256 +1 -0
- package/verifier/dist/verify-vh-standalone.js +4121 -0
- package/verifier/dist/verify-vh-standalone.js.sha256 +1 -0
- package/verifier/lib/canonical.js +141 -0
- package/verifier/lib/keccak.js +30 -0
- package/verifier/lib/keccak256-vendored.js +206 -0
- package/verifier/lib/merkle.js +145 -0
- package/verifier/lib/revocation-core.js +606 -0
- package/verifier/lib/revocation.js +200 -0
- package/verifier/lib/seal-cli.js +374 -0
- package/verifier/lib/seal-evidence.js +237 -0
- package/verifier/lib/secp256k1-recover.js +249 -0
- package/verifier/package.json +39 -0
- package/verifier/verify-vh.js +2374 -0
- package/docs/ADOPTION.json +0 -11
- package/docs/AUDIT.md +0 -55
- package/docs/DECIDE.md +0 -47
- package/docs/DECISIONS-PENDING.md +0 -27
- package/docs/DEPLOY-PUBLIC-SITE.md +0 -301
- package/docs/ENGINE-LEDGER.json +0 -12
- package/docs/LOOP-AUDIT-2026-07-03.json +0 -580
- package/docs/LOOP-HARDENING-PLAN.md +0 -44
- package/docs/METRICS.jsonl +0 -31
- package/docs/MORNING.md +0 -204
- package/docs/STRATEGY-ARCHIVE.md +0 -5055
- package/docs/SUPERVISOR-RUNBOOK.md +0 -52
- 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
|
+
}
|