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,237 @@
1
+ "use strict";
2
+
3
+ // verifier/lib/seal-evidence.js — the FREE-TIER, ZERO-INSTALL evidence SEAL core (T-36.1).
4
+ //
5
+ // WHY THIS EXISTS
6
+ // `verifier/` already lets a stranger VERIFY a `vh.evidence-seal` with NO clone + NO `npm install`
7
+ // (verify-vh.js + ./merkle re-derive the root with no producer stack). The symmetric gap was the
8
+ // PRODUCE side: minting a seal still ran through cli/evidence.js -> cli/core/packetseal.js -> cli/hash.js,
9
+ // and cli/hash.js pulls keccak256/concat/toUtf8Bytes from `ethers`. So a prospect could verify a seal
10
+ // handed to them, but could not produce one of their OWN to hand to a counterparty without the heavy
11
+ // stack. This module closes that loop: a PURE, dependency-free `buildEvidenceSeal({ entries })` that emits
12
+ // an object whose canonical JSON is BYTE-IDENTICAL to what the paid cli/evidence.js seal path produces for
13
+ // the SAME { relPath, bytes } set — reusing the ALREADY-VENDORED, cross-checked ./merkle re-derivation of
14
+ // the family's pathLeaf / leafHash / nodeHash / root convention. No ethers, no js-sha3 (transitively via
15
+ // ./merkle -> ./keccak only), no parent-directory traversal, no bare third-party name. NO signing path
16
+ // lives here — signing is a PAID entitlement that stays in the producer stack; the free tier mints only
17
+ // the UNSIGNED baseline seal.
18
+ //
19
+ // HONEST POSTURE (must not drift from cli/evidence.js#EVIDENCE_TRUST_NOTE — pinned byte-for-byte below)
20
+ // The seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp and NOT a legal opinion. It
21
+ // is an UNTRUSTED transport container: verify (verify-vh.js / cli `vh evidence verify`) RE-DERIVES the root
22
+ // from the bytes the holder has, never from the seal's own stored hashes.
23
+ //
24
+ // PURE + I/O-FREE
25
+ // The CALLER reads the files and hands in already-loaded { relPath, bytes } entries. Nothing here touches
26
+ // the filesystem, the clock, the network, or a key — same inputs -> byte-identical bytes out.
27
+ //
28
+ // RELPATH CONTRACT (fail-closed, so the byte-identical promise can never SILENTLY break)
29
+ // Every relPath MUST already be in canonical POSIX form: forward-slash separators and NO leading "./"
30
+ // (i.e. relPath === toPosixRel(relPath)). This is exactly what a directory walk emits on a POSIX host
31
+ // (cli/evidence.js#loadDirEntries does `path.relative(...).split(path.sep).join("/")`), so the normal
32
+ // producer flow already satisfies it. A relPath that is NOT canonical (a Windows-style backslash, a
33
+ // leading "./") is REJECTED with a named FreeSealError rather than re-normalized — because the free core
34
+ // and the paid producer use DIFFERENT normalizations for those forms (the producer keeps a literal
35
+ // backslash as a content byte on POSIX; "./x" makes the producer throw), so silently accepting them
36
+ // would mint a seal that does NOT byte-match the paid path. Fail-closed means: for any input the free
37
+ // core ACCEPTS, its seal is byte-identical to the paid seal; for inputs it cannot guarantee that, it
38
+ // refuses (a symmetric, named rejection) instead of producing a surprising, non-reproducible artifact.
39
+
40
+ const merkle = require("./merkle");
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Product framing — byte-identical to cli/evidence.js. These are the discriminators + the in-band trust
44
+ // caveat the paid serializeSeal stamps into every seal; reproducing them here (NOT importing cli/) keeps
45
+ // the free seal byte-identical without depending on the producer stack. A drift guard in the parity test
46
+ // asserts these equal the producer's exported constants, so they can never silently diverge.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const SEAL_KIND = "vh.evidence-seal";
50
+ const SEAL_SCHEMA_VERSION = 1;
51
+
52
+ // The free SAMPLE size: how many files an UNLICENSED packet may seal. The free seal core refuses to mint a
53
+ // packet over this cap — sealing more is the paid `evidence_unlimited` entitlement, which stays in the
54
+ // producer stack. Kept byte-identical to cli/evidence.js#SAMPLE_LIMIT (drift-guarded in the parity test).
55
+ const SAMPLE_LIMIT = 25;
56
+
57
+ // The TRUST-BOUNDARIES one-liner, byte-for-byte identical to cli/evidence.js#EVIDENCE_TRUST_NOTE. It is the
58
+ // load-bearing honesty of the artifact and is committed into the seal bytes, so it MUST match exactly.
59
+ const EVIDENCE_TRUST_NOTE =
60
+ "This evidence seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp. Its Merkle " +
61
+ "`root` commits to the full set of (relPath, content) pairs in the directory: any edit, rename, add, " +
62
+ "or remove changes the root, and verify RE-DERIVES the root from the bytes you hold and LOCALIZES the " +
63
+ "change to the exact file (MATCH / CHANGED / MISSING / UNEXPECTED). It does NOT prove WHEN the sealing " +
64
+ 'happened ("sealed at T" rides the human-owned signing/timestamp trust-root, STRATEGY.md P-3) and it ' +
65
+ "is NOT a legal opinion. The packet is an UNTRUSTED transport container: verify never trusts the " +
66
+ "packet's own stored hashes.";
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // A NAMED error so a caller/test catches ONE free-seal error rather than a bare Error or a TypeError leaking
70
+ // from the hasher. A malformed entry set raises this rather than being silently coerced or partly accepted.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ class FreeSealError extends Error {
74
+ constructor(message) {
75
+ super(message);
76
+ this.name = "FreeSealError";
77
+ }
78
+ }
79
+
80
+ function isPlainObject(v) {
81
+ return v != null && typeof v === "object" && !Array.isArray(v);
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // _normalizeEntries(params) — STRICT validation + normalization of the caller-supplied flat file set into
86
+ // the ordered { relPath, bytes } list the hasher consumes. Same per-entry strictness the producer's
87
+ // packetseal._normalizeEntries enforces (non-empty relPath, unique across the set, Buffer/Uint8Array bytes),
88
+ // and the free-tier-only constraints: a non-empty set capped at SAMPLE_LIMIT files. Throws FreeSealError on
89
+ // the FIRST problem; never half-accepts.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ function _normalizeEntries(params) {
93
+ if (!isPlainObject(params)) {
94
+ throw new FreeSealError("buildEvidenceSeal requires a { entries } object");
95
+ }
96
+ const raw = Array.isArray(params.entries) ? params.entries : null;
97
+ if (raw === null) {
98
+ throw new FreeSealError("evidence seal `entries` must be an array of { relPath, bytes }");
99
+ }
100
+ if (raw.length === 0) {
101
+ throw new FreeSealError("evidence seal `entries` must be a non-empty array of { relPath, bytes }");
102
+ }
103
+ if (raw.length > SAMPLE_LIMIT) {
104
+ throw new FreeSealError(
105
+ `the free evidence seal is limited to ${SAMPLE_LIMIT} files (got ${raw.length}); sealing more ` +
106
+ "is the paid `evidence_unlimited` entitlement in the full verifyhash CLI (`vh evidence seal`)."
107
+ );
108
+ }
109
+
110
+ const seen = new Set();
111
+ const entries = raw.map((e) => {
112
+ if (!isPlainObject(e)) {
113
+ throw new FreeSealError("evidence seal entry must be an object with relPath + bytes");
114
+ }
115
+ if (typeof e.relPath !== "string" || e.relPath.length === 0) {
116
+ throw new FreeSealError("evidence seal entry relPath must be a non-empty string");
117
+ }
118
+ // FAIL-CLOSED on non-canonical relPaths (see RELPATH CONTRACT above). A relPath that toPosixRel would
119
+ // change (a backslash separator, a leading "./") does NOT byte-match the paid producer's normalization,
120
+ // so we refuse it BEFORE the dedup/hash rather than silently mint a non-parity seal. Checked against the
121
+ // SAME merkle.toPosixRel the leaf/root math uses, so the guard and the hashing can never disagree.
122
+ if (e.relPath !== merkle.toPosixRel(e.relPath)) {
123
+ throw new FreeSealError(
124
+ `evidence seal entry relPath must be canonical POSIX form (forward slashes, no leading "./"): ` +
125
+ `got ${JSON.stringify(e.relPath)}, expected ${JSON.stringify(merkle.toPosixRel(e.relPath))}. ` +
126
+ "Normalize relPaths before sealing (a POSIX directory walk already does this); the free core " +
127
+ "refuses non-canonical paths so its seal stays byte-identical to the paid CLI."
128
+ );
129
+ }
130
+ if (seen.has(e.relPath)) {
131
+ throw new FreeSealError(
132
+ `evidence seal has a duplicate relPath across the file set: ${JSON.stringify(e.relPath)} ` +
133
+ "(every entry must occupy a distinct path)"
134
+ );
135
+ }
136
+ seen.add(e.relPath);
137
+ if (!(e.bytes instanceof Uint8Array) && !Buffer.isBuffer(e.bytes)) {
138
+ throw new FreeSealError(
139
+ `evidence seal entry ${JSON.stringify(e.relPath)} bytes must be a Buffer/Uint8Array ` +
140
+ "(the core is I/O-free; the caller reads the file and hands in its bytes)"
141
+ );
142
+ }
143
+ const bytes = Buffer.isBuffer(e.bytes) ? e.bytes : Buffer.from(e.bytes);
144
+ return { relPath: e.relPath, bytes };
145
+ });
146
+ return entries;
147
+ }
148
+
149
+ // Stable string comparator matching cli/core/packetseal.js#buildSeal's file sort (and serializeSeal's
150
+ // emitted order): relPath ascending by JS string comparison. Kept in ONE place so build + serialize agree.
151
+ function _byRelPath(a, b) {
152
+ return a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // buildEvidenceSeal({ entries }) — mint a BARE, UNSIGNED evidence seal object from a flat { relPath, bytes }
157
+ // entry list. PURE + deterministic: same { relPath, bytes } set -> byte-identical seal, regardless of the
158
+ // caller's array order (the files array is emitted sorted by relPath).
159
+ //
160
+ // Each file's per-record fields are re-derived via the ALREADY-VENDORED ./merkle convention (the SAME math
161
+ // cli/hash.js computes with ethers, cross-checked byte-for-byte in test/verifier.cli.test.js):
162
+ // contentHash = keccak256(bytes) (merkle.hashBytes)
163
+ // leaf = keccak256(DIR_LEAF_DOMAIN ++ relPath ++ 0x00 ++ contentHash) (merkle.pathLeaf)
164
+ // root = sorted-leaf, domain-separated Merkle root over the path-bound leaves (merkle.rootFromFlat)
165
+ //
166
+ // Returns: { kind, schemaVersion, note, root, fileCount, files: [{ relPath, contentHash, leaf }] }
167
+ // — the EXACT shape + key order cli/evidence.js#serializeSeal emits, so serializeEvidenceSeal(...) below is
168
+ // byte-identical to the paid path.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function buildEvidenceSeal(params) {
172
+ const entries = _normalizeEntries(params);
173
+
174
+ // Per-file (contentHash, leaf), sorted by relPath so the seal bytes are deterministic regardless of the
175
+ // caller's input order — exactly as the producer's buildSeal sorts its emitted `files`.
176
+ const files = entries
177
+ .map((e) => {
178
+ const contentHash = merkle.hashBytes(e.bytes);
179
+ const leaf = merkle.pathLeaf(e.relPath, contentHash);
180
+ return { relPath: e.relPath, contentHash, leaf };
181
+ })
182
+ .sort(_byRelPath);
183
+
184
+ // The root is re-derived from the SAME (relPath, contentHash) leaves the producer commits to. Order does
185
+ // not affect the root (rootFromLeaves sorts the leaves), but we feed the already-sorted list for clarity.
186
+ const root = merkle.rootFromFlat(files.map((f) => ({ relPath: f.relPath, contentHash: f.contentHash })));
187
+
188
+ return {
189
+ kind: SEAL_KIND,
190
+ schemaVersion: SEAL_SCHEMA_VERSION,
191
+ note: EVIDENCE_TRUST_NOTE,
192
+ root,
193
+ fileCount: files.length,
194
+ files,
195
+ };
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // serializeEvidenceSeal(seal) — the canonical, byte-deterministic serialization (newline-terminated),
200
+ // byte-identical to cli/evidence.js#serializeSeal. Emits an EXPLICIT ordered object literal (top-level key
201
+ // order kind, schemaVersion, note, root, fileCount, files; per-file relPath, contentHash, leaf), no
202
+ // insignificant whitespace, a single trailing "\n". Accepts the object buildEvidenceSeal returned (or any
203
+ // structurally equivalent seal); does NOT re-validate (the producer's serializeSeal validates first, but the
204
+ // free path always serializes a seal it just built, so the structure is known-good).
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function serializeEvidenceSeal(seal) {
208
+ if (!isPlainObject(seal)) {
209
+ throw new FreeSealError("serializeEvidenceSeal requires a seal object");
210
+ }
211
+ if (!Array.isArray(seal.files)) {
212
+ throw new FreeSealError("serializeEvidenceSeal requires a seal with a `files` array");
213
+ }
214
+ const canonical = {
215
+ kind: seal.kind,
216
+ schemaVersion: seal.schemaVersion,
217
+ note: seal.note,
218
+ root: seal.root,
219
+ fileCount: seal.fileCount,
220
+ files: seal.files.map((e) => ({
221
+ relPath: e.relPath,
222
+ contentHash: e.contentHash,
223
+ leaf: e.leaf,
224
+ })),
225
+ };
226
+ return JSON.stringify(canonical) + "\n";
227
+ }
228
+
229
+ module.exports = {
230
+ SEAL_KIND,
231
+ SEAL_SCHEMA_VERSION,
232
+ SAMPLE_LIMIT,
233
+ EVIDENCE_TRUST_NOTE,
234
+ FreeSealError,
235
+ buildEvidenceSeal,
236
+ serializeEvidenceSeal,
237
+ };
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+
3
+ // verifier/lib/secp256k1-recover.js — INDEPENDENT EIP-191 personal_sign signer recovery.
4
+ //
5
+ // WHY THIS EXISTS
6
+ // `verifier/` is a near-zero-dependency, SECOND implementation of the family's signature recovery, kept
7
+ // deliberately separate from the production ethers path so the two can be CROSS-CHECKED and can never
8
+ // silently drift (the anti-divergence guard in test/verifier.crypto.test.js). This file recovers the
9
+ // secp256k1 signer ADDRESS from an `eip191-personal-sign` 65-byte (r||s||v) signature over a message,
10
+ // using ONLY:
11
+ // * `js-sha3` (via ./keccak) for keccak256, and
12
+ // * a single, tiny, vendored elliptic-curve routine (below) for the secp256k1 public-key RECOVERY.
13
+ // It does NOT require `ethers`, `hardhat`, `cli/`, or `trustledger/`.
14
+ //
15
+ // THE secp256k1 ROUTINE (vendored, audited, standard)
16
+ // Public-key recovery from an ECDSA signature is textbook curve math over the secp256k1 group
17
+ // (SEC 1, §4.1.6 / §2.3). We implement exactly that with Node BigInt: affine point add/double on
18
+ // y^2 = x^3 + 7 (mod p), a constant-time-agnostic double-and-add scalar multiply, and a Tonelli-Shanks
19
+ // style square root (p ≡ 3 mod 4, so √a = a^((p+1)/4)). No randomness, no secrets — recovery is a PUBLIC
20
+ // computation over the signature + message hash, so timing/side-channels are irrelevant here. The curve
21
+ // constants are the canonical secp256k1 domain parameters.
22
+
23
+ const { keccak256 } = require("./keccak");
24
+
25
+ // ---- secp256k1 domain parameters (canonical) -------------------------------------------------------
26
+ const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; // field prime
27
+ const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; // group order
28
+ const A = 0n; // curve a
29
+ const B = 7n; // curve b
30
+ const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
31
+ const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
32
+
33
+ // ---- modular arithmetic helpers --------------------------------------------------------------------
34
+ function mod(a, m) {
35
+ const r = a % m;
36
+ return r >= 0n ? r : r + m;
37
+ }
38
+
39
+ // Modular inverse via the extended Euclidean algorithm (m is prime here, so a is invertible unless 0).
40
+ function invmod(a, m) {
41
+ a = mod(a, m);
42
+ if (a === 0n) throw new Error("secp256k1: inverse of zero");
43
+ let [old_r, r] = [a, m];
44
+ let [old_s, s] = [1n, 0n];
45
+ while (r !== 0n) {
46
+ const q = old_r / r;
47
+ [old_r, r] = [r, old_r - q * r];
48
+ [old_s, s] = [s, old_s - q * s];
49
+ }
50
+ return mod(old_s, m);
51
+ }
52
+
53
+ // Modular exponentiation (square-and-multiply).
54
+ function powmod(base, exp, m) {
55
+ base = mod(base, m);
56
+ let result = 1n;
57
+ while (exp > 0n) {
58
+ if (exp & 1n) result = mod(result * base, m);
59
+ base = mod(base * base, m);
60
+ exp >>= 1n;
61
+ }
62
+ return result;
63
+ }
64
+
65
+ // Square root mod p. secp256k1's p ≡ 3 (mod 4), so √a = a^((p+1)/4) mod p (when a is a QR).
66
+ function sqrtmod(a) {
67
+ const r = powmod(a, (P + 1n) / 4n, P);
68
+ if (mod(r * r, P) !== mod(a, P)) throw new Error("secp256k1: no square root (x not on curve)");
69
+ return r;
70
+ }
71
+
72
+ // ---- elliptic-curve point arithmetic (affine; null = point at infinity) ---------------------------
73
+ const INF = null;
74
+
75
+ function isInf(Pt) {
76
+ return Pt === INF;
77
+ }
78
+
79
+ function pointAdd(p1, p2) {
80
+ if (isInf(p1)) return p2;
81
+ if (isInf(p2)) return p1;
82
+ const [x1, y1] = p1;
83
+ const [x2, y2] = p2;
84
+ if (x1 === x2 && mod(y1 + y2, P) === 0n) return INF; // p1 = -p2
85
+ let m;
86
+ if (x1 === x2 && y1 === y2) {
87
+ // doubling: m = (3x^2 + a) / (2y)
88
+ m = mod((3n * x1 * x1 + A) * invmod(2n * y1, P), P);
89
+ } else {
90
+ m = mod((y2 - y1) * invmod(x2 - x1, P), P);
91
+ }
92
+ const x3 = mod(m * m - x1 - x2, P);
93
+ const y3 = mod(m * (x1 - x3) - y1, P);
94
+ return [x3, y3];
95
+ }
96
+
97
+ function scalarMul(k, point) {
98
+ k = mod(k, N);
99
+ let result = INF;
100
+ let addend = point;
101
+ while (k > 0n) {
102
+ if (k & 1n) result = pointAdd(result, addend);
103
+ addend = pointAdd(addend, addend);
104
+ k >>= 1n;
105
+ }
106
+ return result;
107
+ }
108
+
109
+ const G = [GX, GY];
110
+
111
+ // Decompress the curve point with x-coordinate `x` and the given y-parity (0 = even, 1 = odd).
112
+ function liftX(x, yParity) {
113
+ const alpha = mod(x * x * x + A * x + B, P); // y^2
114
+ let y = sqrtmod(alpha);
115
+ if ((y & 1n) !== BigInt(yParity)) y = mod(P - y, P);
116
+ return [x, y];
117
+ }
118
+
119
+ // ---- big-endian buffer <-> BigInt ------------------------------------------------------------------
120
+ function bufToBig(buf) {
121
+ let n = 0n;
122
+ for (const b of buf) n = (n << 8n) | BigInt(b);
123
+ return n;
124
+ }
125
+
126
+ function bigTo32(n) {
127
+ const out = Buffer.alloc(32);
128
+ for (let i = 31; i >= 0; i--) {
129
+ out[i] = Number(n & 0xffn);
130
+ n >>= 8n;
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Recover the secp256k1 PUBLIC KEY from an ECDSA signature + 32-byte message hash (SEC 1, §4.1.6).
137
+ * @param {Buffer} msgHash 32-byte hash that was signed
138
+ * @param {bigint} r signature r
139
+ * @param {bigint} s signature s
140
+ * @param {number} recId recovery id 0..3 (derived from v)
141
+ * @returns {{x: bigint, y: bigint}} the recovered public-key point
142
+ */
143
+ function recoverPublicKey(msgHash, r, s, recId) {
144
+ if (r <= 0n || r >= N) throw new Error("secp256k1: r out of range");
145
+ if (s <= 0n || s >= N) throw new Error("secp256k1: s out of range");
146
+ if (recId < 0 || recId > 3) throw new Error("secp256k1: invalid recovery id");
147
+
148
+ // x = r + (recId >> 1) * N (the high bit of recId says whether r overflowed the field by one order)
149
+ const x = r + (recId >> 1 ? N : 0n);
150
+ if (x >= P) throw new Error("secp256k1: recovered x not in field");
151
+
152
+ // R = point with x-coordinate x and y-parity = (recId & 1).
153
+ const R = liftX(x, recId & 1);
154
+
155
+ // Q = r^-1 (s*R - e*G), where e = msgHash mod N.
156
+ const e = mod(bufToBig(msgHash), N);
157
+ const rInv = invmod(r, N);
158
+ const sR = scalarMul(s, R);
159
+ const eG = scalarMul(e, G);
160
+ const negEG = isInf(eG) ? INF : [eG[0], mod(P - eG[1], P)];
161
+ const Q = scalarMul(rInv, pointAdd(sR, negEG));
162
+ if (isInf(Q)) throw new Error("secp256k1: recovered point at infinity");
163
+ return { x: Q[0], y: Q[1] };
164
+ }
165
+
166
+ /**
167
+ * Derive the lowercase 0x Ethereum address from a recovered public-key point.
168
+ * address = "0x" + last 20 bytes of keccak256( X(32) || Y(32) ).
169
+ */
170
+ function pubKeyToAddress(pub) {
171
+ const raw = Buffer.concat([bigTo32(pub.x), bigTo32(pub.y)]); // 64-byte uncompressed (no 0x04 prefix)
172
+ const hash = keccak256(raw);
173
+ return "0x" + hash.slice(12).toString("hex");
174
+ }
175
+
176
+ /**
177
+ * Build the EIP-191 personal_sign pre-image for a message and return its keccak256 digest.
178
+ *
179
+ * EIP-191 personal_sign: keccak256( "\x19Ethereum Signed Message:\n" + <decimal byte length> + <message> ),
180
+ * where <message> is the EXACT canonical UTF-8 bytes (here, the canonical attestation string including its
181
+ * single trailing newline). This reproduces, byte-for-byte, what `cli/core/attestation.js` documents and
182
+ * what ethers' personal_sign hashes.
183
+ *
184
+ * @param {Buffer|Uint8Array|string} message UTF-8 message (string is encoded as UTF-8)
185
+ * @returns {Buffer} the 32-byte EIP-191 digest
186
+ */
187
+ function eip191Hash(message) {
188
+ const msgBytes = Buffer.isBuffer(message)
189
+ ? message
190
+ : message instanceof Uint8Array
191
+ ? Buffer.from(message)
192
+ : Buffer.from(String(message), "utf8");
193
+ const prefix = Buffer.from("\x19Ethereum Signed Message:\n" + msgBytes.length, "utf8");
194
+ return keccak256(Buffer.concat([prefix, msgBytes]));
195
+ }
196
+
197
+ /**
198
+ * Recover the lowercase 0x signer ADDRESS from an `eip191-personal-sign` 65-byte (r||s||v) signature over
199
+ * `message`. INDEPENDENT of ethers/hardhat — only ./keccak (js-sha3) + the vendored secp256k1 above.
200
+ *
201
+ * @param {Buffer|Uint8Array|string} message the EXACT canonical UTF-8 bytes that were signed
202
+ * @param {string|Buffer|Uint8Array} signature 65-byte r(32)||s(32)||v(1), as 0x-hex or raw bytes
203
+ * @returns {string} the recovered signer address, 0x-prefixed lowercase
204
+ */
205
+ function recoverPersonalSignAddress(message, signature) {
206
+ const sig = normalizeSig(signature);
207
+ const r = bufToBig(sig.subarray(0, 32));
208
+ const s = bufToBig(sig.subarray(32, 64));
209
+ let v = sig[64];
210
+ // Accept v in {0,1} or {27,28} (and EIP-155-ish higher v reduced to parity). recId is v's low bit.
211
+ if (v >= 27) v -= 27;
212
+ if (v !== 0 && v !== 1) {
213
+ // Fall back to parity for any non-canonical encoding; reject only the wildly invalid.
214
+ v = v & 1;
215
+ }
216
+ const digest = eip191Hash(message);
217
+ const pub = recoverPublicKey(digest, r, s, v);
218
+ return pubKeyToAddress(pub);
219
+ }
220
+
221
+ function normalizeSig(signature) {
222
+ let buf;
223
+ if (Buffer.isBuffer(signature)) {
224
+ buf = signature;
225
+ } else if (signature instanceof Uint8Array) {
226
+ buf = Buffer.from(signature);
227
+ } else if (typeof signature === "string") {
228
+ const hex = signature.startsWith("0x") || signature.startsWith("0X") ? signature.slice(2) : signature;
229
+ if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) {
230
+ throw new Error("secp256k1: signature must be 0x-hex (even length)");
231
+ }
232
+ buf = Buffer.from(hex, "hex");
233
+ } else {
234
+ throw new TypeError("secp256k1: signature must be a 0x-hex string or byte buffer");
235
+ }
236
+ if (buf.length !== 65) {
237
+ throw new Error(`secp256k1: eip191-personal-sign signature must be 65 bytes (r||s||v), got ${buf.length}`);
238
+ }
239
+ return buf;
240
+ }
241
+
242
+ module.exports = {
243
+ recoverPersonalSignAddress,
244
+ eip191Hash,
245
+ recoverPublicKey,
246
+ pubKeyToAddress,
247
+ // exported for tests/audit:
248
+ _internal: { mod, invmod, powmod, sqrtmod, pointAdd, scalarMul, liftX, G, N, P },
249
+ };
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "verify-vh",
3
+ "version": "0.1.2",
4
+ "description": "Standalone, read-only, OFFLINE verifier for verifyhash artifacts (evidence seals, reconciliation seals, dataset attestations, proof bundles). Near-zero-dependency: js-sha3 + a tiny vendored secp256k1 recovery — explicitly NO ethers/hardhat, so a third party can npm install it alone and audit it in an afternoon.",
5
+ "license": "Apache-2.0",
6
+ "bin": {
7
+ "verify-vh": "verify-vh.js"
8
+ },
9
+ "files": [
10
+ "verify-vh.js",
11
+ "lib/"
12
+ ],
13
+ "main": "verify-vh.js",
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/verifyhash/verifyhash.git",
20
+ "directory": "verifier"
21
+ },
22
+ "homepage": "https://github.com/verifyhash/verifyhash/tree/main/verifier#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/verifyhash/verifyhash/issues"
25
+ },
26
+ "keywords": [
27
+ "verify",
28
+ "merkle",
29
+ "keccak",
30
+ "provenance",
31
+ "tamper-evident",
32
+ "attestation",
33
+ "offline",
34
+ "evidence"
35
+ ],
36
+ "dependencies": {
37
+ "js-sha3": "^0.8.0"
38
+ }
39
+ }