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,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.0",
|
|
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
|
+
}
|